Local vaults without a server; Snap package; version 1.2.2
ci / build (ubuntu-22.04) (push) Waiting to run
ci / build (windows-2025) (push) Waiting to run
ci / deny (push) Waiting to run

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:
2026-10-31 08:24:59 +04:00
co-authored by Claude Opus 5.5
parent 608d85c297
commit a818a83a9c
15 changed files with 227 additions and 11 deletions
+2 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "kestrove-desktop",
"version": "1.2.1",
"version": "1.2.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
+15
View File
@@ -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
+9
View File
@@ -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

+62
View File
@@ -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"
+1 -1
View File
@@ -3064,7 +3064,7 @@ dependencies = [
[[package]]
name = "kestrove-desktop"
version = "1.2.1"
version = "1.2.2"
dependencies = [
"arboard",
"base64 0.22.1",
+1 -1
View File
@@ -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"
+1
View File
@@ -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;
+10
View File
@@ -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,
+79
View File
@@ -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());
}
}
}
+24
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -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; }