4 Commits
Author SHA1 Message Date
samiandClaude Opus 5.5 a818a83a9c 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]>
2026-10-31 08:24:59 +04:00
samiandClaude Opus 5.5 608d85c297 Linux: browser helper for snap Firefox; packages register it
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
Snap Firefox starts the helper through the desktop portal, which reads the
manifest from ~/.mozilla or /usr/lib/mozilla. The .deb and .rpm now install the
manifests system-wide, and install.sh detects snap Firefox.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:12:45 +04:00
samiandClaude Opus 5.5 32eadc4d03 Windows updates come from the Kestrove website; version 1.2.1
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:57:46 +04:00
samiandClaude Opus 5.5 57b728c7e3 gitea-release.sh: work with any subset of bundles
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s
A release with only the Windows installer aborted: shasum failed on the
unmatched *.deb and *.rpm globs under set -e. Checksum the files that exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:29:32 +04:00
23 changed files with 323 additions and 52 deletions
+12 -6
View File
@@ -63,15 +63,21 @@ XWIN_ACCEPT_LICENSE=1 scripts/windows.sh
`src-tauri/Cargo.toml`, tag the commit (`git tag v<version> && git push origin v<version>`), then: `src-tauri/Cargo.toml`, tag the commit (`git tag v<version> && git push origin v<version>`), then:
```bash ```bash
GITEA_TOKEN=<token with write:repository> TAURI_SIGNING_PRIVATE_KEY_PATH=<updater key> scripts/gitea-release.sh GITEA_TOKEN=<token with write:repository> scripts/gitea-release.sh
``` ```
It creates the release for the tag and uploads the installers, the browser helpers and a `SHA256SUMS` file. It creates the release for the tag and uploads the installers, the browser helpers and a `SHA256SUMS` file.
**Offer it as an update (Windows):** the script also signs the installer with the updater key, uploads the **Put it on the website and offer it as an update (Windows):**
signature, and writes `updater/latest.json`. Sign the installer with the code-signing certificate first, because
the updater signature covers the final file. Installed copies are offered the update once that file is committed ```bash
and pushed to main; the script prints the commands. Keep the updater key and its password offline and backed up: TAURI_SIGNING_PRIVATE_KEY_PATH=<updater key> scripts/publish-update.sh
```
This signs the installer with the updater key, copies it to `downloads/` in `kestrove-website` next to this repo,
and writes `updates/latest.json` there. Sign the installer with the code-signing certificate first, because the
updater signature covers the final file. Installed copies are offered the update once the website is deployed.
Keep the updater key and its password offline and backed up:
the app only accepts updates signed with it, so losing it means existing installs can't update. the app only accepts updates signed with it, so losing it means existing installs can't update.
## How the browser connection works ## How the browser connection works
@@ -93,7 +99,7 @@ browser extension ─(native messaging)─▶ kestrove-nmh ─(local connection)
## Updates ## Updates
On Windows, Kestrove checks for a new version a minute after it starts and once a day. The check is a plain request On Windows, Kestrove checks for a new version a minute after it starts and once a day. The check is a plain request
for `updater/latest.json` on the Kestrove Gitea: it reveals this computer's IP address and app version, and nothing for `updates/latest.json` on the Kestrove website: it reveals this computer's IP address and app version, and nothing
from your vault. A new version appears as a banner and installs only when you choose **Update and restart**. The app from your vault. A new version appears as a banner and installs only when you choose **Update and restart**. The app
verifies the download's signature before running it. Turn checks off in **Settings → Updates**. On Linux, updates verifies the download's signature before running it. Turn checks off in **Settings → Updates**. On Linux, updates
come through your package manager. come through your package manager.
+4
View File
@@ -27,5 +27,9 @@ native-messaging/install.sh --uninstall
| Firefox | `~/.mozilla/native-messaging-hosts/app.kestrove.nmh.json` | `HKCU\Software\Mozilla\NativeMessagingHosts\app.kestrove.nmh` | | Firefox | `~/.mozilla/native-messaging-hosts/app.kestrove.nmh.json` | `HKCU\Software\Mozilla\NativeMessagingHosts\app.kestrove.nmh` |
| Brave | `~/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts/` | same key as Chrome | | Brave | `~/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts/` | same key as Chrome |
The Linux `.deb` and `.rpm` install these manifests system-wide (`src-tauri/linux/`), so they need no script. That
covers snap Firefox too: it can't start the host itself, so the desktop portal does, after asking the user once.
AppImage users run `install.sh`.
`allowed_origins` / `allowed_extensions` pin the host to **your** extension id. `allowed_origins` / `allowed_extensions` pin the host to **your** extension id.
That is the first line of defense against other extensions (docs/04 A8). That is the first line of defense against other extensions (docs/04 A8).
+4
View File
@@ -84,6 +84,10 @@ esac
eval "d=\$dir_$b" eval "d=\$dir_$b"
[ -d "$d" ] && BROWSERS="$BROWSERS $b" [ -d "$d" ] && BROWSERS="$BROWSERS $b"
done done
# Snap Firefox keeps its profile in ~/snap, but its portal reads the host from ~/.mozilla like any other Firefox.
case " $BROWSERS " in *" firefox "*) ;; *)
[ -n "$USER_DATA_DIR" ] || { [ -d /snap/firefox ] && BROWSERS="$BROWSERS firefox"; } || true ;;
esac
render() { # template → stdout render() { # template → stdout
sed -e "s|__NMH_PATH__|$NMH|" -e "s|chrome-extension://__CHROME_EXTENSION_ID__/|$CHROME_ORIGINS|" -e "s|__FIREFOX_EXTENSION_ID__|$FIREFOX_ID|" "$HERE/$HOST.$1.json" sed -e "s|__NMH_PATH__|$NMH|" -e "s|chrome-extension://__CHROME_EXTENSION_ID__/|$CHROME_ORIGINS|" -e "s|__FIREFOX_EXTENSION_ID__|$FIREFOX_ID|" "$HERE/$HOST.$1.json"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "kestrove-desktop", "name": "kestrove-desktop",
"version": "1.2.0", "version": "1.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "kestrove-desktop", "name": "kestrove-desktop",
"version": "1.2.0", "version": "1.2.2",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "2.12.3" "@tauri-apps/api": "2.12.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "kestrove-desktop", "name": "kestrove-desktop",
"version": "1.2.0", "version": "1.2.2",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
+4 -32
View File
@@ -1,17 +1,11 @@
#!/bin/sh #!/bin/sh
# Creates (or reuses) the Gitea release for this version, uploads the desktop bundles to it, and writes # Creates (or reuses) the Gitea release for this version and uploads the desktop bundles to it. The website's
# updater/latest.json, which the Windows app checks for updates (src-tauri/src/updates.rs). # downloads and the Windows app's updates come from scripts/publish-update.sh instead.
# #
# GITEA_TOKEN=… TAURI_SIGNING_PRIVATE_KEY_PATH=… scripts/gitea-release.sh [dir-with-files] (default ../release/desktop) # GITEA_TOKEN=… scripts/gitea-release.sh [dir-with-files] (default ../release/desktop)
# #
# GITEA_TOKEN: a Gitea access token with the write:repository scope (Settings → Applications). The tag # GITEA_TOKEN: a Gitea access token with the write:repository scope (Settings → Applications). The tag
# v<version> must already be pushed. Files: *.AppImage, *.deb, *.rpm, *-setup.exe, kestrove-nmh-*, SHA256SUMS. # v<version> must already be pushed. Files: *.AppImage, *.deb, *.rpm, *-setup.exe, kestrove-nmh-*, SHA256SUMS.
#
# Updates: the Windows installer needs an updater signature (<installer>.sig) from the Kestrove updater key, whose
# public half is in src-tauri/tauri.conf.json. Sign after Authenticode signing: the signature covers the final
# file. With TAURI_SIGNING_PRIVATE_KEY_PATH set, a missing .sig is made here (the key's password is asked for, or
# read from TAURI_SIGNING_PRIVATE_KEY_PASSWORD). Optional NOTES: the text shown with the update.
# Users get the update only once updater/latest.json is committed and pushed to main (printed at the end).
set -eu set -eu
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
: "${GITEA_TOKEN:?set GITEA_TOKEN (Gitea → Settings → Applications → token with write:repository)}" : "${GITEA_TOKEN:?set GITEA_TOKEN (Gitea → Settings → Applications → token with write:repository)}"
@@ -21,17 +15,8 @@ DIR=${1:-../release/desktop}
V=$(node -p 'require("./package.json").version') V=$(node -p 'require("./package.json").version')
TAG="v$V" TAG="v$V"
AUTH="Authorization: token $GITEA_TOKEN" AUTH="Authorization: token $GITEA_TOKEN"
SETUP="$DIR/Kestrove_${V}_x64-setup.exe"
if [ -f "$SETUP" ] && [ ! -f "$SETUP.sig" ]; then (cd "$DIR" && rm -f SHA256SUMS && for f in *.AppImage *.deb *.rpm *-setup.exe kestrove-nmh-*; do if [ -f "$f" ]; then shasum -a 256 -- "$f"; fi; done > SHA256SUMS; cat SHA256SUMS)
: "${TAURI_SIGNING_PRIVATE_KEY_PATH:?$SETUP.sig is missing: set TAURI_SIGNING_PRIVATE_KEY_PATH to the updater key to sign it}"
# --app-version binds the signature to this version (the app requires it), so an older signed installer can't
# be offered again under a newer version number.
npx tauri signer sign -f "$TAURI_SIGNING_PRIVATE_KEY_PATH" ${TAURI_SIGNING_PRIVATE_KEY_PASSWORD+-p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD"} \
--app-version "$V" "$SETUP"
fi
(cd "$DIR" && rm -f SHA256SUMS && shasum -a 256 -- *.AppImage *.deb *.rpm *-setup.exe kestrove-nmh-* 2>/dev/null > SHA256SUMS; cat SHA256SUMS)
ID=$(curl -fsS -H "$AUTH" "$API/repos/$REPO/releases/tags/$TAG" 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).id||"")}catch{console.log("")}})') ID=$(curl -fsS -H "$AUTH" "$API/repos/$REPO/releases/tags/$TAG" 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).id||"")}catch{console.log("")}})')
if [ -z "$ID" ]; then if [ -z "$ID" ]; then
@@ -45,16 +30,3 @@ for f in "$DIR"/*.AppImage "$DIR"/*.deb "$DIR"/*.rpm "$DIR"/*-setup.exe "$DIR"/*
done done
echo "Release: ${API%/api/v1}/$REPO/releases/tag/$TAG" echo "Release: ${API%/api/v1}/$REPO/releases/tag/$TAG"
# The update manifest, written only after the installer it points to is uploaded.
if [ -f "$SETUP.sig" ]; then
mkdir -p updater
node -e '
const [v, url, sigFile, notes] = process.argv.slice(1);
const signature = require("fs").readFileSync(sigFile, "utf8").trim();
const doc = { version: v, notes, pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
platforms: { "windows-x86_64": { signature, url } } };
require("fs").writeFileSync("updater/latest.json", JSON.stringify(doc, null, 2) + "\n");
' "$V" "${API%/api/v1}/$REPO/releases/download/$TAG/$(basename "$SETUP")" "$SETUP.sig" "${NOTES:-Kestrove $V}"
echo "Wrote updater/latest.json. To offer the update, commit it and push to main:"
echo " git add updater/latest.json && git commit -m \"Offer $V as an update\" && git push origin main"
fi
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Puts the Windows installer on the Kestrove website and offers it as an update: signs it with the updater key if
# needed, copies it to the website's downloads/, and writes the website's updates/latest.json, which the app checks
# (src-tauri/src/updates.rs).
#
# TAURI_SIGNING_PRIVATE_KEY_PATH=… scripts/publish-update.sh [dir-with-installer] (default ../release/desktop)
#
# Sign after Authenticode signing: the updater signature covers the final file. The key's password is asked for,
# or read from TAURI_SIGNING_PRIVATE_KEY_PASSWORD. Optional NOTES: the text shown with the update. Optional
# WEBSITE_DIR: the website folder (default ../kestrove-website). Users get the update once the website is deployed.
set -eu
cd "$(dirname "$0")/.."
DIR=${1:-../release/desktop}
SITE=${WEBSITE_DIR:-../kestrove-website}
V=$(node -p 'require("./package.json").version')
NAME="Kestrove_${V}_x64-setup.exe"
SETUP="$DIR/$NAME"
[ -f "$SETUP" ] || { echo "$SETUP not found" >&2; exit 1; }
if [ ! -f "$SETUP.sig" ]; then
: "${TAURI_SIGNING_PRIVATE_KEY_PATH:?$SETUP.sig is missing: set TAURI_SIGNING_PRIVATE_KEY_PATH to the updater key to sign it}"
# --app-version binds the signature to this version (the app requires it), so an older signed installer can't
# be offered again under a newer version number.
npx tauri signer sign -f "$TAURI_SIGNING_PRIVATE_KEY_PATH" ${TAURI_SIGNING_PRIVATE_KEY_PASSWORD+-p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD"} \
--app-version "$V" "$SETUP"
fi
mkdir -p "$SITE/downloads" "$SITE/updates"
cp "$SETUP" "$SITE/downloads/$NAME"
node -e '
const [out, v, url, sigFile, notes] = process.argv.slice(1);
const signature = require("fs").readFileSync(sigFile, "utf8").trim();
const doc = { version: v, notes, pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
platforms: { "windows-x86_64": { signature, url } } };
require("fs").writeFileSync(out, JSON.stringify(doc, null, 2) + "\n");
' "$SITE/updates/latest.json" "$V" "https://kestrove.samialabri.com/downloads/$NAME" "$SETUP.sig" "${NOTES:-Kestrove $V}"
echo "Copied $NAME to $SITE/downloads and wrote $SITE/updates/latest.json. Deploy the website to offer the update."
+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]] [[package]]
name = "kestrove-desktop" name = "kestrove-desktop"
version = "1.2.0" version = "1.2.2"
dependencies = [ dependencies = [
"arboard", "arboard",
"base64 0.22.1", "base64 0.22.1",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kestrove-desktop" name = "kestrove-desktop"
version = "1.2.0" version = "1.2.2"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "Apache-2.0" license = "Apache-2.0"
@@ -73,7 +73,7 @@ windows-sys = { version = "0.61", features = [
"Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging", "Win32_UI_WindowsAndMessaging",
] } ] }
# In-app updates (updates.rs): signed installers listed in latest.json on the Kestrove Gitea. # In-app updates (updates.rs): signed installers listed in latest.json on the Kestrove website.
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
# Windows Hello (KeyCredentialManager); the same version Tauri already pulls in. # Windows Hello (KeyCredentialManager); the same version Tauri already pulls in.
windows = { version = "0.62", features = ["Foundation", "Security_Credentials", "Security_Cryptography", "Storage_Streams"] } windows = { version = "0.62", features = ["Foundation", "Security_Credentials", "Security_Cryptography", "Storage_Streams"] }
@@ -0,0 +1,7 @@
{
"name": "app.kestrove.nmh",
"description": "Kestrove native messaging host",
"path": "/usr/bin/kestrove-nmh",
"type": "stdio",
"allowed_origins": ["chrome-extension://pmhfoafjoogobhfadaicklhkldgnafmk/", "chrome-extension://kogapfibnnpeapfcjkkpgjddbhnjfein/"]
}
@@ -0,0 +1,7 @@
{
"name": "app.kestrove.nmh",
"description": "Kestrove native messaging host",
"path": "/usr/bin/kestrove-nmh",
"type": "stdio",
"allowed_extensions": ["[email protected]"]
}
+1
View File
@@ -7,6 +7,7 @@ pub mod autolock;
pub mod device_key; pub mod device_key;
pub mod hello; pub mod hello;
pub mod qr; pub mod qr;
pub mod snap;
pub mod sync; pub mod sync;
pub mod title_bar; pub mod title_bar;
pub mod updates; pub mod updates;
+10
View File
@@ -232,6 +232,14 @@ async fn create_account(
sk.map(|s| s.to_string()) 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. /// `secret_key` may be omitted once this device remembers it.
#[tauri::command] #[tauri::command]
async fn unlock(app: State<'_, App>, master_password: String, secret_key: Option<String>) -> CmdResult<()> { 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() { fn main() {
kestrove_desktop::snap::register_native_host();
let builder = tauri::Builder::default(); let builder = tauri::Builder::default();
#[cfg(windows)] #[cfg(windows)]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
@@ -830,6 +839,7 @@ fn main() {
state, state,
set_title_bar_colors, set_title_bar_colors,
create_account, create_account,
create_local_account,
unlock, unlock,
hello_unlock, hello_unlock,
set_hello, 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) 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 /// For a vault created before sync was set up: registers it with the server (setup code, invite, or nothing
/// with open sign-up). /// with open sign-up).
pub async fn register(&self, setup_code: &str, email: &str) -> Result<(), String> { pub async fn register(&self, setup_code: &str, email: &str) -> Result<(), String> {
@@ -668,6 +679,19 @@ mod tests {
assert!(!device_name().is_empty()); 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] #[tokio::test]
async fn settings_persist_and_secrets_stay_out_of_views() { async fn settings_persist_and_secrets_stay_out_of_views() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -1,7 +1,7 @@
//! In-app updates on Windows. The Microsoft Store doesn't update EXE installs, so the app does it itself. //! In-app updates on Windows. The Microsoft Store doesn't update EXE installs, so the app does it itself.
//! //!
//! Tauri's updater fetches `latest.json` from the first address in tauri.conf.json → plugins.updater.endpoints //! Tauri's updater fetches `latest.json` from the first address in tauri.conf.json → plugins.updater.endpoints
//! that answers (the Kestrove Gitea; a mirror can be added after it), and offers the newer version. Nothing is //! that answers (the Kestrove website; a mirror can be added after it), and offers the newer version. Nothing is
//! downloaded until the user chooses "Update and restart". The installer must carry a signature from the //! downloaded until the user chooses "Update and restart". The installer must carry a signature from the
//! Kestrove updater key, whose public half is in tauri.conf.json, so a compromised download server can hold //! Kestrove updater key, whose public half is in tauri.conf.json, so a compromised download server can hold
//! updates back but can't ship one. //! updates back but can't ship one.
+20 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Kestrove", "productName": "Kestrove",
"version": "1.2.0", "version": "1.2.2",
"identifier": "app.kestrove.desktop", "identifier": "app.kestrove.desktop",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -41,6 +41,24 @@
"icons/icon.ico", "icons/icon.ico",
"icons/icon.png" "icons/icon.png"
], ],
"linux": {
"deb": {
"files": {
"/usr/lib/mozilla/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.firefox.json",
"/etc/opt/chrome/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.chrome.json",
"/etc/chromium/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.chrome.json",
"/etc/opt/edge/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.chrome.json"
}
},
"rpm": {
"files": {
"/usr/lib/mozilla/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.firefox.json",
"/etc/opt/chrome/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.chrome.json",
"/etc/chromium/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.chrome.json",
"/etc/opt/edge/native-messaging-hosts/app.kestrove.nmh.json": "linux/app.kestrove.nmh.chrome.json"
}
}
},
"windows": { "windows": {
"nsis": { "nsis": {
"installerHooks": "windows/hooks.nsh", "installerHooks": "windows/hooks.nsh",
@@ -53,7 +71,7 @@
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZFRUM4RDQ2Mzk0Rjg5Q0IKUldUTGlVODVSbzNzYml1ZTN3d0pBN1IwWkRoZXNROTRmbk5WOE5CR2VJQVYrTUJLN21PNzI4eHEK", "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZFRUM4RDQ2Mzk0Rjg5Q0IKUldUTGlVODVSbzNzYml1ZTN3d0pBN1IwWkRoZXNROTRmbk5WOE5CR2VJQVYrTUJLN21PNzI4eHEK",
"requireSignedVersion": true, "requireSignedVersion": true,
"endpoints": [ "endpoints": [
"https://gitea.samialabri.com/kestrove/kestrove-desktop/raw/branch/main/updater/latest.json" "https://kestrove.samialabri.com/updates/latest.json"
], ],
"windows": { "windows": {
"installMode": "passive" "installMode": "passive"
+20 -5
View File
@@ -208,7 +208,7 @@ type Tab = "phone" | "kit" | "paste" | "create";
async function renderOnboarding(tab: Tab = "phone") { async function renderOnboarding(tab: Tab = "phone") {
const status = await invoke<SyncStatus>("sync_status"); const status = await invoke<SyncStatus>("sync_status");
const body = stack(); 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 nav = h("div", "seg", ...tabs.map(([t, label]) => {
const b = button(label, "quiet", async () => { const b = button(label, "quiet", async () => {
await invoke("join_cancel"); 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 pins = input({ placeholder: "base64, comma-separated", spellcheck: false }, true);
const pw = input({ type: "password", autocomplete: "new-password" }); const pw = input({ type: "password", autocomplete: "new-password" });
const pw2 = 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 err = errBox();
const btn = button("Create account", "primary"); 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 () => { btn.onclick = async () => {
err.replaceChildren(); err.replaceChildren();
if (pw.value !== pw2.value) { if (pw.value !== pw2.value) {
@@ -430,25 +439,31 @@ function createPanel(body: HTMLElement, status: SyncStatus) {
btn.disabled = true; btn.disabled = true;
btn.textContent = "Creating…"; btn.textContent = "Creating…";
try { 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 = ""; pw.value = pw2.value = "";
await showSecretKey(secretKey); await showSecretKey(secretKey);
} catch (e) { } catch (e) {
setErr(err, e); setErr(err, e);
btn.disabled = false; 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). // A vault created here whose server registration failed: continue to the vault (Settings → Sync retries).
if ((await invoke<AppState>("state")).has_account) { if ((await invoke<AppState>("state")).has_account) {
body.append(button("Continue to the vault", "secondary", render)); body.append(button("Continue to the vault", "secondary", render));
} }
} }
}; };
body.append( serverFields.append(
p("kv-muted", "A new account on your Kestrove server. You'll get a Secret Key: keep it with your Emergency Kit."),
field("Your Kestrove server", url), field("Your Kestrove server", url),
field("Email", email, "You sign in with it on new devices."), 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."), 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)), 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("Master password", pw, "10 or more characters."),
field("Repeat master password", pw2), field("Repeat master password", pw2),
h("div", "actions", btn), err, 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; } .grow { flex: 1 1 auto; min-width: 0; }
.row { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; } .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 { 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 { display: flex; gap: var(--space-2); align-items: center; }
.actions.end { justify-content: flex-end; } .actions.end { justify-content: flex-end; }
.center-row { display: flex; justify-content: center; } .center-row { display: flex; justify-content: center; }