From 5e59f50abc2ad459fab86a4403a8908ed35f1899 Mon Sep 17 00:00:00 2001 From: samialabri Date: Wed, 21 Oct 2026 10:02:49 +0900 Subject: [PATCH] Use the browser bridge from kestrove-core; webpki security update The broker, pairing channel, IPC and kestrove-nmh moved to kestrove-core so the macOS app serves the extension too (a Mac no longer needs this app). Old module paths are re-exports. rustls-webpki 0.103.16 and webpki-roots 1.0.10 (RUSTSEC-2026-0381/0382). Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +- native-messaging/install.sh | 2 + src-tauri/Cargo.lock | 37 +- src-tauri/Cargo.toml | 12 +- src-tauri/src/bin/kestrove-nmh.rs | 65 --- src-tauri/src/broker.rs | 718 ------------------------------ src-tauri/src/channel.rs | 248 ----------- src-tauri/src/framing.rs | 68 --- src-tauri/src/ipc.rs | 442 ------------------ src-tauri/src/lib.rs | 14 +- src-tauri/src/private_file.rs | 27 -- src-tauri/src/words.rs | 38 -- 12 files changed, 46 insertions(+), 1643 deletions(-) delete mode 100644 src-tauri/src/bin/kestrove-nmh.rs delete mode 100644 src-tauri/src/broker.rs delete mode 100644 src-tauri/src/channel.rs delete mode 100644 src-tauri/src/framing.rs delete mode 100644 src-tauri/src/ipc.rs delete mode 100644 src-tauri/src/private_file.rs delete mode 100644 src-tauri/src/words.rs diff --git a/README.md b/README.md index 23ea70a..1ecbb4d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ directly. The web UI only renders and never touches keys. macOS uses the native `kestrove-apple` app. Also ships `kestrove-nmh`, the **native-messaging host** that bridges `kestrove-browser` to the app. +On a Mac, the Kestrove Mac app serves the browser extension itself (Chrome, Edge, Brave, Vivaldi, Arc, +Firefox), so this app isn't needed there; it still builds on macOS for development. ```bash # prerequisites: rustup, Node 20+, and Tauri's OS deps (https://tauri.app/start/prerequisites/) @@ -22,23 +24,27 @@ 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) +extension ─ native messaging (stdio) ─▶ kestrove-nmh ─ local IPC ─▶ app (broker) └──────────── 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 +The bridge is shared with the macOS app and lives in kestrove-core: `crates/kv-ipc` (framing, IPC), +`crates/kv-nmh` (the host) and `kv-ffi`'s `browser-bridge` feature (`src/bridge/`: broker, pairing +channel). This crate re-exports them under the old module names. + +* **IPC** (`kv-ipc`): 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-`, 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): +* **Pairing** (`bridge/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` +* **Protocol** (`bridge/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. +* **Native host manifests**: `native-messaging/install.sh` (Linux; macOS for development only, the Mac + app registers its own) 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. diff --git a/native-messaging/install.sh b/native-messaging/install.sh index 7208cd3..e7eb2c9 100755 --- a/native-messaging/install.sh +++ b/native-messaging/install.sh @@ -54,6 +54,8 @@ fi case "$(uname -s)" in Darwin) + # The Kestrove Mac app registers its own host on launch; this points the browsers at a desktop dev build. + [ "$UNINSTALL" = 1 ] || echo "note: on macOS the Kestrove app registers itself; re-open it to switch back" >&2 BASE="$HOME/Library/Application Support" dir_chrome="$BASE/Google/Chrome" dir_chromium="$BASE/Chromium" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8447ff4..ad4272b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3014,16 +3014,15 @@ dependencies = [ name = "kestrove-desktop" version = "0.1.0" dependencies = [ - "aes-gcm", "arboard", "base64 0.22.1", "core-foundation", "getrandom 0.2.17", "hayro", - "hkdf 0.12.4", "image", "keyring", "kv-ffi", + "kv-ipc", "kv-sync", "libc", "p256", @@ -3039,7 +3038,6 @@ dependencies = [ "url", "uuid", "windows-sys 0.61.2", - "x25519-dalek", "zeroize", ] @@ -3108,10 +3106,13 @@ dependencies = [ name = "kv-ffi" version = "0.1.0" dependencies = [ + "aes-gcm", "base64 0.22.1", + "getrandom 0.2.17", "hkdf 0.12.4", "kv-crypto", "kv-import", + "kv-ipc", "kv-model", "kv-proto", "kv-store", @@ -3122,7 +3123,9 @@ dependencies = [ "thiserror 2.0.21", "tokio", "uniffi", + "url", "uuid", + "x25519-dalek", "zeroize", ] @@ -3146,6 +3149,15 @@ dependencies = [ "zip", ] +[[package]] +name = "kv-ipc" +version = "0.1.0" +dependencies = [ + "libc", + "serde_json", + "windows-sys 0.61.2", +] + [[package]] name = "kv-model" version = "0.1.0" @@ -3213,7 +3225,7 @@ dependencies = [ "tokio", "url", "uuid", - "webpki-roots 0.26.11", + "webpki-roots", "wiremock", "zeroize", ] @@ -4620,9 +4632,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -6448,18 +6460,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.11" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.9", -] - -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6adfcfd..791afe4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,26 +15,24 @@ path = "src/lib.rs" name = "kestrove-desktop" path = "src/main.rs" -# Native messaging host: started by the browser, relays to the running app (docs/05). +# Native messaging host: started by the browser, relays to the running app (docs/05). Shared with the macOS +# app, so the source lives in kestrove-core (crates/kv-nmh). [[bin]] name = "kestrove-nmh" -path = "src/bin/kestrove-nmh.rs" +path = "../../kestrove-core/crates/kv-nmh/src/main.rs" [build-dependencies] tauri-build = { version = "2", features = [] } [dependencies] # 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", features = ["browser-bridge"] } +kv-ipc = { path = "../../kestrove-core/crates/kv-ipc" } tauri = { version = "2", features = ["tray-icon"] } serde = { version = "1", features = ["derive"] } serde_json = "1" 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"] } diff --git a/src-tauri/src/bin/kestrove-nmh.rs b/src-tauri/src/bin/kestrove-nmh.rs deleted file mode 100644 index 634b050..0000000 --- a/src-tauri/src/bin/kestrove-nmh.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! 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 (native -//! byte order) followed by UTF-8 JSON. This process holds **no** secrets and can't read the -//! traffic: frames are end-to-end encrypted between the extension and the app (pairing channel, -//! see `channel.rs`). It relays each frame to the running app over local IPC (`ipc.rs`) and -//! relays the app's reply back, strictly one reply per request. -//! -//! 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, Write}; - -use kestrove_desktop::framing::{read_frame, read_json, write_frame, write_json}; -use kestrove_desktop::ipc::{self, Conn}; -use serde_json::json; - -/// Chrome passes the caller origin (`chrome-extension:///`) as the first argument; Firefox -/// passes the manifest path and then the extension id. Informational only (shown when pairing): -/// the browser's `allowed_origins` check and the pairing key are what actually gate access. -fn caller() -> String { - let args: Vec = std::env::args().skip(1).collect(); - match args.as_slice() { - [a, ..] if a.starts_with("chrome-extension://") => a.clone(), - [_, id, ..] if !id.starts_with("--") => id.clone(), - _ => String::new(), - } -} - -fn connect(caller: &str) -> io::Result { - let mut c = ipc::connect()?; - write_json(&mut c, &json!({ "t": "nmh_hello", "caller": caller }))?; - match read_json(&mut c)? { - Some(v) if v["t"] == "ok" => Ok(c), - _ => Err(io::Error::new(io::ErrorKind::InvalidData, "unexpected hello reply")), - } -} - -fn relay(conn: &mut Conn, frame: &[u8]) -> io::Result> { - write_frame(conn, frame)?; - read_frame(conn)?.ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "app closed the connection")) -} - -fn main() -> io::Result<()> { - let caller = caller(); - let (mut stdin, mut stdout) = (io::stdin().lock(), io::stdout().lock()); - let unavailable = serde_json::to_vec(&json!({ "t": "error", "code": "unavailable" }))?; - let mut conn: Option = 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(()) -} diff --git a/src-tauri/src/broker.rs b/src-tauri/src/broker.rs deleted file mode 100644 index 21ae296..0000000 --- a/src-tauri/src/broker.rs +++ /dev/null @@ -1,718 +0,0 @@ -//! 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, -} - -#[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, - pub created_at: u64, - key: String, -} - -impl StoredPairing { - fn key(&self) -> Option> { - 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, - items: Mutex>, -} - -impl PairingStore { - pub fn in_memory() -> Self { - Self { path: None, items: Mutex::new(Vec::new()) } - } - - pub fn open(path: PathBuf) -> io::Result { - 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> { - 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 { - self.guard().clone() - } - - fn get(&self, id: &str) -> Option { - 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 { - 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, - at: Instant, -} - -pub struct Broker { - core: Arc, - ui: Arc, - pairings: PairingStore, - pending_pairing: Mutex>, - saves: Mutex>, -} - -fn lock(m: &Mutex) -> 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(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 { - 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, -} - -impl Broker { - pub fn new(core: Arc, ui: Arc, pairings: PairingStore) -> Arc { - Arc::new(Self { core, ui, pairings, pending_pairing: Mutex::new(None), saves: Mutex::new(Vec::new()) }) - } - - pub fn core(&self) -> &Arc { - &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::(&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 = 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 { - 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 { - 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 { - 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::>(), - }), - 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 { - 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 { - 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) -> 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 { - 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, 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, 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:p@example.com"), 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()); - } -} diff --git a/src-tauri/src/channel.rs b/src-tauri/src/channel.rs deleted file mode 100644 index 800914e..0000000 --- a/src-tauri/src/channel.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! 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() -> [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 { - 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 { - 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::::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::::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 { - 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::::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) { - 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>, 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()); - } -} diff --git a/src-tauri/src/framing.rs b/src-tauri/src/framing.rs deleted file mode 100644 index 8919638..0000000 --- a/src-tauri/src/framing.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! 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>> { - 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> { - 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()); - } -} diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs deleted file mode 100644 index b16bc0c..0000000 --- a/src-tauri/src/ipc.rs +++ /dev/null @@ -1,442 +0,0 @@ -//! 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-`, 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 { - imp::endpoint() -} - -impl Read for Conn { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - self.0.read(buf) - } -} - -impl Write for Conn { - fn write(&mut self, buf: &[u8]) -> io::Result { - 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 { - 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 { - 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 { - 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 { - 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 { - let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 }; - let mut len = std::mem::size_of::() 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 { - 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 { - 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 { - 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 { - 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, - sddl: Vec, - first: std::sync::atomic::AtomicBool, - } - - fn wide(s: &str) -> Vec { - 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 { - 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> { - 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> { - // 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 { - 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 { - 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::() 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 { - 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 { - 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"); - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9ae358c..47cfcbf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,13 +1,13 @@ -//! Kestrove desktop internals shared by the app, `kestrove-nmh` and the integration tests. +//! Kestrove desktop internals shared by the app and the integration tests. +//! +//! The browser bridge (broker, pairing channel, local IPC, `kestrove-nmh`) lives in kestrove-core, where the +//! macOS app uses it too; the old module paths are kept as re-exports. pub mod autolock; -pub mod broker; -pub mod channel; pub mod device_key; -pub mod framing; -pub mod ipc; -pub mod private_file; pub mod qr; pub mod sync; pub mod title_bar; -pub mod words; + +pub use kv_ffi::bridge::{broker, channel, words}; +pub use kv_ipc::{framing, ipc, private_file}; diff --git a/src-tauri/src/private_file.rs b/src-tauri/src/private_file.rs deleted file mode 100644 index fd3703a..0000000 --- a/src-tauri/src/private_file.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! 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>> { - match fs::read(path) { - Ok(b) => Ok(Some(b)), - Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e), - } -} diff --git a/src-tauri/src/words.rs b/src-tauri/src/words.rs deleted file mode 100644 index 0c15e78..0000000 --- a/src-tauri/src/words.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! 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); - } -}