Local vaults without a server; Snap package; version 1.2.2
The New vault tab can now skip the server, as Apple and Android already do. The snap writes its browser manifests on start, since a strict snap can't install them itself. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kestrove-desktop",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kestrove-desktop",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.12.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kestrove-desktop",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Builds the Snap Store package. Needs snapcraft and LXD. Run from anywhere; kestrove-core must sit next to this repo.
|
||||
# The snap is built from a copy, because snapcraft only sees files under the project and the app needs both repos.
|
||||
#
|
||||
# scripts/snap.sh writes kestrove_<version>_amd64.snap to ../release/desktop
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
OUT=$(pwd)/release/desktop
|
||||
WORK=$(mktemp -d "${TMPDIR:-$HOME}/kestrove-snap.XXXXXX")
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
tar --exclude=node_modules --exclude=target --exclude=dist --exclude=.git -cf - kestrove-core kestrove-desktop | tar -xf - -C "$WORK"
|
||||
cp -r kestrove-desktop/snap "$WORK/snap"
|
||||
mkdir -p "$OUT"
|
||||
(cd "$WORK" && snapcraft pack && cp kestrove_*.snap "$OUT/")
|
||||
ls -la "$OUT"/kestrove_*.snap
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Kestrove
|
||||
Comment=Self-hosted, end-to-end encrypted password manager
|
||||
Exec=kestrove %U
|
||||
Icon=${SNAP}/meta/gui/kestrove.png
|
||||
Categories=Utility;Security;
|
||||
StartupWMClass=kestrove-desktop
|
||||
Terminal=false
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,62 @@
|
||||
# Kestrove for the Snap Store. Build with scripts/snap.sh, which lays out kestrove-core next to kestrove-desktop.
|
||||
name: kestrove
|
||||
title: Kestrove
|
||||
base: core24
|
||||
version: "1.2.2"
|
||||
summary: Self-hosted, end-to-end encrypted password manager
|
||||
description: |
|
||||
Kestrove keeps your passwords, passkeys and one-time codes in a vault encrypted on your device. Sync it through a
|
||||
server you run yourself, or keep it on one computer. The Kestrove browser extension fills logins through this app.
|
||||
license: Apache-2.0
|
||||
website: https://kestrove.samialabri.com
|
||||
grade: stable
|
||||
confinement: strict
|
||||
|
||||
plugs:
|
||||
# Lets the app write the browser manifests (src-tauri/src/snap.rs). Needs store approval to connect automatically.
|
||||
browser-native-hosts:
|
||||
interface: personal-files
|
||||
write:
|
||||
- $HOME/.mozilla/native-messaging-hosts
|
||||
- $HOME/.config/google-chrome/NativeMessagingHosts
|
||||
- $HOME/.config/chromium/NativeMessagingHosts
|
||||
- $HOME/.config/microsoft-edge/NativeMessagingHosts
|
||||
- $HOME/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts
|
||||
|
||||
apps:
|
||||
kestrove:
|
||||
command: usr/bin/kestrove-desktop
|
||||
extensions: [gnome]
|
||||
plugs:
|
||||
- network
|
||||
- password-manager-service
|
||||
- browser-native-hosts
|
||||
nmh:
|
||||
command: usr/bin/kestrove-nmh
|
||||
|
||||
parts:
|
||||
kestrove:
|
||||
plugin: nil
|
||||
source: .
|
||||
build-snaps:
|
||||
- node/22/stable
|
||||
build-packages:
|
||||
- curl
|
||||
- libayatana-appindicator3-dev
|
||||
- librsvg2-dev
|
||||
- libsecret-1-dev
|
||||
- libssl-dev
|
||||
- libxdo-dev
|
||||
- patchelf
|
||||
- pkg-config
|
||||
stage-packages:
|
||||
- libayatana-appindicator3-1
|
||||
- libxdo3
|
||||
override-build: |
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
. "$HOME/.cargo/env"
|
||||
cd kestrove-desktop
|
||||
npm ci --no-audit --no-fund
|
||||
npm run tauri build -- --no-bundle
|
||||
install -D src-tauri/target/release/kestrove-desktop "$CRAFT_PART_INSTALL/usr/bin/kestrove-desktop"
|
||||
install -D src-tauri/target/release/kestrove-nmh "$CRAFT_PART_INSTALL/usr/bin/kestrove-nmh"
|
||||
Generated
+1
-1
@@ -3064,7 +3064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kestrove-desktop"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kestrove-desktop"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod autolock;
|
||||
pub mod device_key;
|
||||
pub mod hello;
|
||||
pub mod qr;
|
||||
pub mod snap;
|
||||
pub mod sync;
|
||||
pub mod title_bar;
|
||||
pub mod updates;
|
||||
|
||||
@@ -232,6 +232,14 @@ async fn create_account(
|
||||
sk.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// A new vault on this computer only.
|
||||
#[tauri::command]
|
||||
async fn create_local_account(app: State<'_, App>, master_password: String) -> CmdResult<String> {
|
||||
let sk = app.sync.create_local_account(Zeroizing::new(master_password));
|
||||
app.activity.touch();
|
||||
sk.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// `secret_key` may be omitted once this device remembers it.
|
||||
#[tauri::command]
|
||||
async fn unlock(app: State<'_, App>, master_password: String, secret_key: Option<String>) -> CmdResult<()> {
|
||||
@@ -814,6 +822,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
kestrove_desktop::snap::register_native_host();
|
||||
let builder = tauri::Builder::default();
|
||||
#[cfg(windows)]
|
||||
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
@@ -830,6 +839,7 @@ fn main() {
|
||||
state,
|
||||
set_title_bar_colors,
|
||||
create_account,
|
||||
create_local_account,
|
||||
unlock,
|
||||
hello_unlock,
|
||||
set_hello,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Browser registration for the Snap Store build. A snap can't ship files into the user's home, so the app writes
|
||||
//! the native messaging manifests itself on startup, through the `browser-native-hosts` plug
|
||||
//! (snap/snapcraft.yaml). Outside a snap this does nothing: the .deb and .rpm install them system-wide.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const HOST: &str = "app.kestrove.nmh";
|
||||
/// The helper's snap command. A browser, or the portal for snap Firefox, starts it inside the Kestrove snap, where
|
||||
/// it shares the app's runtime directory and so its socket.
|
||||
const NMH: &str = "/snap/bin/kestrove.nmh";
|
||||
const FIREFOX_IDS: &[&str] = &["[email protected]"];
|
||||
/// The Chrome Web Store build, then the unpacked development build.
|
||||
const CHROME_ORIGINS: &[&str] =
|
||||
&["chrome-extension://pmhfoafjoogobhfadaicklhkldgnafmk/", "chrome-extension://kogapfibnnpeapfcjkkpgjddbhnjfein/"];
|
||||
const FIREFOX_DIR: &str = ".mozilla/native-messaging-hosts";
|
||||
const CHROME_DIRS: &[&str] = &[
|
||||
".config/google-chrome/NativeMessagingHosts",
|
||||
".config/chromium/NativeMessagingHosts",
|
||||
".config/microsoft-edge/NativeMessagingHosts",
|
||||
".config/BraveSoftware/Brave-Browser/NativeMessagingHosts",
|
||||
];
|
||||
|
||||
/// Writes the manifests when running as a snap. A browser that isn't installed, or a plug that isn't connected, only
|
||||
/// means no browser integration for that browser, so failures are logged and the app carries on.
|
||||
pub fn register_native_host() {
|
||||
if std::env::var_os("SNAP").is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(home) = std::env::var_os("SNAP_REAL_HOME") else {
|
||||
eprintln!("kestrove: SNAP_REAL_HOME is not set; browser integration not registered");
|
||||
return;
|
||||
};
|
||||
for (dir, res) in write_manifests(Path::new(&home)) {
|
||||
if let Err(e) = res {
|
||||
eprintln!("kestrove: can't register the browser helper in {dir}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_manifests(home: &Path) -> Vec<(&'static str, std::io::Result<()>)> {
|
||||
let manifest = |key: &str, ids: &[&str]| {
|
||||
let doc = serde_json::json!({
|
||||
"name": HOST, "description": "Kestrove native messaging host", "path": NMH, "type": "stdio", key: ids,
|
||||
});
|
||||
doc.to_string()
|
||||
};
|
||||
let firefox = manifest("allowed_extensions", FIREFOX_IDS);
|
||||
let chrome = manifest("allowed_origins", CHROME_ORIGINS);
|
||||
let targets = std::iter::once((FIREFOX_DIR, &firefox)).chain(CHROME_DIRS.iter().map(|d| (*d, &chrome)));
|
||||
targets.map(|(dir, body)| (dir, write(&home.join(dir), body))).collect()
|
||||
}
|
||||
|
||||
fn write(dir: &Path, body: &str) -> std::io::Result<()> {
|
||||
fs::create_dir_all(dir)?;
|
||||
fs::write(dir.join(format!("{HOST}.json")), body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn writes_a_manifest_for_each_browser() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
assert!(write_manifests(home.path()).iter().all(|(_, r)| r.is_ok()));
|
||||
|
||||
let read = |dir: &str| -> serde_json::Value {
|
||||
let body = fs::read_to_string(home.path().join(dir).join(format!("{HOST}.json"))).unwrap();
|
||||
serde_json::from_str(&body).unwrap()
|
||||
};
|
||||
let firefox = read(FIREFOX_DIR);
|
||||
assert_eq!(firefox["path"], NMH);
|
||||
assert_eq!(firefox["allowed_extensions"][0], FIREFOX_IDS[0]);
|
||||
for dir in CHROME_DIRS {
|
||||
assert_eq!(read(dir)["allowed_origins"].as_array().unwrap().len(), CHROME_ORIGINS.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,6 +403,17 @@ impl SyncService {
|
||||
Ok(sk)
|
||||
}
|
||||
|
||||
/// A vault on this computer only. Settings → Sync can connect it to a server later. Returns the Secret Key for
|
||||
/// the Emergency Kit.
|
||||
pub fn create_local_account(&self, master_password: Zeroizing<String>) -> Result<Zeroizing<String>, String> {
|
||||
if self.core.has_account() {
|
||||
return Err("This computer already has a vault.".into());
|
||||
}
|
||||
let sk = Zeroizing::new(self.core.create_account(master_password.to_string()).map_err(|e| describe(&e))?);
|
||||
self.remember_secret_key(&sk);
|
||||
Ok(sk)
|
||||
}
|
||||
|
||||
/// For a vault created before sync was set up: registers it with the server (setup code, invite, or nothing
|
||||
/// with open sign-up).
|
||||
pub async fn register(&self, setup_code: &str, email: &str) -> Result<(), String> {
|
||||
@@ -668,6 +679,19 @@ mod tests {
|
||||
assert!(!device_name().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_local_vault_needs_no_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let core = VaultCore::new_in_memory();
|
||||
let key = DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string());
|
||||
let svc = SyncService::new(core.clone(), dir.path(), key, Box::new(|_| {}));
|
||||
let sk = svc.create_local_account(Zeroizing::new("correct horse battery".into())).unwrap();
|
||||
assert!(core.has_account());
|
||||
assert_eq!(svc.status().server_url, None);
|
||||
assert_eq!(svc.stored_secret_key().as_deref().map(String::as_str), Some(sk.as_str()));
|
||||
assert!(svc.create_local_account(Zeroizing::new("correct horse battery".into())).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_persist_and_secrets_stay_out_of_views() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Kestrove",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"identifier": "app.kestrove.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+20
-5
@@ -208,7 +208,7 @@ type Tab = "phone" | "kit" | "paste" | "create";
|
||||
async function renderOnboarding(tab: Tab = "phone") {
|
||||
const status = await invoke<SyncStatus>("sync_status");
|
||||
const body = stack();
|
||||
const tabs: [Tab, string][] = [["phone", "Use my phone"], ["kit", "Emergency Kit"], ["paste", "Paste setup code"], ["create", "New account"]];
|
||||
const tabs: [Tab, string][] = [["phone", "Use my phone"], ["kit", "Emergency Kit"], ["paste", "Paste setup code"], ["create", "New vault"]];
|
||||
const nav = h("div", "seg", ...tabs.map(([t, label]) => {
|
||||
const b = button(label, "quiet", async () => {
|
||||
await invoke("join_cancel");
|
||||
@@ -417,8 +417,17 @@ function createPanel(body: HTMLElement, status: SyncStatus) {
|
||||
const pins = input({ placeholder: "base64, comma-separated", spellcheck: false }, true);
|
||||
const pw = input({ type: "password", autocomplete: "new-password" });
|
||||
const pw2 = input({ type: "password", autocomplete: "new-password" });
|
||||
const useServer = input({ type: "checkbox", checked: true });
|
||||
useServer.className = "switch";
|
||||
useServer.setAttribute("aria-label", "Sync with a server");
|
||||
const serverFields = stack();
|
||||
const err = errBox();
|
||||
const btn = button("Create account", "primary");
|
||||
const label = () => (useServer.checked ? "Create account" : "Create vault");
|
||||
useServer.onchange = () => {
|
||||
serverFields.hidden = !useServer.checked;
|
||||
btn.textContent = label();
|
||||
};
|
||||
btn.onclick = async () => {
|
||||
err.replaceChildren();
|
||||
if (pw.value !== pw2.value) {
|
||||
@@ -430,25 +439,31 @@ function createPanel(body: HTMLElement, status: SyncStatus) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Creating…";
|
||||
try {
|
||||
const secretKey = await invoke<string>("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, email: email.value, masterPassword: pw.value });
|
||||
const secretKey = useServer.checked
|
||||
? await invoke<string>("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, email: email.value, masterPassword: pw.value })
|
||||
: await invoke<string>("create_local_account", { masterPassword: pw.value });
|
||||
pw.value = pw2.value = "";
|
||||
await showSecretKey(secretKey);
|
||||
} catch (e) {
|
||||
setErr(err, e);
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Create account";
|
||||
btn.textContent = label();
|
||||
// A vault created here whose server registration failed: continue to the vault (Settings → Sync retries).
|
||||
if ((await invoke<AppState>("state")).has_account) {
|
||||
body.append(button("Continue to the vault", "secondary", render));
|
||||
}
|
||||
}
|
||||
};
|
||||
body.append(
|
||||
p("kv-muted", "A new account on your Kestrove server. You'll get a Secret Key: keep it with your Emergency Kit."),
|
||||
serverFields.append(
|
||||
field("Your Kestrove server", url),
|
||||
field("Email", email, "You sign in with it on new devices."),
|
||||
field("Invite or setup code", code, "The invite from your server's admin, or KV_SETUP_CODE from its .env for the first account. Empty with open sign-up."),
|
||||
h("details", "", h("summary", "kv-small", "Certificate pins (advanced)"), field("SPKI pins", pins)),
|
||||
);
|
||||
body.append(
|
||||
p("kv-muted", "A new vault. You'll get a Secret Key: keep it with your Emergency Kit."),
|
||||
field("Sync with a server", useServer, "Turn this off to use Kestrove on this computer only. You can connect a server later in Settings."),
|
||||
serverFields,
|
||||
field("Master password", pw, "10 or more characters."),
|
||||
field("Repeat master password", pw2),
|
||||
h("div", "actions", btn), err,
|
||||
|
||||
@@ -11,6 +11,7 @@ h1, h2, h3 { margin: 0; }
|
||||
.grow { flex: 1 1 auto; min-width: 0; }
|
||||
.row { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; }
|
||||
.stack { display: flex; flex-direction: column; gap: var(--space-4); min-width: 0; }
|
||||
.stack[hidden] { display: none; }
|
||||
.actions { display: flex; gap: var(--space-2); align-items: center; }
|
||||
.actions.end { justify-content: flex-end; }
|
||||
.center-row { display: flex; justify-content: center; }
|
||||
|
||||
Reference in New Issue
Block a user