diff --git a/package-lock.json b/package-lock.json index 86a317d..25bf989 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 5aa4cb2..460f0bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kestrove-desktop", - "version": "1.2.1", + "version": "1.2.2", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/scripts/snap.sh b/scripts/snap.sh new file mode 100755 index 0000000..51e6f31 --- /dev/null +++ b/scripts/snap.sh @@ -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__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 diff --git a/snap/gui/kestrove.desktop b/snap/gui/kestrove.desktop new file mode 100644 index 0000000..db9cd74 --- /dev/null +++ b/snap/gui/kestrove.desktop @@ -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 diff --git a/snap/gui/kestrove.png b/snap/gui/kestrove.png new file mode 100644 index 0000000..d10abd4 Binary files /dev/null and b/snap/gui/kestrove.png differ diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 0000000..e6e6d79 --- /dev/null +++ b/snap/snapcraft.yaml @@ -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" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 775e91a..779b1f4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3064,7 +3064,7 @@ dependencies = [ [[package]] name = "kestrove-desktop" -version = "1.2.1" +version = "1.2.2" dependencies = [ "arboard", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e163bb9..23394b9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 89c807e..223b2d8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fecf2e5..a6da7f9 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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 { + 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) -> CmdResult<()> { @@ -814,6 +822,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box> { } 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, diff --git a/src-tauri/src/snap.rs b/src-tauri/src/snap.rs new file mode 100644 index 0000000..34126e3 --- /dev/null +++ b/src-tauri/src/snap.rs @@ -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] = &["browser@kestrove.app"]; +/// 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()); + } + } +} diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 632512c..bfe16b0 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -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) -> Result, 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(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f6b8f74..a091872 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/main.ts b/src/main.ts index db09ca6..0acb354 100644 --- a/src/main.ts +++ b/src/main.ts @@ -208,7 +208,7 @@ type Tab = "phone" | "kit" | "paste" | "create"; async function renderOnboarding(tab: Tab = "phone") { const status = await invoke("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("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, email: email.value, masterPassword: pw.value }); + const secretKey = useServer.checked + ? await invoke("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, email: email.value, masterPassword: pw.value }) + : await invoke("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("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, diff --git a/src/style.css b/src/style.css index a8fb30e..160f00e 100644 --- a/src/style.css +++ b/src/style.css @@ -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; }