M3: working desktop app + browser pairing channel

- Builds and runs (tauri build --debug); placeholder icons; persistent VaultCore::open in the
  OS app-data dir; Secret Key remembered on the device; tray keeps the app serving the extension.
- Auto-lock: idle (system idle on Windows/macOS, in-app activity on Linux), screen lock, sleep.
- Local IPC for kestrove-nmh: 0700 dir + 0600 Unix socket with peer-uid checks; Windows named
  pipe (first instance, local only, user DACL, client SID check) behind cfg.
- Pairing channel: X25519 → HKDF → 6-word fingerprint confirmed in both UIs, per-connection
  session keys, AES-256-GCM with strict counters. Test vectors shared with kestrove-browser.
- Broker: query/fill/totp/save with origin re-checks; pairing and save prompts in the UI.
- kestrove-nmh relays ciphertext only; native-host installers for Linux/macOS/Windows.
- Tests: unit tests, tests/nmh_stdio.rs (real kestrove-nmh over stdio), examples/test-host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-16 20:13:54 +09:00
co-authored by Claude Opus 5.5
parent 30cdcee3d2
commit 923c466dea
44 changed files with 9289 additions and 270 deletions
+4 -2
View File
@@ -11,11 +11,13 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: { repository: ${{ github.repository_owner }}/kestrove-core, path: kestrove-core, token: ${{ secrets.CORE_READ_TOKEN }} } with: { repository: ${{ github.repository_owner }}/kestrove-core, path: kestrove-core, token: ${{ secrets.CORE_READ_TOKEN }} }
- if: runner.os == 'Linux' - if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libxdo-dev run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: { node-version: 22 } with: { node-version: 22 }
- run: npm ci && npm run build - run: npm ci && npm run build
working-directory: kestrove-desktop working-directory: kestrove-desktop
- run: cargo check --all-targets - run: cargo clippy --all-targets -- -D warnings
working-directory: kestrove-desktop/src-tauri
- run: cargo test
working-directory: kestrove-desktop/src-tauri working-directory: kestrove-desktop/src-tauri
+43 -5
View File
@@ -8,16 +8,54 @@ Also ships `kestrove-nmh`, the **native-messaging host** that bridges `kestrove-
```bash ```bash
# prerequisites: rustup, Node 20+, and Tauri's OS deps (https://tauri.app/start/prerequisites/) # prerequisites: rustup, Node 20+, and Tauri's OS deps (https://tauri.app/start/prerequisites/)
npm install npm install
npm run tauri icon path/to/logo.png # generates src-tauri/icons (required before building)
npm run tauri dev npm run tauri dev
npm run tauri build # MSI/NSIS on Windows, AppImage/deb on Linux npm run tauri build # MSI/NSIS on Windows, AppImage/deb on Linux
npm run tauri build -- --debug --bundles app # macOS dev build (Kestrove.app)
npm run tauri icon assets/logo.png # regenerate src-tauri/icons (placeholder logo)
(cd src-tauri && cargo clippy --all-targets -- -D warnings && cargo test)
``` ```
The vault lives in the OS app-data dir (`vault.sqlite`, plus `pairings.json`, `settings.json` and
`secret-key`, all owner-only). Closing the window keeps Kestrove in the tray so the browser extension
keeps working; Quit is in the tray menu.
## Browser integration (docs/05)
```
extension ─ native messaging (stdio) ─▶ kestrove-nmh ─ local IPC ─▶ app (src-tauri/src/broker.rs)
└──────────── pairing channel: X25519 → 6 words → AES-256-GCM, end to end ───────────┘
```
* **IPC** (`ipc.rs`): Unix socket `$XDG_RUNTIME_DIR/kestrove/nmh.sock` (macOS: the per-user temp
dir), dir 0700, socket 0600, peer uid checked on both ends. Windows: `\\.\pipe\kestrove-nmh-<user>`,
first instance only, local clients only, DACL for the current user, client token SID checked
(compile-checked only). `KESTROVE_IPC_DIR` isolates an instance for tests.
* **Pairing** (`channel.rs`, mirrored by kestrove-browser `src/channel.ts`, shared test vectors):
both UIs show 6 words; the user confirms in the app (needs an unlocked vault) and in the extension.
Each connection derives a fresh session key from both sides' nonces; messages carry strict
counters, so replay and reordering fail. `kestrove-nmh` only relays ciphertext.
* **Protocol** (`broker.rs`): `query` → identities for the origin (`items_for_url`); `fill`/`totp`
re-check that the item matches the origin and need an unlocked vault; `save` shows a prompt in
the app (new login or password update).
* **Native host manifests**: `native-messaging/install.sh` (Linux, macOS) and `install.ps1`
(Windows, HKCU registry) fill in the host path and extension ids.
* **Tests**: `cargo test` (unit tests + `tests/nmh_stdio.rs`, which drives the real `kestrove-nmh`
over stdio through the paired channel). `examples/test-host.rs` is a headless, auto-approving
stand-in for the app used by kestrove-browser's interop and Chrome end-to-end tests.
## Auto-lock
10 minutes idle by default (Settings), plus screen lock and sleep (`autolock.rs`). Idle time is the
system's on Windows (`GetLastInputInfo`) and macOS; on Linux it is activity inside Kestrove (UI input
and fills). Screen lock: Windows input-desktop check, Linux logind `LockedHint`, macOS session dictionary.
Sleep: a jump in the wall clock between polls.
## Roadmap (M3, kestrove-spec/docs/07) ## Roadmap (M3, kestrove-spec/docs/07)
- [ ] Persistent vault + sync (kv-store / kv-sync)
- [ ] Unlock with Windows Hello (KeyCredentialManager, TPM) and libsecret on Linux - [ ] Unlock with Windows Hello (KeyCredentialManager, TPM) and libsecret on Linux
- [ ] Auto-lock on idle, screen lock and suspend - [x] Persistent vault (`VaultCore::open` in app data); sync UI still to do
- [ ] Local IPC between `kestrove-nmh` and the app, plus the pairing AEAD channel - [x] Auto-lock on idle, screen lock and suspend
- [x] Local IPC between `kestrove-nmh` and the app, plus the pairing AEAD channel
- [x] Native-messaging manifest installers (`native-messaging/install.sh`, `install.ps1`)
- [ ] **Quick Access** global hotkey (Ctrl+Shift+Space): search, then copy or auto-type (Windows `SendInput`, X11 XTest; Wayland gets copy only) - [ ] **Quick Access** global hotkey (Ctrl+Shift+Space): search, then copy or auto-type (Windows `SendInput`, X11 XTest; Wayland gets copy only)
- [ ] Installer registers native-messaging manifests (`native-messaging/`) - [ ] MSI/deb installers run the manifest installers
- [ ] Windows 11 passkey plugin provider - [ ] Windows 11 passkey plugin provider
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

+1
View File
@@ -8,6 +8,7 @@
</head> </head>
<body> <body>
<main id="app"></main> <main id="app"></main>
<div id="overlay" hidden></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+18 -1
View File
@@ -1,6 +1,23 @@
# Native messaging manifests # Native messaging manifests
The installer fills in the placeholders and registers the manifests: `install.sh` (Linux, macOS) and `install.ps1` (Windows) fill in the placeholders and register the
manifests for the current user. Default ids are the development ones: the unpacked Chrome build is pinned
to `kogapfibnnpeapfcjkkpgjddbhnjfein` by the manifest key in kestrove-browser, Firefox uses
`[email protected]`. Pass `--chrome-id` / `-ChromeId` for a Web Store build.
```bash
native-messaging/install.sh # every browser with a profile dir, host from target/
native-messaging/install.sh --browsers chrome --nmh /opt/kestrove/kestrove-nmh
native-messaging/install.sh --user-data-dir /tmp/profile # a Chromium started with --user-data-dir (tests)
native-messaging/install.sh --uninstall
```
| Browser | macOS (dev only; the Apple app ships its own host) |
|---|---|
| Chrome | `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/` |
| Edge / Brave / Chromium | `~/Library/Application Support/{Microsoft Edge,BraveSoftware/Brave-Browser,Chromium}/NativeMessagingHosts/` |
| Firefox | `~/Library/Application Support/Mozilla/NativeMessagingHosts/` |
| Browser | Linux | Windows | | Browser | Linux | Windows |
|---|---|---| |---|---|---|
@@ -1,7 +1,7 @@
{ {
"name": "app.kestrove.nmh", "name": "app.kestrove.nmh",
"description": "Kestrove native messaging host", "description": "Kestrove native messaging host",
"path": "__INSTALL_DIR__/kestrove-nmh", "path": "__NMH_PATH__",
"type": "stdio", "type": "stdio",
"allowed_origins": ["chrome-extension://__CHROME_EXTENSION_ID__/"] "allowed_origins": ["chrome-extension://__CHROME_EXTENSION_ID__/"]
} }
@@ -1,7 +1,7 @@
{ {
"name": "app.kestrove.nmh", "name": "app.kestrove.nmh",
"description": "Kestrove native messaging host", "description": "Kestrove native messaging host",
"path": "__INSTALL_DIR__/kestrove-nmh", "path": "__NMH_PATH__",
"type": "stdio", "type": "stdio",
"allowed_extensions": ["__FIREFOX_EXTENSION_ID__"] "allowed_extensions": ["__FIREFOX_EXTENSION_ID__"]
} }
+61
View File
@@ -0,0 +1,61 @@
# Registers the Kestrove native-messaging host for the current user on Windows (HKCU, no admin).
# Linux/macOS: install.sh.
#
# powershell -ExecutionPolicy Bypass -File install.ps1 [-Nmh C:\path\kestrove-nmh.exe]
# [-ChromeId ID] [-FirefoxId ID] [-Browsers chrome,chromium,edge,firefox] [-Uninstall]
#
# Chrome and Brave share the Google\Chrome key. The manifests go to %LOCALAPPDATA%\Kestrove\NativeMessagingHosts.
# allowed_origins / allowed_extensions pin the host to our extension ids (docs/04 A8).
param(
[string]$Nmh = "",
[string]$ChromeId = "kogapfibnnpeapfcjkkpgjddbhnjfein",
[string]$FirefoxId = "[email protected]",
[string[]]$Browsers = @("chrome", "chromium", "edge", "firefox"),
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$HostName = "app.kestrove.nmh"
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
if ($ChromeId -notmatch '^[a-p]{32}$') { throw "-ChromeId must be 32 letters a-p" }
if ($FirefoxId -notmatch '^[A-Za-z0-9._@{}-]+$') { throw "bad -FirefoxId" }
$Keys = @{
chrome = "HKCU:\Software\Google\Chrome\NativeMessagingHosts\$HostName"
chromium = "HKCU:\Software\Chromium\NativeMessagingHosts\$HostName"
edge = "HKCU:\Software\Microsoft\Edge\NativeMessagingHosts\$HostName"
firefox = "HKCU:\Software\Mozilla\NativeMessagingHosts\$HostName"
}
$OutDir = Join-Path $env:LOCALAPPDATA "Kestrove\NativeMessagingHosts"
if ($Uninstall) {
foreach ($b in $Browsers) {
if (Test-Path $Keys[$b]) { Remove-Item $Keys[$b] -Force; Write-Host "removed $($Keys[$b])" }
}
Remove-Item (Join-Path $OutDir "$HostName.*.json") -ErrorAction SilentlyContinue
return
}
if (-not $Nmh) {
foreach ($c in @((Join-Path $Here "kestrove-nmh.exe"), (Join-Path $Here "..\src-tauri\target\release\kestrove-nmh.exe"), (Join-Path $Here "..\src-tauri\target\debug\kestrove-nmh.exe"))) {
if (Test-Path $c) { $Nmh = $c; break }
}
}
if (-not $Nmh -or -not (Test-Path $Nmh)) { throw "kestrove-nmh.exe not found; pass -Nmh" }
$Nmh = (Resolve-Path $Nmh).Path
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
foreach ($flavor in @("chrome", "firefox")) {
$m = Get-Content -Raw (Join-Path $Here "$HostName.$flavor.json") | ConvertFrom-Json
$m.path = $Nmh
if ($flavor -eq "chrome") { $m.allowed_origins = @("chrome-extension://$ChromeId/") } else { $m.allowed_extensions = @($FirefoxId) }
$m | ConvertTo-Json -Depth 4 | Set-Content -Encoding UTF8 (Join-Path $OutDir "$HostName.$flavor.json")
}
foreach ($b in $Browsers) {
if (-not $Keys.ContainsKey($b)) { throw "unknown browser $b" }
$flavor = if ($b -eq "firefox") { "firefox" } else { "chrome" }
New-Item -Force -Path $Keys[$b] | Out-Null
Set-Item -Path $Keys[$b] -Value (Join-Path $OutDir "$HostName.$flavor.json")
Write-Host "registered $($Keys[$b])"
}
+105
View File
@@ -0,0 +1,105 @@
#!/bin/sh
# Registers the Kestrove native-messaging host with the browsers of the current user (Linux, macOS).
# Windows: install.ps1.
#
# install.sh [--nmh /path/to/kestrove-nmh] [--chrome-id ID] [--firefox-id ID]
# [--browsers chrome,chromium,edge,brave,firefox] [--user-data-dir DIR] [--uninstall]
#
# With neither --browsers nor --user-data-dir it installs for every browser whose profile directory exists.
# --user-data-dir adds a Chromium-style profile dir started with --user-data-dir (Chromium then
# looks for NativeMessagingHosts/ inside it), e.g. for tests.
# allowed_origins / allowed_extensions pin the host to our extension ids (docs/04 A8).
set -eu
HOST=app.kestrove.nmh
HERE=$(cd "$(dirname "$0")" && pwd)
NMH=""
# Development ids: the unpacked Chrome build is pinned by the manifest key in kestrove-browser/scripts/build.mjs.
CHROME_ID=kogapfibnnpeapfcjkkpgjddbhnjfein
FIREFOX_ID=[email protected]
BROWSERS=""
USER_DATA_DIR=""
UNINSTALL=0
die() { echo "install.sh: $*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--nmh) NMH=$2; shift 2 ;;
--chrome-id) CHROME_ID=$2; shift 2 ;;
--firefox-id) FIREFOX_ID=$2; shift 2 ;;
--browsers) BROWSERS=$(echo "$2" | tr ',' ' '); shift 2 ;;
--user-data-dir) USER_DATA_DIR=$2; shift 2 ;;
--uninstall) UNINSTALL=1; shift ;;
-h|--help) sed -n '2,13p' "$0"; exit 0 ;;
*) die "unknown option $1" ;;
esac
done
echo "$CHROME_ID" | grep -Eq '^[a-p]{32}$' || die "--chrome-id must be 32 letters a-p"
echo "$FIREFOX_ID" | grep -Eq '^[A-Za-z0-9._@{}-]+$' || die "bad --firefox-id"
if [ "$UNINSTALL" = 0 ]; then
if [ -z "$NMH" ]; then
# Next to this script (installed layout), else the cargo build output (development).
for c in "$HERE/kestrove-nmh" "$HERE/../src-tauri/target/release/kestrove-nmh" "$HERE/../src-tauri/target/debug/kestrove-nmh"; do
[ -x "$c" ] && NMH=$c && break
done
fi
[ -n "$NMH" ] || die "kestrove-nmh not found; pass --nmh"
[ -x "$NMH" ] || die "$NMH is not executable"
NMH=$(cd "$(dirname "$NMH")" && pwd)/$(basename "$NMH")
case "$NMH" in *[\"\\\|\&]*) die "the host path may not contain quotes, backslashes, | or &" ;; esac
fi
case "$(uname -s)" in
Darwin)
BASE="$HOME/Library/Application Support"
dir_chrome="$BASE/Google/Chrome"
dir_chromium="$BASE/Chromium"
dir_edge="$BASE/Microsoft Edge"
dir_brave="$BASE/BraveSoftware/Brave-Browser"
dir_firefox="$BASE/Mozilla"
ff_sub=NativeMessagingHosts
;;
Linux)
CFG=${XDG_CONFIG_HOME:-$HOME/.config}
dir_chrome="$CFG/google-chrome"
dir_chromium="$CFG/chromium"
dir_edge="$CFG/microsoft-edge"
dir_brave="$CFG/BraveSoftware/Brave-Browser"
dir_firefox="$HOME/.mozilla"
ff_sub=native-messaging-hosts
;;
*) die "unsupported OS $(uname -s); on Windows use install.ps1" ;;
esac
# Auto-detect only when nothing was named: a test run with --user-data-dir must not touch real profiles.
[ -n "$BROWSERS$USER_DATA_DIR" ] || for b in chrome chromium edge brave firefox; do
eval "d=\$dir_$b"
[ -d "$d" ] && BROWSERS="$BROWSERS $b"
done
render() { # template → stdout
sed -e "s|__NMH_PATH__|$NMH|" -e "s|__CHROME_EXTENSION_ID__|$CHROME_ID|" -e "s|__FIREFOX_EXTENSION_ID__|$FIREFOX_ID|" "$HERE/$HOST.$1.json"
}
place() { # flavor dir
if [ "$UNINSTALL" = 1 ]; then
rm -f "$2/$HOST.json" && echo "removed $2/$HOST.json"
else
mkdir -p "$2"
render "$1" > "$2/$HOST.json.tmp" && chmod 644 "$2/$HOST.json.tmp" && mv "$2/$HOST.json.tmp" "$2/$HOST.json"
echo "installed $2/$HOST.json"
fi
}
for b in $BROWSERS; do
case "$b" in
chrome|chromium|edge|brave) eval "d=\$dir_$b"; place chrome "$d/NativeMessagingHosts" ;;
firefox) place firefox "$dir_firefox/$ff_sub" ;;
*) die "unknown browser $b" ;;
esac
done
[ -z "$USER_DATA_DIR" ] || place chrome "$USER_DATA_DIR/NativeMessagingHosts"
[ -n "$BROWSERS$USER_DATA_DIR" ] || echo "no supported browser profile found (use --browsers)"
+246 -158
View File
@@ -9,18 +9,18 @@
"version": "0.1.0", "version": "0.1.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "2.1.1" "@tauri-apps/api": "2.12.3"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.1.0", "@tauri-apps/cli": "2.12.2",
"typescript": "5.7.2", "typescript": "5.7.2",
"vite": "6.0.3" "vite": "6.4.4"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -35,9 +35,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -52,9 +52,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -69,9 +69,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -86,9 +86,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -103,9 +103,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -120,9 +120,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -137,9 +137,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -154,9 +154,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -171,9 +171,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -188,9 +188,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -205,9 +205,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -222,9 +222,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -239,9 +239,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -256,9 +256,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -273,9 +273,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -290,9 +290,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -307,9 +307,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -324,9 +324,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -341,9 +341,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -358,9 +358,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -374,10 +374,27 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -392,9 +409,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -409,9 +426,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -426,9 +443,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -810,9 +827,9 @@
] ]
}, },
"node_modules/@tauri-apps/api": { "node_modules/@tauri-apps/api": {
"version": "2.1.1", "version": "2.12.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.1.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.3.tgz",
"integrity": "sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A==", "integrity": "sha512-9s0PKw0p4r3kFq92jhpivYSp+cSht15o0gXpZxuQd0+dV9KCexEVkNaLELVoLdJWyoH4KlfQo5h3W2LIM8siJg==",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -820,9 +837,9 @@
} }
}, },
"node_modules/@tauri-apps/cli": { "node_modules/@tauri-apps/cli": {
"version": "2.1.0", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.2.tgz",
"integrity": "sha512-K2VhcKqBhAeS5pNOVdnR/xQRU6jwpgmkSL2ejHXcl0m+kaTggT0WRDQnFtPq6NljA7aE03cvwsbCAoFG7vtkJw==", "integrity": "sha512-PMSppzAnlT+BvZtRKuidxjRSm2r5t8kDpJ/czmmQUa0AtDNTwrjLE8vApK3phCWGg7isfnYL46Dl1jB4ArI0hA==",
"dev": true, "dev": true,
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"bin": { "bin": {
@@ -836,22 +853,23 @@
"url": "https://opencollective.com/tauri" "url": "https://opencollective.com/tauri"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.1.0", "@tauri-apps/cli-darwin-arm64": "2.12.2",
"@tauri-apps/cli-darwin-x64": "2.1.0", "@tauri-apps/cli-darwin-x64": "2.12.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.1.0", "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.1.0", "@tauri-apps/cli-linux-arm64-gnu": "2.12.2",
"@tauri-apps/cli-linux-arm64-musl": "2.1.0", "@tauri-apps/cli-linux-arm64-musl": "2.12.2",
"@tauri-apps/cli-linux-x64-gnu": "2.1.0", "@tauri-apps/cli-linux-riscv64-gnu": "2.12.2",
"@tauri-apps/cli-linux-x64-musl": "2.1.0", "@tauri-apps/cli-linux-x64-gnu": "2.12.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.1.0", "@tauri-apps/cli-linux-x64-musl": "2.12.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.1.0", "@tauri-apps/cli-win32-arm64-msvc": "2.12.2",
"@tauri-apps/cli-win32-x64-msvc": "2.1.0" "@tauri-apps/cli-win32-ia32-msvc": "2.12.2",
"@tauri-apps/cli-win32-x64-msvc": "2.12.2"
} }
}, },
"node_modules/@tauri-apps/cli-darwin-arm64": { "node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.1.0", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.2.tgz",
"integrity": "sha512-ESc6J6CE8hl1yKH2vJ+ALF+thq4Be+DM1mvmTyUCQObvezNCNhzfS6abIUd3ou4x5RGH51ouiANeT3wekU6dCw==", "integrity": "sha512-Lk3zaBTfzNvA7670Al5PNgo2M/95uaDPe1yp+ILJeDOmol87XiYuK5E5+FUbK5e6U6OORx9qpZdOo2U+5tETtQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -866,9 +884,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-darwin-x64": { "node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.1.0", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.2.tgz",
"integrity": "sha512-TasHS442DFs8cSH2eUQzuDBXUST4ECjCd0yyP+zZzvAruiB0Bg+c8A+I/EnqCvBQ2G2yvWLYG8q/LI7c87A5UA==", "integrity": "sha512-2CyYjJ833JB3sKRz4B0W/Tv4oOVC16ws1bxdSCWj1XvrmanWwvZi99e5tNIoCHwHhcOm0jh+sh0PCGQyls6Z+w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -883,9 +901,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.1.0", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.2.tgz",
"integrity": "sha512-aP7ZBGNL4ny07Cbb6kKpUOSrmhcIK2KhjviTzYlh+pPhAptxnC78xQGD3zKQkTi2WliJLPmBYbOHWWQa57lQ9w==", "integrity": "sha512-uvt2xbone4JftKarelssi86Kqnl7JiG1Smh5+Ae+nZmtM+JUhTGmqkcqfUPXRW8Ygwog92POZvd4oGDN9j8eXQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -900,9 +918,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-gnu": { "node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.1.0", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.2.tgz",
"integrity": "sha512-ZTdgD5gLeMCzndMT2f358EkoYkZ5T+Qy6zPzU+l5vv5M7dHVN9ZmblNAYYXmoOuw7y+BY4X/rZvHV9pcGrcanQ==", "integrity": "sha512-DZlDJ24u3tlocKOYqzR+b2M+Qr+5WFcoMBZ7EfdhHN5j3ZXwBBpms4G4gyNvPCrak5vMzmdZ65ecBXPJcC+5sA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -917,9 +935,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-arm64-musl": { "node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.1.0", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.2.tgz",
"integrity": "sha512-NzwqjUCilhnhJzusz3d/0i0F1GFrwCQbkwR6yAHUxItESbsGYkZRJk0yMEWkg3PzFnyK4cWTlQJMEU52TjhEzA==", "integrity": "sha512-pb+RTZUYH/p0gR4j4ecCCq+FNfz3W3eKuUQvVEBn7MQZx6DzfLO7FaYzt2NyDKWpdNN3lP10mdzOvw3R5P5aAQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -933,10 +951,27 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.2.tgz",
"integrity": "sha512-nidEvDUJyHYNbIv9MUk6KiEix8IXfRh4Wj2G2SA5g8nFmgbBCwazUNWYiG/LhNeNbfZpBMLerRBUtoQ5K0GidA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": { "node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.1.0", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.2.tgz",
"integrity": "sha512-TyiIpMEtZxNOQmuFyfJwaaYbg3movSthpBJLIdPlKxSAB2BW0VWLY3/ZfIxm/G2YGHyREkjJvimzYE0i37PnMA==", "integrity": "sha512-gkXyYn3w+Xfc8nz/DleEbkm1M7bBuU2cejqUXNmI98cA2P5kp+oPmS4ALTorb1FaNWjpsKoYZBWsbS9BXxYFkQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -951,9 +986,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-linux-x64-musl": { "node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.1.0", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.2.tgz",
"integrity": "sha512-/dQd0TlaxBdJACrR72DhynWftzHDaX32eBtS5WBrNJ+nnNb+znM3gON6nJ9tSE9jgDa6n1v2BkI/oIDtypfUXw==", "integrity": "sha512-MrjgoZ09s8Hsn99XvxSNvK8NQTZxi7XiyK3FxxytzfHgz2jb3qUT69CZtsokRkN/zdi9rCgrMtq+w9XNICjcfQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -968,9 +1003,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-arm64-msvc": { "node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.1.0", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.2.tgz",
"integrity": "sha512-NdQJO7SmdYqOcE+JPU7bwg7+odfZMWO6g8xF9SXYCMdUzvM2Gv/AQfikNXz5yS7ralRhNFuW32i5dcHlxh4pDg==", "integrity": "sha512-BR+CYM7iF3lkfyTtVkwMQU/s9hwegLpj3UqDjaxKCPmixQCa1csBBPy3C75V+FhLtOjIXNna4Gt6Vm9oxbV+qA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -985,9 +1020,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-ia32-msvc": { "node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.1.0", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.2.tgz",
"integrity": "sha512-f5h8gKT/cB8s1ticFRUpNmHqkmaLutT62oFDB7N//2YTXnxst7EpMIn1w+QimxTvTk2gcx6EcW6bEk/y2hZGzg==", "integrity": "sha512-zODJo9l3TbNBM+GrO/yPqGeELNMK5cjIc+QHh1WQc3l8tD+jDZGTQyYuAxViCsM6Uh5uWJ//RnCZG9LXrvsXww==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1002,9 +1037,9 @@
} }
}, },
"node_modules/@tauri-apps/cli-win32-x64-msvc": { "node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.1.0", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.2.tgz",
"integrity": "sha512-P/+LrdSSb5Xbho1LRP4haBjFHdyPdjWvGgeopL96OVtrFpYnfC+RctB45z2V2XxqFk3HweDDxk266btjttfjGw==", "integrity": "sha512-cQXI2ZaZmwBDt0XATCqASVanYizxkEE3ceQVlVHXaQ51HzsQUWA04c9Nz7LrrY2lgAu3jFMyat+8Yz3Z2R2qBA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1026,9 +1061,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.24.2", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -1039,31 +1074,50 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2", "@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.24.2", "@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.24.2", "@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.24.2", "@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.24.2", "@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.24.2", "@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.24.2", "@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.24.2", "@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.24.2", "@esbuild/openbsd-x64": "0.25.12",
"@esbuild/sunos-x64": "0.24.2", "@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/win32-arm64": "0.24.2", "@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-x64": "0.24.2" "@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
} }
}, },
"node_modules/fsevents": { "node_modules/fsevents": {
@@ -1107,6 +1161,20 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.30", "version": "8.5.30",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz",
@@ -1192,6 +1260,23 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.7.2", "version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
@@ -1207,15 +1292,18 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.0.3", "version": "6.4.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz",
"integrity": "sha512-Cmuo5P0ENTN6HxLSo6IHsjCLn/81Vgrp81oaiFFMRa8gGDj5xEjIcEpf2ZymZtZR8oU0P2JX5WuUp/rlXcHkAw==", "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.24.0", "esbuild": "^0.25.0",
"postcss": "^8.4.49", "fdir": "^6.4.4",
"rollup": "^4.23.0" "picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
+3 -3
View File
@@ -10,11 +10,11 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "2.1.1" "@tauri-apps/api": "2.12.3"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.1.0", "@tauri-apps/cli": "2.12.2",
"typescript": "5.7.2", "typescript": "5.7.2",
"vite": "6.0.3" "vite": "6.4.4"
} }
} }
+6148
View File
File diff suppressed because it is too large Load Diff
+39 -1
View File
@@ -6,6 +6,11 @@ rust-version = "1.82"
license = "Apache-2.0" license = "Apache-2.0"
publish = false publish = false
# Shared by the app, the native-messaging host and the integration tests.
[lib]
name = "kestrove_desktop"
path = "src/lib.rs"
[[bin]] [[bin]]
name = "kestrove-desktop" name = "kestrove-desktop"
path = "src/main.rs" path = "src/main.rs"
@@ -21,10 +26,43 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
# Repos are checked out side by side (see ../../README.md). # Repos are checked out side by side (see ../../README.md).
kv-ffi = { path = "../../kestrove-core/crates/kv-ffi" } kv-ffi = { path = "../../kestrove-core/crates/kv-ffi" }
tauri = { version = "2", features = [] } tauri = { version = "2", features = ["tray-icon"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
arboard = "3" arboard = "3"
# Browser pairing channel (docs/05): X25519 + HKDF-SHA256 + AES-256-GCM (what WebCrypto offers on the extension side).
x25519-dalek = { version = "2", features = ["static_secrets", "zeroize"] }
hkdf = "0.12"
sha2 = "0.10"
aes-gcm = "0.10"
getrandom = "0.2"
base64 = "0.22"
uuid = { version = "1", features = ["v4"] }
url = "2"
zeroize = "1"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System_IO",
"Win32_System_Pipes",
"Win32_System_Threading",
"Win32_System_StationsAndDesktops",
"Win32_System_SystemInformation",
"Win32_UI_Input_KeyboardAndMouse",
] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
[dev-dependencies]
tempfile = "3"
[profile.release] [profile.release]
lto = true lto = true
+71
View File
@@ -0,0 +1,71 @@
//! Headless stand-in for the desktop app, for end-to-end tests of the browser extension and
//! `kestrove-nmh`. It runs the same `Broker` as the app over the same IPC endpoint, with an
//! in-memory vault holding one test login, and plays the user: it approves pairings and saves.
//! Never ship this: it auto-approves.
//!
//! KESTROVE_IPC_DIR=/tmp/kv-e2e KESTROVE_TEST_ORIGIN=http://127.0.0.1:8765 \
//! cargo run --example test-host
//!
//! Prints `READY <endpoint>`, then `PAIRED <words>` and `SAVED <origin> <username>` as they happen.
use std::io::Write;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
use kestrove_desktop::ipc;
use kv_ffi::{ItemDto, ItemKindDto, VaultCore};
struct Quiet;
impl Ui for Quiet {
fn pairing_requested(&self, _: &PairingView) {}
fn save_requested(&self, _: &SaveView) {}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let origin = std::env::var("KESTROVE_TEST_ORIGIN").unwrap_or_else(|_| "http://127.0.0.1:8765".into());
let core = VaultCore::new_in_memory();
core.create_account("test master password".into())?;
core.save_item(ItemDto {
id: String::new(),
kind: ItemKindDto::Login,
title: "Test login".into(),
username: Some("[email protected]".into()),
password: Some("correct-horse-42".into()),
urls: vec![origin],
totp: Some("JBSWY3DPEHPK3PXP".into()),
notes: None,
favorite: false,
tags: vec![],
modified_at: 0,
})?;
let broker = Broker::new(core, Arc::new(Quiet), PairingStore::in_memory());
let listener = ipc::Listener::bind()?;
println!("READY {}", ipc::endpoint()?.display());
std::io::stdout().flush()?;
let b = broker.clone();
thread::spawn(move || loop {
thread::sleep(Duration::from_millis(200));
if let Some(p) = b.pending_pairing() {
if b.resolve_pairing(&p.id, true).is_ok() {
println!("PAIRED {}", p.words.join(" "));
}
}
for s in b.pending_saves() {
if b.resolve_save(&s.id, true).is_ok() {
println!("SAVED {} {}", s.origin, s.username);
}
}
let _ = std::io::stdout().flush();
});
loop {
let conn = listener.accept()?;
let b = broker.clone();
thread::spawn(move || {
let _ = b.serve(conn);
});
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+213
View File
@@ -0,0 +1,213 @@
//! Auto-lock (docs/02 "Local unlock"): after the idle timeout (default 10 min), on screen lock,
//! and after sleep/suspend.
//!
//! * Idle: system input idle time where the OS reports it (Windows `GetLastInputInfo`, macOS
//! `CGEventSourceSecondsSinceLastEventType`). Linux has no portable call without a D-Bus
//! dependency, so it falls back to activity inside Kestrove (UI input and browser fills).
//! * Screen lock: Windows `OpenInputDesktop` (fails or isn't switchable while locked), Linux
//! logind's `LockedHint` (via `loginctl`), macOS `CGSSessionScreenIsLocked`.
//! * Suspend: the wall clock jumping well past the poll interval means the machine slept.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub const DEFAULT_IDLE: Duration = Duration::from_secs(10 * 60);
const POLL: Duration = Duration::from_secs(5);
/// A gap this much larger than POLL between two ticks means the machine was asleep.
const SLEEP_GAP: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
Idle,
ScreenLocked,
Sleep,
}
/// Last activity seen inside the app, for platforms without a system idle time.
#[derive(Default)]
pub struct Activity(AtomicU64);
fn now_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
impl Activity {
pub fn new() -> Arc<Self> {
let a = Arc::new(Self::default());
a.touch();
a
}
pub fn touch(&self) {
self.0.store(now_secs(), Ordering::Relaxed);
}
pub fn idle(&self) -> Duration {
Duration::from_secs(now_secs().saturating_sub(self.0.load(Ordering::Relaxed)))
}
}
/// Pure decision for one tick, unit-tested below.
pub fn decide(idle: Duration, limit: Duration, screen_locked: bool, wall_gap: Duration) -> Option<Reason> {
if wall_gap > POLL + SLEEP_GAP {
Some(Reason::Sleep)
} else if screen_locked {
Some(Reason::ScreenLocked)
} else if idle >= limit {
Some(Reason::Idle)
} else {
None
}
}
/// Polls in a background thread. `is_unlocked` gates the checks; `lock` is called on a trigger.
pub fn spawn(
activity: Arc<Activity>,
limit: impl Fn() -> Duration + Send + 'static,
is_unlocked: impl Fn() -> bool + Send + 'static,
lock: impl Fn(Reason) + Send + 'static,
) {
thread::Builder::new()
.name("autolock".into())
.spawn(move || {
let mut last_wall = SystemTime::now();
let mut last_mono = Instant::now();
loop {
thread::sleep(POLL);
let wall = SystemTime::now();
// Monotonic clocks may pause during sleep, the wall clock doesn't.
let gap = wall.duration_since(last_wall).unwrap_or_default().max(last_mono.elapsed());
last_wall = wall;
last_mono = Instant::now();
if !is_unlocked() {
continue;
}
let idle = platform::system_idle().unwrap_or_else(|| activity.idle());
if let Some(r) = decide(idle, limit(), platform::screen_locked(), gap) {
lock(r);
}
}
})
.expect("spawn autolock thread");
}
#[cfg(target_os = "macos")]
mod platform {
use std::time::Duration;
use core_foundation::base::{CFType, TCFType};
use core_foundation::boolean::CFBoolean;
use core_foundation::dictionary::{CFDictionary, CFDictionaryRef};
use core_foundation::string::CFString;
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
fn CGEventSourceSecondsSinceLastEventType(state: i32, event_type: u32) -> f64;
fn CGSessionCopyCurrentDictionary() -> CFDictionaryRef;
}
const HID_SYSTEM_STATE: i32 = 1;
const ANY_INPUT_EVENT: u32 = u32::MAX;
pub fn system_idle() -> Option<Duration> {
// SAFETY: pure query with constant arguments.
let s = unsafe { CGEventSourceSecondsSinceLastEventType(HID_SYSTEM_STATE, ANY_INPUT_EVENT) };
(s.is_finite() && s >= 0.0).then(|| Duration::from_secs_f64(s))
}
pub fn screen_locked() -> bool {
// SAFETY: a Copy function: we own the returned dictionary (create rule), or get null.
let raw = unsafe { CGSessionCopyCurrentDictionary() };
if raw.is_null() {
return false;
}
// SAFETY: raw is a non-null CFDictionary we own.
let dict: CFDictionary<CFString, CFType> = unsafe { CFDictionary::wrap_under_create_rule(raw) };
dict.find(CFString::from_static_string("CGSSessionScreenIsLocked"))
.and_then(|v| v.downcast::<CFBoolean>())
.is_some_and(bool::from)
}
}
#[cfg(windows)]
mod platform {
use std::time::Duration;
use windows_sys::Win32::System::StationsAndDesktops::{CloseDesktop, OpenInputDesktop, SwitchDesktop, DESKTOP_SWITCHDESKTOP};
use windows_sys::Win32::System::SystemInformation::GetTickCount;
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO};
pub fn system_idle() -> Option<Duration> {
let mut info = LASTINPUTINFO { cbSize: std::mem::size_of::<LASTINPUTINFO>() as u32, dwTime: 0 };
// SAFETY: info is a valid, sized LASTINPUTINFO.
if unsafe { GetLastInputInfo(&mut info) } == 0 {
return None;
}
// SAFETY: no preconditions. Both are u32 ms tick counts; wrapping_sub handles the 49-day wrap.
let now = unsafe { GetTickCount() };
Some(Duration::from_millis(u64::from(now.wrapping_sub(info.dwTime))))
}
/// While the workstation is locked the input desktop is Winlogon's, which we can't switch to.
pub fn screen_locked() -> bool {
// SAFETY: the desktop handle is closed before returning.
unsafe {
let h = OpenInputDesktop(0, 0, DESKTOP_SWITCHDESKTOP);
if h.is_null() {
return true;
}
let switchable = SwitchDesktop(h) != 0;
CloseDesktop(h);
!switchable
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod platform {
use std::process::Command;
use std::time::Duration;
pub fn system_idle() -> Option<Duration> {
None
}
pub fn screen_locked() -> bool {
let session = std::env::var("XDG_SESSION_ID").unwrap_or_else(|_| "auto".into());
Command::new("loginctl")
.args(["show-session", &session, "-p", "LockedHint", "--value"])
.output()
.ok()
.filter(|o| o.status.success())
.is_some_and(|o| o.stdout.trim_ascii() == b"yes")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decisions() {
let min = Duration::from_secs(60);
let tick = POLL;
assert_eq!(decide(Duration::ZERO, 10 * min, false, tick), None);
assert_eq!(decide(10 * min, 10 * min, false, tick), Some(Reason::Idle));
assert_eq!(decide(Duration::ZERO, 10 * min, true, tick), Some(Reason::ScreenLocked));
assert_eq!(decide(Duration::ZERO, 10 * min, false, 5 * min), Some(Reason::Sleep));
}
#[test]
fn platform_probes_do_not_panic() {
let _ = platform::system_idle();
let _ = platform::screen_locked();
}
#[test]
fn activity_tracks_touch() {
let a = Activity::new();
assert!(a.idle() < Duration::from_secs(2));
}
}
+49 -38
View File
@@ -1,54 +1,65 @@
//! Native messaging host for Chrome/Edge/Firefox (kestrove-spec/docs/05). //! Native messaging host for Chrome/Edge/Brave/Firefox (kestrove-spec/docs/05).
//! //!
//! The browser starts this process and talks over stdio: each message is a u32 length //! The browser starts this process and talks over stdio: each message is a u32 length (native
//! (native byte order) followed by UTF-8 JSON, at most 1 MiB to the browser. //! byte order) followed by UTF-8 JSON. This process holds **no** secrets and can't read the
//! This process holds **no** secrets. It relays each request to the running desktop app over //! traffic: frames are end-to-end encrypted between the extension and the app (pairing channel,
//! a local IPC channel (Windows named pipe `\\.\pipe\kestrove-nmh-<user>`, Unix socket //! see `channel.rs`). It relays each frame to the running app over local IPC (`ipc.rs`) and
//! `$XDG_RUNTIME_DIR/kestrove/nmh.sock` with 0600 perms). The app re-checks origins and asks for //! relays the app's reply back, strictly one reply per request.
//! approval or unlock as needed. //!
//! When the app isn't running, every request gets `{t:"error", code:"unavailable"}`; it
//! reconnects on the next request, so starting the app later just works.
use std::io::{self, Read, Write}; use std::io::{self, Write};
use serde_json::{json, Value}; use kestrove_desktop::framing::{read_frame, read_json, write_frame, write_json};
use kestrove_desktop::ipc::{self, Conn};
use serde_json::json;
const MAX_IN: usize = 4 * 1024 * 1024; /// Chrome passes the caller origin (`chrome-extension://<id>/`) as the first argument; Firefox
/// passes the manifest path and then the extension id. Informational only (shown when pairing):
fn read_message(stdin: &mut impl Read) -> io::Result<Option<Value>> { /// the browser's `allowed_origins` check and the pairing key are what actually gate access.
let mut len = [0u8; 4]; fn caller() -> String {
match stdin.read_exact(&mut len) { let args: Vec<String> = std::env::args().skip(1).collect();
Ok(()) => {} match args.as_slice() {
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), // browser closed the port [a, ..] if a.starts_with("chrome-extension://") => a.clone(),
Err(e) => return Err(e), [_, id, ..] if !id.starts_with("--") => id.clone(),
_ => String::new(),
} }
let len = u32::from_ne_bytes(len) as usize;
if len > MAX_IN {
return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large"));
}
let mut buf = vec![0u8; len];
stdin.read_exact(&mut buf)?;
serde_json::from_slice(&buf).map(Some).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
} }
fn write_message(stdout: &mut impl Write, v: &Value) -> io::Result<()> { fn connect(caller: &str) -> io::Result<Conn> {
let bytes = serde_json::to_vec(v)?; let mut c = ipc::connect()?;
stdout.write_all(&(bytes.len() as u32).to_ne_bytes())?; write_json(&mut c, &json!({ "t": "nmh_hello", "caller": caller }))?;
stdout.write_all(&bytes)?; match read_json(&mut c)? {
stdout.flush() Some(v) if v["t"] == "ok" => Ok(c),
_ => Err(io::Error::new(io::ErrorKind::InvalidData, "unexpected hello reply")),
}
} }
/// TODO(M3): forward to the desktop app over local IPC, inside the paired AEAD channel. fn relay(conn: &mut Conn, frame: &[u8]) -> io::Result<Vec<u8>> {
fn relay(req: &Value) -> Value { write_frame(conn, frame)?;
match req.get("type").and_then(Value::as_str) { read_frame(conn)?.ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "app closed the connection"))
Some("hello") => json!({ "type": "hello", "version": env!("CARGO_PKG_VERSION"), "paired": false }),
Some(_) => json!({ "type": "error", "code": "unavailable" }),
None => json!({ "type": "error", "code": "bad_request" }),
}
} }
fn main() -> io::Result<()> { fn main() -> io::Result<()> {
let caller = caller();
let (mut stdin, mut stdout) = (io::stdin().lock(), io::stdout().lock()); let (mut stdin, mut stdout) = (io::stdin().lock(), io::stdout().lock());
while let Some(req) = read_message(&mut stdin)? { let unavailable = serde_json::to_vec(&json!({ "t": "error", "code": "unavailable" }))?;
write_message(&mut stdout, &relay(&req))?; let mut conn: Option<Conn> = None;
while let Some(frame) = read_frame(&mut stdin)? {
if conn.is_none() {
conn = connect(&caller).ok();
}
let reply = match conn.as_mut().map(|c| relay(c, &frame)) {
Some(Ok(r)) => r,
Some(Err(_)) => {
conn = None;
unavailable.clone()
}
None => unavailable.clone(),
};
write_frame(&mut stdout, &reply)?;
stdout.flush()?;
} }
Ok(()) Ok(())
} }
+705
View File
@@ -0,0 +1,705 @@
//! The app's side of the browser protocol: pairing, encrypted sessions and request handling.
//!
//! Outer frames (JSON, field `t`), relayed unchanged by `kestrove-nmh`:
//!
//! | extension → app | app → extension |
//! |---|---|
//! | `{t:"nmh_hello", caller}` (sent by the host itself) | `{t:"ok"}` |
//! | `{t:"ping"}` | `{t:"pong", version}` |
//! | `{t:"pair_init", pub, name}` | `{t:"pair_started", pairing_id, pub}` (app shows the words) |
//! | `{t:"pair_confirm", pairing_id, proof}` | `{t:"pair_waiting"}` / `{t:"pair_done"}` / error |
//! | `{t:"open", pairing_id, nonce}` | `{t:"opened", nonce}` / `{t:"error", code:"not_paired"}` |
//! | `{t:"msg", n, c}` | `{t:"msg", n, c}` / `{t:"error", code:"bad_session"}` |
//!
//! `msg` plaintexts are `{rid, body}` where `body` is a `NativeRequest` / `NativeResponse` from
//! kestrove-browser/src/protocol.ts. Origins come from the extension's `sender`, and are
//! re-checked here against the vault before any secret leaves the app.
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use kv_ffi::{CoreError, ItemDto, ItemKindDto, VaultCore};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use zeroize::Zeroizing;
use crate::channel::{random, verify_confirm, Ephemeral, Session};
use crate::framing::{read_frame, write_json};
use crate::private_file;
/// How long a pairing request waits for the user in the app.
const PAIRING_TTL: Duration = Duration::from_secs(300);
/// How long a save prompt stays in the app.
const SAVE_TTL: Duration = Duration::from_secs(600);
const MAX_PENDING_SAVES: usize = 8;
/// What the app UI must show. Implemented with Tauri events in the app, and auto-approving in tests.
pub trait Ui: Send + Sync {
fn pairing_requested(&self, req: &PairingView);
fn save_requested(&self, req: &SaveView);
/// A user-driven request (a fill) arrived: counts as activity for auto-lock.
fn activity(&self) {}
}
#[derive(Debug, Clone, Serialize)]
pub struct PairingView {
pub id: String,
pub name: String,
pub caller: String,
pub words: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SaveView {
pub id: String,
pub origin: String,
pub username: String,
/// Some item already has this username for this site: saving updates its password.
pub update: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredPairing {
pub id: String,
pub name: String,
pub caller: String,
pub words: Vec<String>,
pub created_at: u64,
key: String,
}
impl StoredPairing {
fn key(&self) -> Option<Zeroizing<[u8; 32]>> {
let raw = Zeroizing::new(B64.decode(&self.key).ok()?);
let arr: [u8; 32] = raw.as_slice().try_into().ok()?;
Some(Zeroizing::new(arr))
}
}
/// Persisted pairings (`pairings.json`, 0600). The pairing key only authenticates the
/// extension to the app; it can't decrypt the vault. Every secret still needs an unlocked vault.
pub struct PairingStore {
path: Option<PathBuf>,
items: Mutex<Vec<StoredPairing>>,
}
impl PairingStore {
pub fn in_memory() -> Self {
Self { path: None, items: Mutex::new(Vec::new()) }
}
pub fn open(path: PathBuf) -> io::Result<Self> {
let items = match private_file::read(&path)? {
Some(b) => serde_json::from_slice(&b).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
None => Vec::new(),
};
Ok(Self { path: Some(path), items: Mutex::new(items) })
}
fn guard(&self) -> MutexGuard<'_, Vec<StoredPairing>> {
self.items.lock().unwrap_or_else(|e| e.into_inner())
}
fn persist(&self, items: &[StoredPairing]) -> io::Result<()> {
match &self.path {
Some(path) => private_file::write(path, &serde_json::to_vec_pretty(items)?),
None => Ok(()),
}
}
pub fn list(&self) -> Vec<StoredPairing> {
self.guard().clone()
}
fn get(&self, id: &str) -> Option<StoredPairing> {
self.guard().iter().find(|p| p.id == id).cloned()
}
fn add(&self, p: StoredPairing) -> io::Result<()> {
let mut items = self.guard();
items.push(p);
self.persist(&items)
}
pub fn remove(&self, id: &str) -> io::Result<bool> {
let mut items = self.guard();
let before = items.len();
items.retain(|p| p.id != id);
self.persist(&items)?;
Ok(items.len() != before)
}
}
struct PendingPairing {
view: PairingView,
key: Zeroizing<[u8; 32]>,
at: Instant,
}
struct PendingSave {
id: String,
origin: String,
username: String,
password: Zeroizing<String>,
at: Instant,
}
pub struct Broker {
core: Arc<VaultCore>,
ui: Arc<dyn Ui>,
pairings: PairingStore,
pending_pairing: Mutex<Option<PendingPairing>>,
saves: Mutex<Vec<PendingSave>>,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
fn now_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn err(code: &str) -> Value {
json!({ "type": "error", "code": code })
}
fn core_err(e: &CoreError) -> Value {
match e {
CoreError::Locked | CoreError::NoAccount => err("locked"),
CoreError::NotFound => err("origin_mismatch"),
_ => err("internal"),
}
}
fn b64_array<const N: usize>(v: &Value) -> Option<[u8; N]> {
B64.decode(v.as_str()?).ok()?.try_into().ok()
}
/// `scheme://host[:port]` for http(s) origins; anything else (paths, credentials, other schemes) is rejected.
pub fn normalize_origin(raw: &str) -> Option<String> {
let u = url::Url::parse(raw).ok()?;
if !matches!(u.scheme(), "https" | "http") || u.host_str().is_none_or(str::is_empty) {
return None;
}
if !u.username().is_empty() || u.password().is_some() {
return None;
}
Some(u.origin().ascii_serialization())
}
/// Per-connection state.
pub struct Conn {
caller: String,
session: Option<Session>,
}
impl Broker {
pub fn new(core: Arc<VaultCore>, ui: Arc<dyn Ui>, pairings: PairingStore) -> Arc<Self> {
Arc::new(Self { core, ui, pairings, pending_pairing: Mutex::new(None), saves: Mutex::new(Vec::new()) })
}
pub fn core(&self) -> &Arc<VaultCore> {
&self.core
}
/// Serves one `kestrove-nmh` connection until it closes. Strict request → response.
pub fn serve(&self, mut io: impl Read + Write) -> io::Result<()> {
let mut conn = Conn { caller: String::new(), session: None };
while let Some(frame) = read_frame(&mut io)? {
let reply = match serde_json::from_slice::<Value>(&frame) {
Ok(v) => self.handle_frame(&mut conn, &v),
Err(_) => json!({ "t": "error", "code": "bad_request" }),
};
write_json(&mut io, &reply)?;
}
Ok(())
}
pub fn handle_frame(&self, conn: &mut Conn, v: &Value) -> Value {
let outer_err = |code: &str| json!({ "t": "error", "code": code });
match v.get("t").and_then(Value::as_str) {
Some("nmh_hello") => {
conn.caller = v.get("caller").and_then(Value::as_str).unwrap_or("").chars().take(200).collect();
json!({ "t": "ok" })
}
Some("ping") => json!({ "t": "pong", "version": env!("CARGO_PKG_VERSION") }),
Some("pair_init") => {
let Some(ext_pub) = v.get("pub").and_then(b64_array::<32>) else { return outer_err("bad_request") };
let name: String = v.get("name").and_then(Value::as_str).unwrap_or("Browser").chars().take(80).collect();
self.pair_init(conn, &ext_pub, name).unwrap_or_else(|| outer_err("bad_request"))
}
Some("pair_confirm") => {
let (Some(id), Some(proof)) = (
v.get("pairing_id").and_then(Value::as_str),
v.get("proof").and_then(Value::as_str).and_then(|p| B64.decode(p).ok()),
) else {
return outer_err("bad_request");
};
self.pair_confirm(id, &proof)
}
Some("open") => {
let (Some(id), Some(ne)) =
(v.get("pairing_id").and_then(Value::as_str), v.get("nonce").and_then(b64_array::<16>))
else {
return outer_err("bad_request");
};
let Some(key) = self.pairings.get(id).and_then(|p| p.key()) else { return outer_err("not_paired") };
let na = random::<16>();
conn.session = Some(Session::app(&key, &ne, &na));
json!({ "t": "opened", "nonce": B64.encode(na) })
}
Some("msg") => {
let Some(session) = conn.session.as_mut() else { return outer_err("bad_session") };
let (Some(n), Some(c)) = (
v.get("n").and_then(Value::as_u64),
v.get("c").and_then(Value::as_str).and_then(|c| B64.decode(c).ok()),
) else {
return outer_err("bad_request");
};
let Ok(pt) = session.open(n, &c) else {
conn.session = None; // the extension re-opens a fresh session
return outer_err("bad_session");
};
let parsed: Option<Value> = serde_json::from_slice(&pt).ok();
drop(pt);
let (rid, body) = match &parsed {
Some(p) => (p.get("rid").cloned().unwrap_or(Value::Null), p.get("body").cloned().unwrap_or(Value::Null)),
None => (Value::Null, Value::Null),
};
let reply = Zeroizing::new(
serde_json::to_vec(&json!({ "rid": rid, "body": self.handle_request(&body) })).unwrap_or_default(),
);
let session = conn.session.as_mut().expect("session still open");
let (n, c) = session.seal(&reply);
json!({ "t": "msg", "n": n, "c": B64.encode(c) })
}
_ => outer_err("unknown_type"),
}
}
// ------------------------------------------------------------ pairing
fn pair_init(&self, conn: &Conn, ext_pub: &[u8; 32], name: String) -> Option<Value> {
let eph = Ephemeral::generate();
let p = eph.pair(ext_pub)?;
let view = PairingView {
id: uuid::Uuid::new_v4().to_string(),
name,
caller: conn.caller.clone(),
words: p.words.iter().map(|w| w.to_string()).collect(),
};
let reply = json!({ "t": "pair_started", "pairing_id": view.id, "pub": B64.encode(eph.public) });
self.ui.pairing_requested(&view);
// One pending request at a time; a new one replaces the old (and its words).
*lock(&self.pending_pairing) = Some(PendingPairing { view, key: p.key, at: Instant::now() });
Some(reply)
}
fn pair_confirm(&self, id: &str, proof: &[u8]) -> Value {
if let Some(key) = self.pairings.get(id).and_then(|p| p.key()) {
return if verify_confirm(&key, id, proof) {
json!({ "t": "pair_done" })
} else {
json!({ "t": "error", "code": "not_paired" })
};
}
match lock(&self.pending_pairing).as_ref() {
Some(p) if p.view.id == id && p.at.elapsed() < PAIRING_TTL && verify_confirm(&p.key, id, proof) => {
json!({ "t": "pair_waiting" })
}
_ => json!({ "t": "error", "code": "not_paired" }),
}
}
pub fn pending_pairing(&self) -> Option<PairingView> {
let mut g = lock(&self.pending_pairing);
if g.as_ref().is_some_and(|p| p.at.elapsed() >= PAIRING_TTL) {
*g = None;
}
g.as_ref().map(|p| p.view.clone())
}
/// The user compared the words and accepted (or rejected) the pairing. Requires an unlocked
/// vault, so someone at a locked machine can't pair a new extension.
pub fn resolve_pairing(&self, id: &str, accept: bool) -> Result<(), String> {
let mut g = lock(&self.pending_pairing);
let Some(p) = g.take_if(|p| p.view.id == id) else { return Err("This pairing request expired.".into()) };
if !accept {
return Ok(());
}
if self.core.is_locked() {
*g = Some(p);
return Err("Unlock Kestrove to pair a browser.".into());
}
if p.at.elapsed() >= PAIRING_TTL {
return Err("This pairing request expired.".into());
}
self.pairings
.add(StoredPairing {
id: p.view.id,
name: p.view.name,
caller: p.view.caller,
words: p.view.words,
created_at: now_secs(),
key: B64.encode(p.key.as_ref()),
})
.map_err(|e| format!("Could not save the pairing: {e}"))
}
pub fn pairings(&self) -> Vec<StoredPairing> {
self.pairings.list()
}
pub fn revoke_pairing(&self, id: &str) -> Result<(), String> {
self.pairings.remove(id).map(|_| ()).map_err(|e| e.to_string())
}
// ------------------------------------------------------------ requests
pub fn handle_request(&self, body: &Value) -> Value {
let Some(kind) = body.get("type").and_then(Value::as_str) else { return err("bad_request") };
if kind == "hello" {
return json!({ "type": "hello", "version": env!("CARGO_PKG_VERSION"), "paired": true });
}
let Some(origin) = body.get("origin").and_then(Value::as_str).and_then(normalize_origin) else {
return err("bad_request");
};
let item_id = || body.get("itemId").and_then(Value::as_str).filter(|s| !s.is_empty());
match kind {
"query" => match self.core.items_for_url(origin) {
Ok(items) => json!({
"type": "identities",
"items": items.iter().map(|i| json!({
"id": i.id, "title": i.title, "username": i.username.clone().unwrap_or_default(), "totp": i.has_totp,
})).collect::<Vec<_>>(),
}),
Err(e) => core_err(&e),
},
"fill" => {
let Some(id) = item_id() else { return err("bad_request") };
self.ui.activity();
match self.matching_item(&origin, id) {
Ok(d) => json!({
"type": "credential",
"username": d.username.clone().unwrap_or_default(),
"password": d.password.clone().unwrap_or_default(),
}),
Err(v) => v,
}
}
"totp" => {
let Some(id) = item_id() else { return err("bad_request") };
self.ui.activity();
match self.matching_item(&origin, id) {
Ok(d) => match d.totp.as_deref().map(|t| kv_ffi::totp_code(t.to_string(), now_secs())) {
Some(Ok(c)) => json!({ "type": "totp", "code": c.code, "secondsRemaining": c.seconds_remaining }),
_ => err("bad_request"),
},
Err(v) => v,
}
}
"save" => {
let username = body.get("username").and_then(Value::as_str).unwrap_or("");
let Some(password) = body.get("password").and_then(Value::as_str).filter(|p| !p.is_empty()) else {
return err("bad_request");
};
if username.chars().count() > 512 || password.chars().count() > 4096 {
return err("bad_request");
}
self.queue_save(origin, username.to_string(), Zeroizing::new(password.to_string()))
}
_ => err("unknown_type"),
}
}
/// The item, if (and only if) it matches the requesting origin. The id alone is never enough.
fn matching_item(&self, origin: &str, id: &str) -> Result<ItemDto, Value> {
let candidates = self.core.items_for_url(origin.to_string()).map_err(|e| core_err(&e))?;
if !candidates.iter().any(|i| i.id == id) {
return Err(err("origin_mismatch"));
}
self.core.get_item(id.to_string()).map_err(|e| core_err(&e))
}
/// The existing login on this origin with this username, if the vault is unlocked.
fn existing(&self, origin: &str, username: &str) -> Option<ItemDto> {
let items = self.core.items_for_url(origin.to_string()).ok()?;
let s = items.into_iter().find(|i| i.username.as_deref().unwrap_or("") == username)?;
self.core.get_item(s.id).ok()
}
fn queue_save(&self, origin: String, username: String, password: Zeroizing<String>) -> Value {
if let Some(e) = self.existing(&origin, &username) {
if e.password.as_deref() == Some(password.as_str()) {
return json!({ "type": "saved" }); // already up to date: no prompt
}
}
let save = PendingSave { id: uuid::Uuid::new_v4().to_string(), origin, username, password, at: Instant::now() };
let view = self.save_view(&save);
{
let mut q = lock(&self.saves);
q.retain(|s| s.at.elapsed() < SAVE_TTL && !(s.origin == save.origin && s.username == save.username));
if q.len() >= MAX_PENDING_SAVES {
q.remove(0);
}
q.push(save);
}
self.ui.save_requested(&view);
json!({ "type": "save_prompted" })
}
fn save_view(&self, s: &PendingSave) -> SaveView {
SaveView {
id: s.id.clone(),
origin: s.origin.clone(),
username: s.username.clone(),
update: self.existing(&s.origin, &s.username).is_some(),
}
}
pub fn pending_saves(&self) -> Vec<SaveView> {
let mut q = lock(&self.saves);
q.retain(|s| s.at.elapsed() < SAVE_TTL);
let pending: Vec<_> = q.iter().map(|s| (s.id.clone(), s.origin.clone(), s.username.clone())).collect();
drop(q);
pending
.into_iter()
.map(|(id, origin, username)| SaveView {
update: self.existing(&origin, &username).is_some(),
id,
origin,
username,
})
.collect()
}
/// The user answered a save prompt in the app. Returns the saved item id.
pub fn resolve_save(&self, id: &str, accept: bool) -> Result<Option<String>, String> {
let s = {
let mut q = lock(&self.saves);
let Some(pos) = q.iter().position(|s| s.id == id) else { return Err("This save request expired.".into()) };
if accept && self.core.is_locked() {
return Err("Unlock Kestrove to save.".into());
}
q.remove(pos)
};
if !accept {
return Ok(None);
}
let item = match self.existing(&s.origin, &s.username) {
Some(mut e) => {
e.password = Some(s.password.to_string());
e
}
None => ItemDto {
id: String::new(),
kind: ItemKindDto::Login,
title: url::Url::parse(&s.origin)
.ok()
.and_then(|u| u.host_str().map(|h| h.trim_start_matches("www.").to_string()))
.unwrap_or_else(|| s.origin.clone()),
username: (!s.username.is_empty()).then(|| s.username.clone()),
password: Some(s.password.to_string()),
urls: vec![s.origin.clone()],
totp: None,
notes: None,
favorite: false,
tags: vec![],
modified_at: 0,
},
};
self.core.save_item(item).map(Some).map_err(|e| e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channel::{make_confirm, Session as Sess};
struct AutoUi;
impl Ui for AutoUi {
fn pairing_requested(&self, _: &PairingView) {}
fn save_requested(&self, _: &SaveView) {}
}
fn broker_with_item() -> (Arc<Broker>, String) {
let core = VaultCore::new_in_memory();
core.create_account("correct horse battery".into()).unwrap();
let id = core
.save_item(ItemDto {
id: String::new(),
kind: ItemKindDto::Login,
title: "Example".into(),
username: Some("alice".into()),
password: Some("s3cret".into()),
urls: vec!["https://example.com".into()],
totp: Some("JBSWY3DPEHPK3PXP".into()),
notes: None,
favorite: false,
tags: vec![],
modified_at: 0,
})
.unwrap();
(Broker::new(core, Arc::new(AutoUi), PairingStore::in_memory()), id)
}
/// Plays the extension: pairs, has the "user" approve in the app, opens a session.
fn pair_and_open(b: &Broker, conn: &mut Conn) -> Sess {
let ext = Ephemeral::generate();
let r = b.handle_frame(conn, &json!({ "t": "pair_init", "pub": B64.encode(ext.public), "name": "Test" }));
assert_eq!(r["t"], "pair_started");
let id = r["pairing_id"].as_str().unwrap().to_string();
let app_pub: [u8; 32] = B64.decode(r["pub"].as_str().unwrap()).unwrap().try_into().unwrap();
let shared_ext = ext.pair_as_ext(&app_pub).unwrap();
assert_eq!(shared_ext.words.to_vec(), b.pending_pairing().unwrap().words);
let proof = B64.encode(make_confirm(&shared_ext.key, &id));
let r = b.handle_frame(conn, &json!({ "t": "pair_confirm", "pairing_id": id, "proof": proof }));
assert_eq!(r["t"], "pair_waiting");
b.resolve_pairing(&id, true).unwrap();
let r = b.handle_frame(conn, &json!({ "t": "pair_confirm", "pairing_id": id, "proof": proof }));
assert_eq!(r["t"], "pair_done");
let ne = random::<16>();
let r = b.handle_frame(conn, &json!({ "t": "open", "pairing_id": id, "nonce": B64.encode(ne) }));
assert_eq!(r["t"], "opened");
let na: [u8; 16] = B64.decode(r["nonce"].as_str().unwrap()).unwrap().try_into().unwrap();
Sess::ext(&shared_ext.key, &ne, &na)
}
fn call(b: &Broker, conn: &mut Conn, s: &mut Sess, body: Value) -> Value {
let (n, c) = s.seal(&serde_json::to_vec(&json!({ "rid": 7, "body": body })).unwrap());
let r = b.handle_frame(conn, &json!({ "t": "msg", "n": n, "c": B64.encode(c) }));
assert_eq!(r["t"], "msg", "{r}");
let pt = s.open(r["n"].as_u64().unwrap(), &B64.decode(r["c"].as_str().unwrap()).unwrap()).unwrap();
let v: Value = serde_json::from_slice(&pt).unwrap();
assert_eq!(v["rid"], 7);
v["body"].clone()
}
#[test]
fn pair_query_fill_and_origin_checks() {
let (b, id) = broker_with_item();
let mut conn = Conn { caller: String::new(), session: None };
let mut s = pair_and_open(&b, &mut conn);
let r = call(&b, &mut conn, &mut s, json!({ "type": "query", "origin": "https://example.com" }));
assert_eq!(r["items"][0]["username"], "alice");
assert!(r["items"][0].get("password").is_none());
let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "https://example.com", "itemId": id }));
assert_eq!(r, json!({ "type": "credential", "username": "alice", "password": "s3cret" }));
let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "https://evil.test", "itemId": id }));
assert_eq!(r["code"], "origin_mismatch");
let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "javascript:alert(1)", "itemId": id }));
assert_eq!(r["code"], "bad_request");
let r = call(&b, &mut conn, &mut s, json!({ "type": "totp", "origin": "https://example.com", "itemId": id }));
assert_eq!(r["type"], "totp");
assert_eq!(r["code"].as_str().unwrap().len(), 6);
b.core().lock();
let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "https://example.com", "itemId": id }));
assert_eq!(r["code"], "locked");
}
#[test]
fn replayed_frame_kills_the_session() {
let (b, _) = broker_with_item();
let mut conn = Conn { caller: String::new(), session: None };
let mut s = pair_and_open(&b, &mut conn);
let (n, c) = s.seal(br#"{"rid":1,"body":{"type":"hello","version":"1"}}"#);
let frame = json!({ "t": "msg", "n": n, "c": B64.encode(c) });
assert_eq!(b.handle_frame(&mut conn, &frame)["t"], "msg");
assert_eq!(b.handle_frame(&mut conn, &frame)["code"], "bad_session");
assert_eq!(b.handle_frame(&mut conn, &frame)["code"], "bad_session");
}
#[test]
fn unpaired_and_revoked_extensions_are_refused() {
let (b, _) = broker_with_item();
let mut conn = Conn { caller: String::new(), session: None };
let r = b.handle_frame(&mut conn, &json!({ "t": "open", "pairing_id": "nope", "nonce": B64.encode([0u8; 16]) }));
assert_eq!(r["code"], "not_paired");
let r = b.handle_frame(&mut conn, &json!({ "t": "msg", "n": 0, "c": "AAAA" }));
assert_eq!(r["code"], "bad_session");
let _ = pair_and_open(&b, &mut conn);
let id = b.pairings()[0].id.clone();
b.revoke_pairing(&id).unwrap();
let r = b.handle_frame(&mut conn, &json!({ "t": "open", "pairing_id": id, "nonce": B64.encode([0u8; 16]) }));
assert_eq!(r["code"], "not_paired");
}
#[test]
fn pairing_needs_unlock_and_rejects_bad_proof() {
let (b, _) = broker_with_item();
let mut conn = Conn { caller: String::new(), session: None };
let ext = Ephemeral::generate();
let r = b.handle_frame(&mut conn, &json!({ "t": "pair_init", "pub": B64.encode(ext.public) }));
let id = r["pairing_id"].as_str().unwrap().to_string();
let r = b.handle_frame(&mut conn, &json!({ "t": "pair_confirm", "pairing_id": id, "proof": B64.encode([0u8; 40]) }));
assert_eq!(r["code"], "not_paired");
b.core().lock();
assert!(b.resolve_pairing(&id, true).is_err());
assert!(b.pending_pairing().is_some(), "still pending after a locked attempt");
assert!(b.pairings().is_empty());
}
#[test]
fn save_prompts_then_creates_or_updates() {
let (b, id) = broker_with_item();
let r = b.handle_request(&json!({ "type": "save", "origin": "https://example.com", "username": "alice", "password": "s3cret" }));
assert_eq!(r["type"], "saved"); // identical: nothing to ask
let r = b.handle_request(&json!({ "type": "save", "origin": "https://example.com/login", "username": "alice", "password": "new" }));
assert_eq!(r["type"], "save_prompted");
let views = b.pending_saves();
assert_eq!((views.len(), views[0].update, views[0].origin.as_str()), (1, true, "https://example.com"));
assert_eq!(b.resolve_save(&views[0].id, true).unwrap().as_deref(), Some(id.as_str()));
assert_eq!(b.core().get_item(id).unwrap().password.as_deref(), Some("new"));
b.handle_request(&json!({ "type": "save", "origin": "https://new.example.org", "username": "bob", "password": "pw" }));
let v = b.pending_saves().pop().unwrap();
assert!(!v.update);
let new_id = b.resolve_save(&v.id, true).unwrap().unwrap();
let d = b.core().get_item(new_id).unwrap();
assert_eq!((d.title.as_str(), d.urls[0].as_str()), ("new.example.org", "https://new.example.org"));
assert!(b.pending_saves().is_empty());
}
#[test]
fn normalizes_origins() {
assert_eq!(normalize_origin("https://Example.com:443/x?y").as_deref(), Some("https://example.com"));
assert_eq!(normalize_origin("http://localhost:8080").as_deref(), Some("http://localhost:8080"));
assert_eq!(normalize_origin("https://u:[email protected]"), None);
assert_eq!(normalize_origin("file:///etc/passwd"), None);
assert_eq!(normalize_origin("chrome-extension://abc/"), None);
}
#[test]
fn pairing_store_persists_with_private_mode() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pairings.json");
let s = PairingStore::open(path.clone()).unwrap();
s.add(StoredPairing {
id: "a".into(),
name: "n".into(),
caller: String::new(),
words: vec![],
created_at: 1,
key: B64.encode([1u8; 32]),
})
.unwrap();
assert_eq!(PairingStore::open(path.clone()).unwrap().list().len(), 1);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
}
assert!(s.remove("a").unwrap());
assert!(PairingStore::open(path).unwrap().list().is_empty());
}
}
+251
View File
@@ -0,0 +1,251 @@
//! Pairing channel between the browser extension and the app (docs/05 "Pairing", threat A8).
//!
//! The same construction is implemented with WebCrypto in kestrove-browser/src/channel.ts;
//! the test vectors below are asserted on both sides.
//!
//! 1. **Pair.** Both sides generate an X25519 key. With `ss = X25519(ext, app)` and
//! `salt = SHA-256("kestrove/v1/pair" || ext_pub || app_pub)`:
//! `pairing_key = HKDF-SHA256(ss, salt, "kestrove/v1/pair/key", 32)` and
//! `fingerprint = HKDF-SHA256(ss, salt, "kestrove/v1/pair/fingerprint", 6)`, shown as 6 words
//! in both UIs. The user confirms they match in the app (which is what makes the pairing
//! trusted) and in the extension. A man in the middle would have to win a 2^-48 collision.
//! 2. **Session.** Per connection, each side sends a random 16-byte nonce and
//! `session_key = HKDF-SHA256(pairing_key, nonce_ext || nonce_app, "kestrove/v1/session", 32)`.
//! Fresh nonces on both sides mean a recorded session can't be replayed in a new one.
//! 3. **Messages.** AES-256-GCM, nonce `dir || 0x000000 || counter_be64`, AAD `"kestrove/v1/msg"`.
//! `dir` is 1 for extension → app and 2 for app → extension (no reflection). Each side expects
//! the exact next counter, so replays, drops and reordering within a session all fail.
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use hkdf::Hkdf;
use sha2::{Digest, Sha256};
use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::Zeroizing;
use crate::words::WORDS;
pub const DIR_EXT_TO_APP: u8 = 1;
pub const DIR_APP_TO_EXT: u8 = 2;
const DIR_CONFIRM: u8 = 3;
const MSG_AAD: &[u8] = b"kestrove/v1/msg";
const CONFIRM_AAD: &[u8] = b"kestrove/v1/pair/confirm";
#[derive(Debug, PartialEq, Eq)]
pub struct CryptoFailure;
pub fn random<const N: usize>() -> [u8; N] {
let mut b = [0u8; N];
getrandom::getrandom(&mut b).expect("OS RNG unavailable");
b
}
/// The app's half of a pairing handshake.
pub struct Ephemeral {
secret: StaticSecret,
pub public: [u8; 32],
}
impl Ephemeral {
pub fn generate() -> Self {
Self::from_secret(random())
}
pub fn from_secret(bytes: [u8; 32]) -> Self {
let secret = StaticSecret::from(bytes);
let public = PublicKey::from(&secret).to_bytes();
Self { secret, public }
}
/// The app's derivation of the pairing key and words. `None` for a low-order peer key.
pub fn pair(&self, ext_pub: &[u8; 32]) -> Option<Pairing> {
let ss = self.secret.diffie_hellman(&PublicKey::from(*ext_pub));
ss.was_contributory().then(|| Pairing::derive(ss.as_bytes(), ext_pub, &self.public))
}
/// The extension's derivation (used by tests that play the extension).
pub fn pair_as_ext(&self, app_pub: &[u8; 32]) -> Option<Pairing> {
let ss = self.secret.diffie_hellman(&PublicKey::from(*app_pub));
ss.was_contributory().then(|| Pairing::derive(ss.as_bytes(), &self.public, app_pub))
}
}
pub struct Pairing {
pub key: Zeroizing<[u8; 32]>,
pub words: [&'static str; 6],
}
impl Pairing {
pub fn derive(shared: &[u8; 32], ext_pub: &[u8; 32], app_pub: &[u8; 32]) -> Self {
let salt = Sha256::new()
.chain_update(b"kestrove/v1/pair")
.chain_update(ext_pub)
.chain_update(app_pub)
.finalize();
let hk = Hkdf::<Sha256>::new(Some(&salt), shared);
let mut key = Zeroizing::new([0u8; 32]);
hk.expand(b"kestrove/v1/pair/key", key.as_mut()).expect("32 bytes is a valid HKDF length");
let mut fp = [0u8; 6];
hk.expand(b"kestrove/v1/pair/fingerprint", &mut fp).expect("6 bytes is a valid HKDF length");
Self { key, words: fp.map(|b| WORDS[b as usize]) }
}
}
fn cipher(key: &[u8; 32]) -> Aes256Gcm {
Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key))
}
fn nonce(dir: u8, n: u64) -> [u8; 12] {
let mut out = [0u8; 12];
out[0] = dir;
out[4..].copy_from_slice(&n.to_be_bytes());
out
}
/// Checks the extension's proof that it derived the same pairing key: AES-GCM of the pairing id.
pub fn verify_confirm(pairing_key: &[u8; 32], pairing_id: &str, proof: &[u8]) -> bool {
cipher(pairing_key)
.decrypt(Nonce::from_slice(&nonce(DIR_CONFIRM, 0)), Payload { msg: proof, aad: CONFIRM_AAD })
.is_ok_and(|pt| pt == pairing_id.as_bytes())
}
/// The extension's proof (tests play the extension).
pub fn make_confirm(pairing_key: &[u8; 32], pairing_id: &str) -> Vec<u8> {
cipher(pairing_key)
.encrypt(Nonce::from_slice(&nonce(DIR_CONFIRM, 0)), Payload { msg: pairing_id.as_bytes(), aad: CONFIRM_AAD })
.expect("AES-GCM encryption cannot fail for small inputs")
}
/// One encrypted session over one connection.
pub struct Session {
cipher: Aes256Gcm,
send_dir: u8,
recv_dir: u8,
send_n: u64,
recv_n: u64,
}
impl Session {
pub fn key(pairing_key: &[u8; 32], nonce_ext: &[u8; 16], nonce_app: &[u8; 16]) -> Zeroizing<[u8; 32]> {
let mut salt = [0u8; 32];
salt[..16].copy_from_slice(nonce_ext);
salt[16..].copy_from_slice(nonce_app);
let mut key = Zeroizing::new([0u8; 32]);
Hkdf::<Sha256>::new(Some(&salt), pairing_key)
.expand(b"kestrove/v1/session", key.as_mut())
.expect("32 bytes is a valid HKDF length");
key
}
/// The app's end: receives extension → app, sends app → extension.
pub fn app(pairing_key: &[u8; 32], nonce_ext: &[u8; 16], nonce_app: &[u8; 16]) -> Self {
Self::new(&Self::key(pairing_key, nonce_ext, nonce_app), DIR_APP_TO_EXT, DIR_EXT_TO_APP)
}
/// The extension's end (tests stand in for the extension).
pub fn ext(pairing_key: &[u8; 32], nonce_ext: &[u8; 16], nonce_app: &[u8; 16]) -> Self {
Self::new(&Self::key(pairing_key, nonce_ext, nonce_app), DIR_EXT_TO_APP, DIR_APP_TO_EXT)
}
fn new(key: &[u8; 32], send_dir: u8, recv_dir: u8) -> Self {
Self { cipher: cipher(key), send_dir, recv_dir, send_n: 0, recv_n: 0 }
}
pub fn seal(&mut self, plaintext: &[u8]) -> (u64, Vec<u8>) {
let n = self.send_n;
self.send_n += 1;
let ct = self
.cipher
.encrypt(Nonce::from_slice(&nonce(self.send_dir, n)), Payload { msg: plaintext, aad: MSG_AAD })
.expect("AES-GCM encryption cannot fail for small inputs");
(n, ct)
}
/// Opens the next message. Anything but the exact next counter is rejected (replay, reorder).
pub fn open(&mut self, n: u64, ciphertext: &[u8]) -> Result<Zeroizing<Vec<u8>>, CryptoFailure> {
if n != self.recv_n {
return Err(CryptoFailure);
}
let pt = self
.cipher
.decrypt(Nonce::from_slice(&nonce(self.recv_dir, n)), Payload { msg: ciphertext, aad: MSG_AAD })
.map_err(|_| CryptoFailure)?;
self.recv_n += 1;
Ok(Zeroizing::new(pt))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// Fixed vectors, also asserted in kestrove-browser/test/channel.test.ts.
#[test]
fn vectors() {
let ext = Ephemeral::from_secret([1u8; 32]);
let app = Ephemeral::from_secret([2u8; 32]);
assert_eq!(hex(&ext.public), "a4e09292b651c278b9772c569f5fa9bb13d906b46ab68c9df9dc2b4409f8a209");
assert_eq!(hex(&app.public), "ce8d3ad1ccb633ec7b70c17814a5c76ecd029685050d344745ba05870e587d59");
let p = app.pair(&ext.public).unwrap();
let q = ext.pair_as_ext(&app.public).unwrap();
assert_eq!((p.words, p.key.as_ref()), (q.words, q.key.as_ref()));
let fp = p.words.join(" ");
let key = hex(p.key.as_ref());
assert_eq!(fp, VECTOR_WORDS);
assert_eq!(key, VECTOR_KEY);
let mut s = Session::ext(&p.key, &[3u8; 16], &[4u8; 16]);
let (n, ct) = s.seal(br#"{"rid":0}"#);
assert_eq!(n, 0);
assert_eq!(hex(&ct), VECTOR_CT0);
assert_eq!(hex(&make_confirm(&p.key, "pid")), VECTOR_CONFIRM);
}
const VECTOR_WORDS: &str = "cargo bucket blade cricket bamboo radar";
const VECTOR_KEY: &str = "7dd958e1408eb2e772ed71c44763a05c7df77f6c7a6620d2e7e9049ec4573ae7";
const VECTOR_CT0: &str = "f4204547c03bc11c2c0f1969ebcfd68985571ff7e10f7a6bd6";
const VECTOR_CONFIRM: &str = "42eea959ea11c1e5aadcbcd3118c2f5878bab1";
#[test]
fn session_rejects_replay_reorder_and_reflection() {
let key = [9u8; 32];
let (ne, na) = ([1u8; 16], [2u8; 16]);
let mut ext = Session::ext(&key, &ne, &na);
let mut app = Session::app(&key, &ne, &na);
let (n0, c0) = ext.seal(b"one");
let (n1, c1) = ext.seal(b"two");
assert_eq!(app.open(n1, &c1), Err(CryptoFailure)); // out of order
assert_eq!(app.open(n0, &c0).unwrap().as_slice(), b"one");
assert_eq!(app.open(n0, &c0), Err(CryptoFailure)); // replay
assert_eq!(app.open(n1, &c1).unwrap().as_slice(), b"two");
// An app → ext message can't be reflected back to the app.
let (rn, rc) = app.seal(b"reply");
let mut app2 = Session::app(&key, &ne, &na);
assert_eq!(app2.open(rn, &rc), Err(CryptoFailure));
assert_eq!(ext.open(rn, &rc).unwrap().as_slice(), b"reply");
// A different session (new app nonce) can't open old traffic.
let mut other = Session::app(&key, &ne, &[5u8; 16]);
assert_eq!(other.open(n0, &c0), Err(CryptoFailure));
}
#[test]
fn confirm_proof_binds_key_and_id() {
let key = [7u8; 32];
let proof = make_confirm(&key, "abc");
assert!(verify_confirm(&key, "abc", &proof));
assert!(!verify_confirm(&key, "abd", &proof));
assert!(!verify_confirm(&[8u8; 32], "abc", &proof));
}
#[test]
fn rejects_low_order_point() {
assert!(Ephemeral::generate().pair(&[0u8; 32]).is_none());
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Length-prefixed JSON frames, the native-messaging wire format (a u32 length in native byte
//! order, then UTF-8 JSON). The same framing is used on the local IPC channel between
//! `kestrove-nmh` and the app, so the host can relay frames without parsing them.
use std::io::{self, Read, Write};
use serde_json::Value;
/// Chrome caps host → browser messages at 1 MiB; browser → host at 4 GiB, but we never need more than this.
pub const MAX_FRAME: usize = 1024 * 1024;
/// Reads one raw frame. `Ok(None)` on a clean EOF before the length prefix.
pub fn read_frame(r: &mut impl Read) -> io::Result<Option<Vec<u8>>> {
let mut len = [0u8; 4];
match r.read_exact(&mut len) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
}
let len = u32::from_ne_bytes(len) as usize;
if len > MAX_FRAME {
return Err(io::Error::new(io::ErrorKind::InvalidData, "frame too large"));
}
let mut buf = vec![0u8; len];
r.read_exact(&mut buf)?;
Ok(Some(buf))
}
pub fn write_frame(w: &mut impl Write, bytes: &[u8]) -> io::Result<()> {
if bytes.len() > MAX_FRAME {
return Err(io::Error::new(io::ErrorKind::InvalidData, "frame too large"));
}
w.write_all(&(bytes.len() as u32).to_ne_bytes())?;
w.write_all(bytes)?;
w.flush()
}
pub fn read_json(r: &mut impl Read) -> io::Result<Option<Value>> {
match read_frame(r)? {
Some(b) => serde_json::from_slice(&b).map(Some).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)),
None => Ok(None),
}
}
pub fn write_json(w: &mut impl Write, v: &Value) -> io::Result<()> {
write_frame(w, &serde_json::to_vec(v)?)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn round_trip_and_eof() {
let mut buf = Vec::new();
write_json(&mut buf, &json!({"t": "ping"})).unwrap();
let mut r = buf.as_slice();
assert_eq!(read_json(&mut r).unwrap(), Some(json!({"t": "ping"})));
assert_eq!(read_json(&mut r).unwrap(), None);
}
#[test]
fn rejects_oversized_length() {
let bytes = ((MAX_FRAME + 1) as u32).to_ne_bytes();
assert!(read_frame(&mut bytes.as_slice()).is_err());
}
}
+450
View File
@@ -0,0 +1,450 @@
//! Local IPC between `kestrove-nmh` (started by the browser) and the running app.
//!
//! * Unix: a socket in a 0700 per-user directory, socket mode 0600, and a peer-credential check
//! on both ends (`SO_PEERCRED` on Linux, `getpeereid` on macOS/BSD). Linux uses
//! `$XDG_RUNTIME_DIR/kestrove/nmh.sock`; macOS uses the per-user temp dir from
//! `confstr(_CS_DARWIN_USER_TEMP_DIR)`.
//! * Windows: the named pipe `\\.\pipe\kestrove-nmh-<user>`, created first-instance-only (no
//! squatting), local clients only, with a DACL for the current user, plus a client token check.
//!
//! The channel carries only already-encrypted pairing frames (see `channel`), so this layer keeps
//! other users and remote machines out; the pairing key keeps other same-user processes out.
//! Traffic is strictly request → response: no concurrent read and write on one connection
//! (synchronous Windows pipe handles serialize I/O, so that would deadlock).
use std::io::{self, Read, Write};
use std::path::PathBuf;
/// Overrides the IPC directory (Unix) or pipe name suffix (Windows). Tests use it to run an
/// isolated app; the browser passes its environment through to the host, so a browser started
/// with it set reaches that app.
pub const ENV_IPC_DIR: &str = "KESTROVE_IPC_DIR";
pub use imp::{connect, Conn, Listener};
/// Where the socket lives (Unix) or a display string for the pipe (Windows).
pub fn endpoint() -> io::Result<PathBuf> {
imp::endpoint()
}
impl Read for Conn {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.0.read(buf)
}
}
impl Write for Conn {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.flush()
}
}
#[cfg(unix)]
mod imp {
use std::fs;
use std::io;
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
use std::os::unix::io::AsRawFd;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::time::Duration;
use super::ENV_IPC_DIR;
pub struct Conn(pub(super) UnixStream);
pub struct Listener {
inner: UnixListener,
path: PathBuf,
}
fn uid() -> u32 {
// SAFETY: geteuid has no preconditions and cannot fail.
unsafe { libc::geteuid() }
}
fn base_dir() -> io::Result<PathBuf> {
if let Some(d) = std::env::var_os(ENV_IPC_DIR).filter(|d| !d.is_empty()) {
return Ok(PathBuf::from(d));
}
platform_dir()
}
#[cfg(target_os = "macos")]
fn platform_dir() -> io::Result<PathBuf> {
use std::ffi::{CStr, OsStr};
use std::os::unix::ffi::OsStrExt;
let mut buf = vec![0u8; 1024];
// SAFETY: buf is valid for buf.len() bytes; confstr NUL-terminates within that length.
let n = unsafe { libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, buf.as_mut_ptr().cast(), buf.len()) };
if n == 0 || n > buf.len() {
return Err(io::Error::last_os_error());
}
let s = CStr::from_bytes_until_nul(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(PathBuf::from(OsStr::from_bytes(s.to_bytes())).join("kestrove"))
}
#[cfg(not(target_os = "macos"))]
fn platform_dir() -> io::Result<PathBuf> {
match std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from) {
Some(d) if d.is_absolute() => Ok(d.join("kestrove")),
// No runtime dir (e.g. no systemd session): a per-uid dir in /tmp, ownership checked in secure_dir.
_ => Ok(PathBuf::from(format!("/tmp/kestrove-{}", uid()))),
}
}
pub fn endpoint() -> io::Result<PathBuf> {
Ok(base_dir()?.join("nmh.sock"))
}
/// Creates the directory 0700, or checks that an existing one is ours and not group/world accessible.
fn secure_dir(dir: &std::path::Path) -> io::Result<()> {
match fs::DirBuilder::new().recursive(true).mode(0o700).create(dir) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
let meta = fs::symlink_metadata(dir)?;
if !meta.is_dir() || meta.uid() != uid() {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "IPC directory is not owned by this user"));
}
if meta.mode() & 0o077 != 0 {
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
#[cfg(any(target_os = "linux", target_os = "android"))]
pub(super) fn peer_uid(s: &UnixStream) -> io::Result<u32> {
let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 };
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
// SAFETY: cred and len are valid out-pointers of the sizes passed.
let rc = unsafe {
libc::getsockopt(
s.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PEERCRED,
(&mut cred as *mut libc::ucred).cast(),
&mut len,
)
};
if rc != 0 {
return Err(io::Error::last_os_error());
}
Ok(cred.uid)
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub(super) fn peer_uid(s: &UnixStream) -> io::Result<u32> {
let (mut uid, mut gid) = (0, 0);
// SAFETY: uid and gid are valid out-pointers.
if unsafe { libc::getpeereid(s.as_raw_fd(), &mut uid, &mut gid) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(uid)
}
fn check_peer(s: &UnixStream) -> io::Result<()> {
if peer_uid(s)? != uid() {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "IPC peer is a different user"));
}
Ok(())
}
impl Listener {
/// Binds the socket. Fails with `AddrInUse` if another app instance is already serving it.
pub fn bind() -> io::Result<Self> {
let path = endpoint()?;
secure_dir(path.parent().expect("socket path has a parent"))?;
if path.exists() {
if UnixStream::connect(&path).is_ok() {
return Err(io::Error::new(io::ErrorKind::AddrInUse, "Kestrove is already running"));
}
fs::remove_file(&path)?; // stale socket from a crashed instance
}
let inner = UnixListener::bind(&path)?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
Ok(Self { inner, path })
}
/// Blocks for the next same-user client. Other users' connections are dropped and skipped.
pub fn accept(&self) -> io::Result<Conn> {
loop {
let (s, _) = self.inner.accept()?;
if check_peer(&s).is_ok() {
// A stuck peer must not pin a thread forever.
s.set_read_timeout(Some(Duration::from_secs(600)))?;
return Ok(Conn(s));
}
}
}
}
impl Drop for Listener {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
pub fn connect() -> io::Result<Conn> {
let s = UnixStream::connect(endpoint()?)?;
check_peer(&s)?; // don't talk to a socket another user planted
Ok(Conn(s))
}
}
#[cfg(windows)]
mod imp {
use std::fs::File;
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::path::PathBuf;
use std::ptr;
use windows_sys::Win32::Foundation::{
CloseHandle, GetLastError, LocalFree, ERROR_PIPE_CONNECTED, GENERIC_READ, GENERIC_WRITE, HANDLE,
INVALID_HANDLE_VALUE,
};
use windows_sys::Win32::Security::Authorization::{
ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
use windows_sys::Win32::Security::{
EqualSid, GetTokenInformation, TokenUser, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER,
};
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, FILE_FLAG_FIRST_PIPE_INSTANCE, OPEN_EXISTING, PIPE_ACCESS_DUPLEX,
};
use windows_sys::Win32::System::Pipes::{
ConnectNamedPipe, CreateNamedPipeW, GetNamedPipeClientProcessId, GetNamedPipeServerProcessId,
PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
};
use windows_sys::Win32::System::Threading::{
GetCurrentProcess, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION,
};
use super::ENV_IPC_DIR;
pub struct Conn(pub(super) File);
pub struct Listener {
name: Vec<u16>,
sddl: Vec<u16>,
first: std::sync::atomic::AtomicBool,
}
fn wide(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect()
}
fn pipe_name() -> String {
let suffix = std::env::var(ENV_IPC_DIR)
.ok()
.filter(|s| !s.is_empty())
.or_else(|| std::env::var("USERNAME").ok())
.unwrap_or_else(|| "user".into());
let clean: String = suffix.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
format!(r"\\.\pipe\kestrove-nmh-{clean}")
}
pub fn endpoint() -> io::Result<PathBuf> {
Ok(PathBuf::from(pipe_name()))
}
/// The TOKEN_USER buffer of a process (the SID pointer points into it).
fn token_user(process: HANDLE) -> io::Result<Vec<u8>> {
let mut token: HANDLE = ptr::null_mut();
// SAFETY: valid process handle and out-pointer.
if unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: we own the token handle from here on.
let token = unsafe { OwnedHandle::from_raw_handle(token) };
let mut len = 0u32;
// SAFETY: a size query with a null buffer is the documented pattern.
unsafe { GetTokenInformation(token.as_raw_handle(), TokenUser, ptr::null_mut(), 0, &mut len) };
let mut buf = vec![0u8; len as usize];
// SAFETY: buf has len bytes.
if unsafe { GetTokenInformation(token.as_raw_handle(), TokenUser, buf.as_mut_ptr().cast(), len, &mut len) } == 0
{
return Err(io::Error::last_os_error());
}
Ok(buf)
}
fn sid_of(buf: &[u8]) -> *mut core::ffi::c_void {
// SAFETY: buf came from GetTokenInformation(TokenUser) and starts with a TOKEN_USER.
unsafe { (*(buf.as_ptr() as *const TOKEN_USER)).User.Sid }
}
fn same_user_as_process(pid: u32) -> io::Result<()> {
// SAFETY: plain Win32 calls; every handle is closed before returning.
unsafe {
let ours = token_user(GetCurrentProcess())?;
let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if h.is_null() {
return Err(io::Error::last_os_error());
}
let theirs = token_user(h);
CloseHandle(h);
let theirs = theirs?;
if EqualSid(sid_of(&ours), sid_of(&theirs)) == 0 {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "IPC peer is a different user"));
}
}
Ok(())
}
/// SDDL granting full access to the current user only (no Everyone read like the default DACL).
fn user_only_sddl() -> io::Result<Vec<u16>> {
// SAFETY: ConvertSidToStringSidW allocates with LocalAlloc; we free it after copying.
unsafe {
let ours = token_user(GetCurrentProcess())?;
let mut s: *mut u16 = ptr::null_mut();
if ConvertSidToStringSidW(sid_of(&ours), &mut s) == 0 {
return Err(io::Error::last_os_error());
}
let mut n = 0;
while *s.add(n) != 0 {
n += 1;
}
let sid = String::from_utf16_lossy(std::slice::from_raw_parts(s, n));
LocalFree(s.cast());
Ok(wide(&format!("D:P(A;;GA;;;{sid})")))
}
}
impl Listener {
pub fn bind() -> io::Result<Self> {
let me = Self {
name: wide(&pipe_name()),
sddl: user_only_sddl()?,
first: std::sync::atomic::AtomicBool::new(true),
};
Ok(me)
}
fn create_instance(&self) -> io::Result<HANDLE> {
let first = self.first.swap(false, std::sync::atomic::Ordering::SeqCst);
let mut sd: PSECURITY_DESCRIPTOR = ptr::null_mut();
// SAFETY: sddl is NUL-terminated; sd is freed with LocalFree below.
unsafe {
if ConvertStringSecurityDescriptorToSecurityDescriptorW(
self.sddl.as_ptr(),
SDDL_REVISION_1,
&mut sd,
ptr::null_mut(),
) == 0
{
return Err(io::Error::last_os_error());
}
let sa = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: sd,
bInheritHandle: 0,
};
let h = CreateNamedPipeW(
self.name.as_ptr(),
PIPE_ACCESS_DUPLEX | if first { FILE_FLAG_FIRST_PIPE_INSTANCE } else { 0 },
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
PIPE_UNLIMITED_INSTANCES,
64 * 1024,
64 * 1024,
0,
&sa,
);
LocalFree(sd);
if h == INVALID_HANDLE_VALUE {
let e = io::Error::last_os_error();
return Err(if first { io::Error::new(io::ErrorKind::AddrInUse, e) } else { e });
}
Ok(h)
}
}
pub fn accept(&self) -> io::Result<Conn> {
loop {
let h = self.create_instance()?;
// SAFETY: h is a fresh pipe handle owned by `file` from here on.
let file = unsafe { File::from_raw_handle(h) };
// SAFETY: blocking connect on our own pipe handle.
let ok = unsafe { ConnectNamedPipe(h, ptr::null_mut()) } != 0
|| unsafe { GetLastError() } == ERROR_PIPE_CONNECTED;
if !ok {
continue;
}
let mut pid = 0u32;
// SAFETY: valid handle and out-pointer.
if unsafe { GetNamedPipeClientProcessId(h, &mut pid) } != 0 && same_user_as_process(pid).is_ok() {
return Ok(Conn(file));
}
}
}
}
pub fn connect() -> io::Result<Conn> {
let name = wide(&pipe_name());
// SAFETY: name is NUL-terminated; the handle is owned by File on success.
let h = unsafe {
CreateFileW(
name.as_ptr(),
GENERIC_READ | GENERIC_WRITE,
0,
ptr::null(),
OPEN_EXISTING,
0,
ptr::null_mut(),
)
};
if h == INVALID_HANDLE_VALUE {
return Err(io::Error::last_os_error());
}
// SAFETY: h is a valid, owned handle.
let file = unsafe { File::from_raw_handle(h) };
let mut pid = 0u32;
// SAFETY: valid handle and out-pointer.
if unsafe { GetNamedPipeServerProcessId(h, &mut pid) } == 0 {
return Err(io::Error::last_os_error());
}
same_user_as_process(pid)?;
Ok(Conn(file))
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::framing::{read_json, write_json};
use serde_json::json;
use std::os::unix::fs::PermissionsExt;
#[test]
fn socket_is_private_and_round_trips() {
let dir = tempfile::tempdir().unwrap();
let ipc_dir = dir.path().join("ipc");
// SAFETY: tests in this module are the only ones touching this variable.
unsafe { std::env::set_var(ENV_IPC_DIR, &ipc_dir) };
let l = Listener::bind().unwrap();
let path = endpoint().unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(std::fs::metadata(&ipc_dir).unwrap().permissions().mode() & 0o777, 0o700);
let t = std::thread::spawn(move || {
let mut c = l.accept().unwrap();
let v = read_json(&mut c).unwrap().unwrap();
write_json(&mut c, &json!({"echo": v})).unwrap();
l
});
let mut c = connect().unwrap();
write_json(&mut c, &json!({"t": "ping"})).unwrap();
assert_eq!(read_json(&mut c).unwrap(), Some(json!({"echo": {"t": "ping"}})));
let l = t.join().unwrap();
// A second instance sees the live socket and refuses to steal it.
assert_eq!(Listener::bind().err().map(|e| e.kind()), Some(io::ErrorKind::AddrInUse));
drop(l);
assert!(!path.exists(), "socket removed on shutdown");
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Kestrove desktop internals shared by the app, `kestrove-nmh` and the integration tests.
pub mod autolock;
pub mod broker;
pub mod channel;
pub mod framing;
pub mod ipc;
pub mod private_file;
pub mod words;
+357 -21
View File
@@ -1,21 +1,59 @@
// Kestrove desktop (Windows/Linux; macOS uses kestrove-apple). Thin Tauri shell over kv-ffi. // Kestrove desktop (Windows/Linux; macOS uses kestrove-apple, but this builds and runs there for
// development). Thin Tauri shell over kv-ffi: the web UI only renders and never sees keys.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{sync::Arc, thread, time::Duration}; use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::{io, thread};
use kv_ffi::{generate_password as core_generate_password, CoreError, ItemDto, ItemKindDto, VaultCore}; use kestrove_desktop::autolock::{self, Activity, Reason};
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
use kestrove_desktop::{ipc, private_file};
use kv_ffi::{generate_password as core_generate_password, totp_code, CoreError, ItemDto, ItemKindDto, VaultCore};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::State; use tauri::menu::{Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Emitter, Manager, State, WindowEvent};
use zeroize::Zeroizing;
const CLIPBOARD_CLEAR: Duration = Duration::from_secs(90);
struct App { struct App {
// TODO(M3): persistent store + sync (kv-store/kv-sync) instead of in-memory.
core: Arc<VaultCore>, core: Arc<VaultCore>,
broker: Arc<Broker>,
activity: Arc<Activity>,
data_dir: PathBuf,
ipc_error: Mutex<Option<String>>,
idle_minutes: Arc<AtomicU64>,
// Kept alive: on X11 the clipboard owner must outlive the copy.
clipboard: Mutex<Option<arboard::Clipboard>>,
}
impl App {
fn secret_key_path(&self) -> PathBuf {
self.data_dir.join("secret-key")
}
fn settings_path(&self) -> PathBuf {
self.data_dir.join("settings.json")
}
}
#[derive(Serialize, Deserialize)]
struct Settings {
idle_minutes: u64,
} }
#[derive(Serialize)] #[derive(Serialize)]
struct AppState { struct AppState {
has_account: bool, has_account: bool,
locked: bool, locked: bool,
/// The Secret Key is kept on this device, so unlock only needs the master password.
remembers_secret_key: bool,
/// Browser integration could not start (e.g. another instance is running).
ipc_error: Option<String>,
idle_minutes: u64,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -25,6 +63,7 @@ struct Summary {
username: Option<String>, username: Option<String>,
urls: Vec<String>, urls: Vec<String>,
favorite: bool, favorite: bool,
has_totp: bool,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -44,6 +83,22 @@ struct NewLogin {
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
urls: Vec<String>, urls: Vec<String>,
totp: Option<String>,
}
#[derive(Serialize)]
struct Totp {
code: String,
seconds_remaining: u64,
}
#[derive(Serialize)]
struct PairingSummary {
id: String,
name: String,
caller: String,
words: Vec<String>,
created_at: u64,
} }
type CmdResult<T> = Result<T, String>; type CmdResult<T> = Result<T, String>;
@@ -52,20 +107,80 @@ fn err(e: CoreError) -> String {
e.to_string() e.to_string()
} }
/// Tauri events to the UI; the window is brought forward so the user sees the prompt.
struct TauriUi {
handle: AppHandle,
activity: Arc<Activity>,
}
impl TauriUi {
fn surface(&self) {
if let Some(w) = self.handle.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}
}
impl Ui for TauriUi {
fn pairing_requested(&self, req: &PairingView) {
self.surface();
let _ = self.handle.emit("pairing-request", req);
}
fn save_requested(&self, req: &SaveView) {
self.surface();
let _ = self.handle.emit("save-request", req);
}
fn activity(&self) {
self.activity.touch();
}
}
fn remember_secret_key(app: &App, secret_key: &str) {
if let Err(e) = private_file::write(&app.secret_key_path(), secret_key.trim().as_bytes()) {
eprintln!("kestrove: could not store the Secret Key: {e}");
}
}
fn stored_secret_key(app: &App) -> Option<Zeroizing<String>> {
let b = private_file::read(&app.secret_key_path()).ok()??;
String::from_utf8(b).ok().map(Zeroizing::new)
}
#[tauri::command] #[tauri::command]
fn state(app: State<'_, App>) -> AppState { fn state(app: State<'_, App>) -> AppState {
AppState { has_account: app.core.has_account(), locked: app.core.is_locked() } AppState {
has_account: app.core.has_account(),
locked: app.core.is_locked(),
remembers_secret_key: stored_secret_key(&app).is_some(),
ipc_error: app.ipc_error.lock().unwrap_or_else(|e| e.into_inner()).clone(),
idle_minutes: app.idle_minutes.load(Ordering::Relaxed),
}
} }
// Argon2id takes ~0.5 s, so these run as async commands off the main thread. // Argon2id takes ~0.5 s, so these run as async commands off the main thread.
#[tauri::command] #[tauri::command]
async fn create_account(app: State<'_, App>, master_password: String) -> CmdResult<String> { async fn create_account(app: State<'_, App>, master_password: String) -> CmdResult<String> {
app.core.create_account(master_password).map_err(err) let master_password = Zeroizing::new(master_password);
let sk = app.core.create_account(master_password.to_string()).map_err(err)?;
remember_secret_key(&app, &sk);
app.activity.touch();
Ok(sk)
} }
/// `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: String) -> CmdResult<()> { async fn unlock(app: State<'_, App>, master_password: String, secret_key: Option<String>) -> CmdResult<()> {
app.core.unlock(master_password, secret_key).map_err(err) let master_password = Zeroizing::new(master_password);
let typed = secret_key.map(Zeroizing::new).filter(|s| !s.trim().is_empty());
let sk = typed.clone().or_else(|| stored_secret_key(&app)).ok_or("Enter your Secret Key.")?;
app.core.unlock(master_password.to_string(), sk.trim().to_string()).map_err(err)?;
if let Some(sk) = typed {
remember_secret_key(&app, &sk);
}
app.activity.touch();
Ok(())
} }
#[tauri::command] #[tauri::command]
@@ -73,6 +188,20 @@ fn lock(app: State<'_, App>) {
app.core.lock(); app.core.lock();
} }
/// UI input (throttled in JS): feeds the idle timer where the OS has no system idle time.
#[tauri::command]
fn touch(app: State<'_, App>) {
app.activity.touch();
}
#[tauri::command]
fn set_idle_minutes(app: State<'_, App>, minutes: u64) -> CmdResult<()> {
let minutes = minutes.clamp(1, 240);
app.idle_minutes.store(minutes, Ordering::Relaxed);
let bytes = serde_json::to_vec(&Settings { idle_minutes: minutes }).map_err(|e| e.to_string())?;
private_file::write(&app.settings_path(), &bytes).map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
fn list_items(app: State<'_, App>) -> CmdResult<Vec<Summary>> { fn list_items(app: State<'_, App>) -> CmdResult<Vec<Summary>> {
Ok(app Ok(app
@@ -80,7 +209,14 @@ fn list_items(app: State<'_, App>) -> CmdResult<Vec<Summary>> {
.list_items() .list_items()
.map_err(err)? .map_err(err)?
.into_iter() .into_iter()
.map(|s| Summary { id: s.id, title: s.title, username: s.username, urls: s.urls, favorite: s.favorite }) .map(|s| Summary {
id: s.id,
title: s.title,
username: s.username,
urls: s.urls,
favorite: s.favorite,
has_totp: s.has_totp,
})
.collect()) .collect())
} }
@@ -100,7 +236,7 @@ fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
username: item.username, username: item.username,
password: item.password, password: item.password,
urls: item.urls, urls: item.urls,
totp: None, totp: item.totp.filter(|t| !t.trim().is_empty()),
notes: None, notes: None,
favorite: false, favorite: false,
tags: vec![], tags: vec![],
@@ -109,15 +245,50 @@ fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
.map_err(err) .map_err(err)
} }
/// Copies the password from Rust so it never needs to round-trip through JS, then clears it after 90 s.
/// TODO(M3): on Windows also set ExcludeClipboardContentFromMonitorProcessing (docs/04).
#[tauri::command] #[tauri::command]
fn copy_secret(app: State<'_, App>, id: String) -> CmdResult<()> { fn delete_item(app: State<'_, App>, id: String) -> CmdResult<()> {
let secret = app.core.get_item(id).map_err(err)?.password.unwrap_or_default(); app.core.delete_item(id).map_err(err)
let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?; }
cb.set_text(secret.clone()).map_err(|e| e.to_string())?;
#[tauri::command]
fn totp(app: State<'_, App>, id: String) -> CmdResult<Option<Totp>> {
let Some(secret) = app.core.get_item(id).map_err(err)?.totp else { return Ok(None) };
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
let c = totp_code(secret, now).map_err(err)?;
Ok(Some(Totp { code: c.code, seconds_remaining: c.seconds_remaining }))
}
/// Copies from Rust so the secret never round-trips through JS, then clears it after 90 s.
#[tauri::command]
fn copy_secret(app: State<'_, App>, id: String, field: Option<String>) -> CmdResult<()> {
let item = app.core.get_item(id).map_err(err)?;
let secret = Zeroizing::new(match field.as_deref() {
Some("username") => item.username.unwrap_or_default(),
Some("totp") => {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
item.totp.map(|t| totp_code(t, now)).transpose().map_err(err)?.map(|c| c.code).unwrap_or_default()
}
_ => item.password.unwrap_or_default(),
});
let mut guard = app.clipboard.lock().unwrap_or_else(|e| e.into_inner());
if guard.is_none() {
*guard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
}
let cb = guard.as_mut().expect("clipboard initialized above");
#[cfg(windows)]
{
// docs/04: keep secrets out of clipboard history, cloud sync and monitors.
use arboard::SetExtWindows;
cb.set().exclude_from_monitoring().exclude_from_cloud().exclude_from_history().text(secret.to_string())
}
#[cfg(not(windows))]
{
cb.set_text(secret.to_string())
}
.map_err(|e| e.to_string())?;
drop(guard);
thread::spawn(move || { thread::spawn(move || {
thread::sleep(Duration::from_secs(90)); thread::sleep(CLIPBOARD_CLEAR);
if let Ok(mut cb) = arboard::Clipboard::new() { if let Ok(mut cb) = arboard::Clipboard::new() {
if cb.get_text().ok().as_deref() == Some(secret.as_str()) { if cb.get_text().ok().as_deref() == Some(secret.as_str()) {
let _ = cb.clear(); let _ = cb.clear();
@@ -129,22 +300,187 @@ fn copy_secret(app: State<'_, App>, id: String) -> CmdResult<()> {
#[tauri::command] #[tauri::command]
fn generate_password(length: u32) -> String { fn generate_password(length: u32) -> String {
core_generate_password(length, true) core_generate_password(length.clamp(8, 128), true)
}
// ---------------------------------------------------------------- browser pairing + saves
#[tauri::command]
fn pending_pairing(app: State<'_, App>) -> Option<PairingView> {
app.broker.pending_pairing()
}
#[tauri::command]
fn resolve_pairing(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()> {
app.broker.resolve_pairing(&id, accept)
}
#[tauri::command]
fn list_pairings(app: State<'_, App>) -> Vec<PairingSummary> {
app.broker
.pairings()
.into_iter()
.map(|p| PairingSummary { id: p.id, name: p.name, caller: p.caller, words: p.words, created_at: p.created_at })
.collect()
}
#[tauri::command]
fn revoke_pairing(app: State<'_, App>, id: String) -> CmdResult<()> {
app.broker.revoke_pairing(&id)
}
#[tauri::command]
fn pending_saves(app: State<'_, App>) -> Vec<SaveView> {
app.broker.pending_saves()
}
#[tauri::command]
fn resolve_save(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()> {
app.broker.resolve_save(&id, accept).map(|_| ())
}
// ---------------------------------------------------------------- startup
fn start_ipc(broker: Arc<Broker>) -> io::Result<()> {
let listener = ipc::Listener::bind()?;
thread::Builder::new().name("ipc-accept".into()).spawn(move || loop {
match listener.accept() {
Ok(conn) => {
let b = broker.clone();
let _ = thread::Builder::new().name("ipc-conn".into()).spawn(move || {
let _ = b.serve(conn);
});
}
Err(e) => {
eprintln!("kestrove: IPC accept failed: {e}");
thread::sleep(Duration::from_secs(1));
}
}
})?;
Ok(())
}
fn lock_and_notify(handle: &AppHandle, core: &VaultCore, reason: &str) {
core.lock();
let _ = handle.emit("locked", reason);
}
fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let data_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&data_dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o700))?;
}
let core = VaultCore::open(data_dir.join("vault.sqlite").to_string_lossy().into_owned())?;
let activity = Activity::new();
let ui = Arc::new(TauriUi { handle: app.handle().clone(), activity: activity.clone() });
let broker = Broker::new(core.clone(), ui, PairingStore::open(data_dir.join("pairings.json"))?);
let idle_minutes = private_file::read(&data_dir.join("settings.json"))
.ok()
.flatten()
.and_then(|b| serde_json::from_slice::<Settings>(&b).ok())
.map_or(autolock::DEFAULT_IDLE.as_secs() / 60, |s| s.idle_minutes);
let idle_minutes = Arc::new(AtomicU64::new(idle_minutes));
let ipc_error = start_ipc(broker.clone()).err().map(|e| {
eprintln!("kestrove: browser integration disabled: {e}");
if e.kind() == io::ErrorKind::AddrInUse {
"Another Kestrove instance is serving the browser extension.".to_string()
} else {
format!("Browser integration could not start: {e}")
}
});
{
let (handle, core, minutes) = (app.handle().clone(), core.clone(), idle_minutes.clone());
let unlocked = core.clone();
autolock::spawn(
activity.clone(),
move || Duration::from_secs(minutes.load(Ordering::Relaxed) * 60),
move || !unlocked.is_locked(),
move |reason| {
let why = match reason {
Reason::Idle => "idle",
Reason::ScreenLocked => "screen-locked",
Reason::Sleep => "sleep",
};
lock_and_notify(&handle, &core, why);
},
);
}
// Tray: the app keeps running (for the browser extension) when its window is closed.
let open = MenuItem::with_id(app, "open", "Open Kestrove", true, None::<&str>)?;
let lock_item = MenuItem::with_id(app, "lock", "Lock", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open, &lock_item, &quit])?;
let mut tray = TrayIconBuilder::with_id("main").menu(&menu).tooltip("Kestrove").on_menu_event({
let core = core.clone();
move |handle, event| match event.id().as_ref() {
"open" => {
if let Some(w) = handle.get_webview_window("main") {
let _ = w.show();
let _ = w.set_focus();
}
}
"lock" => lock_and_notify(handle, &core, "manual"),
"quit" => {
core.lock();
handle.exit(0);
}
_ => {}
}
});
if let Some(icon) = app.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray.build(app)?;
app.manage(App {
core,
broker,
activity,
data_dir,
ipc_error: Mutex::new(ipc_error),
idle_minutes,
clipboard: Mutex::new(None),
});
Ok(())
} }
fn main() { fn main() {
tauri::Builder::default() tauri::Builder::default()
.manage(App { core: VaultCore::new_in_memory() }) .setup(setup)
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
// Keep serving the extension from the tray; Quit is in the tray menu.
api.prevent_close();
let _ = window.hide();
}
})
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
state, state,
create_account, create_account,
unlock, unlock,
lock, lock,
touch,
set_idle_minutes,
list_items, list_items,
get_item, get_item,
save_item, save_item,
delete_item,
totp,
copy_secret, copy_secret,
generate_password generate_password,
pending_pairing,
resolve_pairing,
list_pairings,
revoke_pairing,
pending_saves,
resolve_save
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running Kestrove"); .expect("error while running Kestrove");
+27
View File
@@ -0,0 +1,27 @@
//! Small files only this user may read (0600 on Unix; on Windows the per-user app-data ACL).
use std::fs;
use std::io::{self, Write};
use std::path::Path;
/// Writes atomically (temp file + rename) with owner-only permissions.
pub fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
let tmp = path.with_extension("tmp");
let mut opts = fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
let mut f = opts.open(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
fs::rename(tmp, path)
}
/// `Ok(None)` when the file doesn't exist.
pub fn read(path: &Path) -> io::Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(b) => Ok(Some(b)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
+48
View File
@@ -0,0 +1,48 @@
//! 256 words for pairing fingerprints: one word per byte, 6 words = 48 bits, compared by a person.
//! The same list, in the same order, is in kestrove-browser/src/words.ts.
pub const WORDS: [&str; 256] = [
"acid", "acorn", "actor", "adult", "agent", "alarm", "album", "alley",
"amber", "angle", "ankle", "apple", "april", "apron", "arena", "armor",
"arrow", "atlas", "attic", "audio", "aunt", "autumn", "award", "bacon",
"badge", "bagel", "baker", "bamboo", "banjo", "barn", "basil", "basket",
"beach", "beard", "beetle", "bell", "bench", "berry", "bike", "birch",
"bison", "blade", "blanket", "blimp", "bloom", "boat", "bonus", "boot",
"bottle", "boxer", "brain", "brick", "bridge", "broom", "brush", "bucket",
"bugle", "cabin", "cable", "cactus", "camel", "camera", "canal", "candle",
"canoe", "canyon", "carbon", "cargo", "carpet", "castle", "cedar", "cello",
"chalk", "cherry", "chess", "chief", "cider", "cinema", "circus", "clay",
"cliff", "clock", "cloud", "clover", "cobra", "cocoa", "comet", "coral",
"cotton", "cousin", "coyote", "crane", "crayon", "cricket", "crown", "cup",
"daisy", "dancer", "delta", "denim", "desert", "diary", "diesel", "dingo",
"disco", "doctor", "dolphin", "donkey", "dragon", "drum", "duck", "eagle",
"earth", "easel", "echo", "eclipse", "elbow", "elder", "ember", "emerald",
"engine", "falcon", "fang", "farmer", "feather", "fence", "ferry", "fiddle",
"finch", "flag", "flute", "forest", "fossil", "fox", "frost", "galaxy",
"garden", "garlic", "gecko", "ghost", "ginger", "giraffe", "glacier", "globe",
"goat", "gold", "gorilla", "grape", "gravel", "guitar", "hammer", "harbor",
"harp", "hawk", "hazel", "helmet", "heron", "hiker", "honey", "hornet",
"hotel", "igloo", "index", "iris", "island", "ivory", "jacket", "jaguar",
"jelly", "jewel", "jungle", "kayak", "kettle", "kitten", "koala", "ladder",
"lagoon", "lake", "lantern", "laser", "lemon", "lentil", "lily", "lion",
"lizard", "lobster", "locket", "lotus", "magnet", "mango", "maple", "marble",
"meadow", "melon", "mint", "mirror", "monkey", "moose", "mosaic", "motor",
"mountain", "mulberry", "museum", "napkin", "nectar", "needle", "nickel", "noodle",
"novel", "nugget", "oasis", "ocean", "olive", "onion", "orange", "orbit",
"orchid", "otter", "owl", "oyster", "paddle", "palace", "panda", "paper",
"parrot", "peach", "pebble", "pepper", "piano", "pickle", "pilot", "pine",
"planet", "plum", "poem", "pony", "puzzle", "quartz", "quill", "rabbit",
"radar", "radio", "raven", "ribbon", "river", "robin", "rocket", "rose",
"ruby", "saddle", "salmon", "sandal", "satin", "scarf", "scout", "sugar",
];
#[cfg(test)]
mod tests {
#[test]
fn unique() {
let mut v = super::WORDS.to_vec();
v.sort_unstable();
v.dedup();
assert_eq!(v.len(), 256);
}
}
+1 -1
View File
@@ -18,6 +18,6 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["msi", "nsis", "appimage", "deb"], "targets": ["msi", "nsis", "appimage", "deb"],
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico", "icons/icon.png"] "icon": ["icons/32x32.png", "icons/128x128.png", "icons/[email protected]", "icons/icon.icns", "icons/icon.ico", "icons/icon.png"]
} }
} }
+148
View File
@@ -0,0 +1,148 @@
//! Drives the real `kestrove-nmh` binary over stdio, the way a browser does, through the
//! paired AEAD channel to a broker serving the app's IPC endpoint (isolated via KESTROVE_IPC_DIR).
#![cfg(unix)]
use std::io::{BufReader, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::Arc;
use std::thread;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
use kestrove_desktop::channel::{make_confirm, random, Ephemeral, Session};
use kestrove_desktop::framing::{read_json, write_json};
use kestrove_desktop::ipc;
use kv_ffi::{ItemDto, ItemKindDto, VaultCore};
use serde_json::{json, Value};
struct NoUi;
impl Ui for NoUi {
fn pairing_requested(&self, _: &PairingView) {}
fn save_requested(&self, _: &SaveView) {}
}
struct Host {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
}
impl Host {
fn spawn(ipc_dir: &std::path::Path) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_kestrove-nmh"))
.arg("chrome-extension://abcdefghijklmnopabcdefghijklmnop/")
.env(ipc::ENV_IPC_DIR, ipc_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
Self { child, stdin, stdout }
}
fn rpc(&mut self, v: Value) -> Value {
write_json(&mut self.stdin, &v).unwrap();
self.stdin.flush().unwrap();
read_json(&mut self.stdout).unwrap().unwrap()
}
}
impl Drop for Host {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn call(h: &mut Host, s: &mut Session, rid: u64, body: Value) -> Value {
let (n, c) = s.seal(&serde_json::to_vec(&json!({ "rid": rid, "body": body })).unwrap());
let r = h.rpc(json!({ "t": "msg", "n": n, "c": B64.encode(c) }));
assert_eq!(r["t"], "msg", "{r}");
let pt = s.open(r["n"].as_u64().unwrap(), &B64.decode(r["c"].as_str().unwrap()).unwrap()).unwrap();
let v: Value = serde_json::from_slice(&pt).unwrap();
assert_eq!(v["rid"], rid);
v["body"].clone()
}
#[test]
fn pair_and_fill_through_the_native_host() {
let dir = tempfile::tempdir().unwrap();
let ipc_dir = dir.path().join("ipc");
// No app yet: the host answers "unavailable" instead of hanging.
let mut early = Host::spawn(&ipc_dir);
assert_eq!(early.rpc(json!({ "t": "ping" })), json!({ "t": "error", "code": "unavailable" }));
let core = VaultCore::new_in_memory();
core.create_account("correct horse battery".into()).unwrap();
let id = core
.save_item(ItemDto {
id: String::new(),
kind: ItemKindDto::Login,
title: "Local".into(),
username: Some("alice".into()),
password: Some("pw-123".into()),
urls: vec!["http://127.0.0.1:8765".into()],
totp: None,
notes: None,
favorite: false,
tags: vec![],
modified_at: 0,
})
.unwrap();
let broker = Broker::new(core, Arc::new(NoUi), PairingStore::in_memory());
// SAFETY: this test binary has a single test, so nothing else reads the environment concurrently.
unsafe { std::env::set_var(ipc::ENV_IPC_DIR, &ipc_dir) };
let listener = ipc::Listener::bind().unwrap();
{
let b = broker.clone();
thread::spawn(move || loop {
let conn = listener.accept().unwrap();
let b = b.clone();
thread::spawn(move || b.serve(conn));
});
}
// The same host process reconnects once the app is up.
assert_eq!(early.rpc(json!({ "t": "ping" }))["t"], "pong");
drop(early);
let mut h = Host::spawn(&ipc_dir);
let ext = Ephemeral::generate();
let r = h.rpc(json!({ "t": "pair_init", "pub": B64.encode(ext.public), "name": "Test browser" }));
assert_eq!(r["t"], "pair_started");
let pid = r["pairing_id"].as_str().unwrap().to_string();
let p = ext.pair_as_ext(&B64.decode(r["pub"].as_str().unwrap()).unwrap().try_into().unwrap()).unwrap();
let pending = broker.pending_pairing().unwrap();
assert_eq!(pending.words, p.words.to_vec(), "both sides show the same words");
assert_eq!(pending.caller, "chrome-extension://abcdefghijklmnopabcdefghijklmnop/");
let proof = B64.encode(make_confirm(&p.key, &pid));
assert_eq!(h.rpc(json!({ "t": "pair_confirm", "pairing_id": pid, "proof": proof }))["t"], "pair_waiting");
broker.resolve_pairing(&pid, true).unwrap(); // the user clicks "pair" in the app
assert_eq!(h.rpc(json!({ "t": "pair_confirm", "pairing_id": pid, "proof": proof }))["t"], "pair_done");
let ne = random::<16>();
let r = h.rpc(json!({ "t": "open", "pairing_id": pid, "nonce": B64.encode(ne) }));
let na: [u8; 16] = B64.decode(r["nonce"].as_str().unwrap()).unwrap().try_into().unwrap();
let mut s = Session::ext(&p.key, &ne, &na);
let ids = call(&mut h, &mut s, 1, json!({ "type": "query", "origin": "http://127.0.0.1:8765" }));
assert_eq!(ids["items"][0]["id"], id);
let cred = call(&mut h, &mut s, 2, json!({ "type": "fill", "origin": "http://127.0.0.1:8765", "itemId": id }));
assert_eq!(cred, json!({ "type": "credential", "username": "alice", "password": "pw-123" }));
let other = call(&mut h, &mut s, 3, json!({ "type": "fill", "origin": "http://127.0.0.2:8765", "itemId": id }));
assert_eq!(other["code"], "origin_mismatch");
// A replayed ciphertext is refused and the session is dropped.
let (n, c) = s.seal(br#"{"rid":4,"body":{"type":"hello","version":"1"}}"#);
let frame = json!({ "t": "msg", "n": n, "c": B64.encode(c) });
assert_eq!(h.rpc(frame.clone())["t"], "msg");
assert_eq!(h.rpc(frame)["code"], "bad_session");
// A second host process (new browser connection) needs a fresh session; the old one is useless there.
let mut h2 = Host::spawn(&ipc_dir);
let (n, c) = Session::ext(&p.key, &ne, &na).seal(b"{}");
assert_eq!(h2.rpc(json!({ "t": "msg", "n": n, "c": B64.encode(c) }))["code"], "bad_session");
}
+209 -38
View File
@@ -1,64 +1,90 @@
// Minimal desktop UI. All vault logic runs in Rust (src-tauri → kv-ffi); this layer only renders. // Desktop UI. All vault logic runs in Rust (src-tauri → kv-ffi); this layer only renders.
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean } interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number }
interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean; has_totp: boolean }
interface ItemDto { id: string; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null } interface ItemDto { id: string; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null }
interface PairingView { id: string; name: string; caller: string; words: string[] }
interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number }
interface SaveView { id: string; origin: string; username: string; update: boolean }
const app = document.getElementById("app")!; const app = document.getElementById("app")!;
const overlay = document.getElementById("overlay")!;
function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Record<string, unknown> = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] { function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLElementTagNameMap[K]> & Record<string, unknown> = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] {
const e: HTMLElementTagNameMap[K] = Object.assign(document.createElement(tag), props); const e: HTMLElementTagNameMap[K] = Object.assign(document.createElement(tag), props);
e.append(...children); e.append(...children);
return e; return e;
} }
const errText = (e: unknown) => (typeof e === "string" ? e : e instanceof Error ? e.message : String(e));
async function render() { async function render() {
const state = await invoke<{ has_account: boolean; locked: boolean }>("state"); const state = await invoke<AppState>("state");
app.replaceChildren(); app.replaceChildren();
if (!state.has_account) return renderCreate(); if (!state.has_account) renderCreate();
if (state.locked) return renderUnlock(); else if (state.locked) renderUnlock(state);
return renderVault(); else await renderVault(state);
await renderPrompts(state);
} }
// ------------------------------------------------------------------ account
function renderCreate() { function renderCreate() {
const pw = el("input", { type: "password", placeholder: "Master password (10+ chars)" }); const pw = el("input", { type: "password", placeholder: "Master password (10+ chars)" });
const err = el("div", { className: "err" }); const err = el("div", { className: "err" });
const btn = el("button", { textContent: "Create vault" }); const btn = el("button", { textContent: "Create vault", className: "primary" });
const card = el("div", { className: "card" }, el("h2", { textContent: "Create your Kestrove vault" }), pw, btn, err); const card = el("div", { className: "card" }, el("h2", { textContent: "Create your Kestrove vault" }), pw, btn, err);
btn.onclick = async () => { btn.onclick = async () => {
btn.disabled = true;
try { try {
const secretKey = await invoke<string>("create_account", { masterPassword: pw.value }); const secretKey = await invoke<string>("create_account", { masterPassword: pw.value });
pw.value = "";
card.replaceChildren( card.replaceChildren(
el("h2", { textContent: "Your Secret Key" }), el("h2", { textContent: "Your Secret Key" }),
el("p", { className: "mono", textContent: secretKey }), el("p", { className: "mono", textContent: secretKey }),
el("p", { className: "muted", textContent: "Write this down in your Emergency Kit. You need it to add devices." }), el("p", { className: "muted", textContent: "Write this down in your Emergency Kit. You need it to add devices. This device remembers it." }),
el("button", { textContent: "Continue", onclick: render }), el("button", { textContent: "Continue", className: "primary", onclick: render }),
); );
} catch (e) { } catch (e) {
err.textContent = String(e); err.textContent = errText(e);
btn.disabled = false;
} }
}; };
app.append(el("div", { className: "center" }, card)); app.append(el("div", { className: "center" }, card));
pw.focus();
} }
function renderUnlock() { function renderUnlock(state: AppState) {
const pw = el("input", { type: "password", placeholder: "Master password" }); const pw = el("input", { type: "password", placeholder: "Master password" });
const sk = el("input", { placeholder: "Secret Key (K1-…)", className: "mono" }); const sk = el("input", { placeholder: "Secret Key (K1-…)", className: "mono" });
const err = el("div", { className: "err" }); const err = el("div", { className: "err" });
const btn = el("button", { textContent: "Unlock" }); const btn = el("button", { textContent: "Unlock", className: "primary" });
btn.onclick = async () => { const submit = async () => {
btn.disabled = true;
try { try {
await invoke("unlock", { masterPassword: pw.value, secretKey: sk.value }); await invoke("unlock", { masterPassword: pw.value, secretKey: sk.value || null });
pw.value = ""; pw.value = "";
sk.value = "";
await render(); await render();
} catch (e) { } catch (e) {
err.textContent = String(e); err.textContent = errText(e);
btn.disabled = false;
} }
}; };
app.append(el("div", { className: "center" }, el("div", { className: "card" }, el("h2", { textContent: "Kestrove is locked" }), pw, sk, btn, err))); btn.onclick = submit;
pw.onkeydown = (e) => e.key === "Enter" && void submit();
const card = el("div", { className: "card" }, el("h2", { textContent: "Kestrove is locked" }), pw);
if (!state.remembers_secret_key) card.append(sk);
card.append(btn, err);
app.append(el("div", { className: "center" }, card));
pw.focus();
} }
async function renderVault() { // ------------------------------------------------------------------ vault
async function renderVault(state: AppState) {
const items = await invoke<ItemSummary[]>("list_items"); const items = await invoke<ItemSummary[]>("list_items");
const detail = el("section"); const detail = el("section");
const list = el("ul"); const list = el("ul");
@@ -67,24 +93,55 @@ async function renderVault() {
li.onclick = async () => { li.onclick = async () => {
list.querySelectorAll("li").forEach((n) => n.classList.remove("sel")); list.querySelectorAll("li").forEach((n) => n.classList.remove("sel"));
li.classList.add("sel"); li.classList.add("sel");
const item = await invoke<ItemDto>("get_item", { id: it.id }); await renderDetail(detail, it.id);
const pwSpan = el("span", { className: "mono", textContent: "••••••••" });
detail.replaceChildren(
el("h2", { textContent: item.title }),
el("p", {}, "Username: ", el("span", { className: "mono", textContent: item.username ?? "" })),
el("p", {}, "Password: ", pwSpan, " ",
el("button", { textContent: "Reveal", onclick: () => (pwSpan.textContent = item.password ?? "") }),
" ",
el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id: item.id }) })),
el("p", { className: "muted", textContent: item.urls.join(", ") }),
);
}; };
list.append(li); list.append(li);
} }
const add = el("button", { textContent: "+ New login" }); if (!items.length) list.append(el("li", { className: "muted", textContent: "No items yet." }));
add.onclick = () => renderEditor(detail); const add = el("button", { textContent: "+ New login", onclick: () => renderEditor(detail) });
const settings = el("button", { textContent: "Settings", onclick: () => renderSettings(detail, state) });
const lock = el("button", { textContent: "Lock", onclick: async () => { await invoke("lock"); await render(); } }); const lock = el("button", { textContent: "Lock", onclick: async () => { await invoke("lock"); await render(); } });
app.append(el("aside", {}, el("div", { className: "toolbar" }, add, lock), list), detail); const aside = el("aside", {}, el("div", { className: "toolbar" }, add, settings, lock), list);
if (state.ipc_error) aside.append(el("p", { className: "err pad", textContent: state.ipc_error }));
app.append(aside, detail);
}
async function renderDetail(target: HTMLElement, id: string) {
const item = await invoke<ItemDto>("get_item", { id });
const pwSpan = el("span", { className: "mono", textContent: "••••••••" });
const rows: Node[] = [
el("h2", { textContent: item.title }),
el("p", {}, "Username: ", el("span", { className: "mono", textContent: item.username ?? "" }), " ",
el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id, field: "username" }) })),
el("p", {}, "Password: ", pwSpan, " ",
el("button", { textContent: "Reveal", onclick: () => (pwSpan.textContent = item.password ?? "") }), " ",
el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id, field: "password" }) })),
];
if (item.totp) {
const code = el("span", { className: "mono" });
const tick = async () => {
if (!code.isConnected) return;
const t = await invoke<{ code: string; seconds_remaining: number } | null>("totp", { id }).catch(() => null);
code.textContent = t ? `${t.code} (${t.seconds_remaining}s)` : "—";
setTimeout(tick, 1000);
};
rows.push(el("p", {}, "One-time code: ", code, " ", el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id, field: "totp" }) })));
queueMicrotask(tick);
}
rows.push(
el("p", { className: "muted", textContent: item.urls.join(", ") }),
el("button", {
textContent: "Delete",
className: "danger",
onclick: async () => {
if (confirm(`Delete “${item.title}”?`)) {
await invoke("delete_item", { id });
await render();
}
},
}),
);
target.replaceChildren(...rows);
} }
function renderEditor(target: HTMLElement) { function renderEditor(target: HTMLElement) {
@@ -92,15 +149,129 @@ function renderEditor(target: HTMLElement) {
const user = el("input", { placeholder: "Username" }); const user = el("input", { placeholder: "Username" });
const pass = el("input", { placeholder: "Password", className: "mono" }); const pass = el("input", { placeholder: "Password", className: "mono" });
const url = el("input", { placeholder: "https://example.com" }); const url = el("input", { placeholder: "https://example.com" });
const totp = el("input", { placeholder: "TOTP secret or otpauth:// URI (optional)", className: "mono" });
const gen = el("button", { textContent: "Generate", onclick: async () => (pass.value = await invoke<string>("generate_password", { length: 24 })) }); const gen = el("button", { textContent: "Generate", onclick: async () => (pass.value = await invoke<string>("generate_password", { length: 24 })) });
const save = el("button", { textContent: "Save" }); const err = el("div", { className: "err" });
const save = el("button", { textContent: "Save", className: "primary" });
save.onclick = async () => { save.onclick = async () => {
await invoke("save_item", { try {
item: { title: title.value, username: user.value || null, password: pass.value || null, urls: url.value ? [url.value] : [] }, await invoke("save_item", {
}); item: { title: title.value, username: user.value || null, password: pass.value || null, urls: url.value ? [url.value] : [], totp: totp.value || null },
await render(); });
await render();
} catch (e) {
err.textContent = errText(e);
}
}; };
target.replaceChildren(el("div", { className: "card" }, el("h2", { textContent: "New login" }), title, user, pass, gen, url, save)); target.replaceChildren(el("div", { className: "card" }, el("h2", { textContent: "New login" }), title, user, el("div", { className: "row" }, pass, gen), url, totp, save, err));
}
async function renderSettings(target: HTMLElement, state: AppState) {
const pairings = await invoke<Pairing[]>("list_pairings");
const idle = el("select", {}, ...[1, 5, 10, 15, 30, 60, 120].map((m) => el("option", { value: String(m), textContent: `${m} min`, selected: m === state.idle_minutes })));
idle.onchange = () => invoke("set_idle_minutes", { minutes: Number(idle.value) });
const list = el("ul", { className: "plain" });
for (const p of pairings) {
list.append(
el("li", {},
el("div", { textContent: `${p.name}${p.caller ? ` · ${p.caller}` : ""}` }),
el("div", { className: "muted mono", textContent: p.words.join(" ") }),
el("div", { className: "muted", textContent: `Paired ${new Date(p.created_at * 1000).toLocaleString()}` }),
el("button", {
textContent: "Unpair",
onclick: async () => {
await invoke("revoke_pairing", { id: p.id });
await renderSettings(target, state);
},
})),
);
}
if (!pairings.length) list.append(el("li", { className: "muted", textContent: "No browsers paired. Open the Kestrove extension in your browser and choose “Pair”." }));
target.replaceChildren(
el("h2", { textContent: "Settings" }),
el("label", { className: "row" }, "Lock after idle for ", idle),
el("p", { className: "muted", textContent: "Kestrove also locks on screen lock and sleep." }),
el("h3", { textContent: "Paired browsers" }),
list,
);
}
// ------------------------------------------------------------------ browser prompts (pairing, save)
async function renderPrompts(state: AppState) {
overlay.replaceChildren();
const pairing = await invoke<PairingView | null>("pending_pairing");
if (pairing) overlay.append(pairingCard(pairing, state));
if (state.has_account && !state.locked) {
for (const s of await invoke<SaveView[]>("pending_saves")) overlay.append(saveCard(s));
} else if (state.has_account && (await invoke<SaveView[]>("pending_saves")).length) {
overlay.append(el("div", { className: "prompt" }, el("p", { textContent: "Unlock Kestrove to review logins your browser asked to save." })));
}
overlay.hidden = !overlay.childElementCount;
}
function pairingCard(p: PairingView, state: AppState): HTMLElement {
const err = el("div", { className: "err" });
const words = el("div", { className: "words" }, ...p.words.map((w) => el("span", { textContent: w })));
const accept = el("button", { textContent: "The words match: pair", className: "primary" });
const reject = el("button", { textContent: "Don't pair" });
const resolve = async (yes: boolean) => {
try {
await invoke("resolve_pairing", { id: p.id, accept: yes });
await render();
} catch (e) {
err.textContent = errText(e);
}
};
accept.onclick = () => resolve(true);
reject.onclick = () => resolve(false);
accept.disabled = state.locked;
return el("div", { className: "prompt" },
el("h3", { textContent: "Pair a browser?" }),
el("p", { textContent: `${p.name}${p.caller ? ` (${p.caller})` : ""} wants to use Kestrove. Check that the extension shows exactly these words:` }),
words,
state.locked ? el("p", { className: "muted", textContent: "Unlock Kestrove first, then confirm." }) : "",
el("div", { className: "row" }, accept, reject),
err);
}
function saveCard(s: SaveView): HTMLElement {
const err = el("div", { className: "err" });
const yes = el("button", { textContent: s.update ? "Update password" : "Save", className: "primary" });
const no = el("button", { textContent: "Not now" });
const resolve = async (accept: boolean) => {
try {
await invoke("resolve_save", { id: s.id, accept });
await render();
} catch (e) {
err.textContent = errText(e);
}
};
yes.onclick = () => resolve(true);
no.onclick = () => resolve(false);
return el("div", { className: "prompt" },
el("h3", { textContent: s.update ? "Update saved password?" : "Save this login?" }),
el("p", {}, el("span", { className: "mono", textContent: s.username || "(no username)" }), " on ", el("span", { className: "mono", textContent: s.origin })),
el("div", { className: "row" }, yes, no),
err);
}
// ------------------------------------------------------------------ wiring
void listen("pairing-request", () => render());
void listen("save-request", () => render());
void listen("locked", () => render());
// Activity for the idle timer (Linux has no system idle time without extra deps).
let lastTouch = 0;
for (const ev of ["keydown", "mousedown", "mousemove"]) {
document.addEventListener(ev, () => {
const now = Date.now();
if (now - lastTouch > 30_000) {
lastTouch = now;
void invoke("touch");
}
}, { passive: true });
} }
void render(); void render();
+13
View File
@@ -14,3 +14,16 @@ section { padding: 16px; }
.err { color: #d33; } .err { color: #d33; }
.mono { font-family: ui-monospace, monospace; } .mono { font-family: ui-monospace, monospace; }
.toolbar { display: flex; gap: 8px; padding: 8px; } .toolbar { display: flex; gap: 8px; padding: 8px; }
h3 { margin: 16px 0 8px; }
select { font: inherit; padding: 6px; border-radius: 6px; }
.row { display: flex; gap: 8px; align-items: center; }
.pad { padding: 0 12px; }
.primary { background: #2c6e86; color: white; border-color: transparent; }
.danger { color: #d33; }
ul.plain { padding: 0; }
ul.plain li { list-style: none; padding: 8px 0; border-bottom: 1px solid #8883; display: grid; gap: 4px; justify-items: start; }
#overlay { position: fixed; inset: 0; background: #0006; display: grid; place-content: center; gap: 12px; }
#overlay[hidden] { display: none; }
.prompt { background: Canvas; color: CanvasText; border-radius: 10px; padding: 16px 20px; width: 420px; box-shadow: 0 10px 40px #0005; }
.words { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 12px; }
.words span { font: 600 15px ui-monospace, monospace; padding: 4px 8px; border-radius: 6px; background: #8882; }