From 9044b00a2be85ee285c995c2a1feef1b651ab4d4 Mon Sep 17 00:00:00 2001 From: samialabri Date: Wed, 21 Oct 2026 10:02:45 +0900 Subject: [PATCH] Browser bridge in the core (shared by the Mac and desktop apps); webpki security update - crates/kv-ipc: native-messaging framing and the local IPC socket/pipe (moved from kestrove-desktop), plus Listener::bind_in/connect_in and, on macOS, the App Group socket for the kestrove-nmh bundled in Kestrove.app (Contents/Resources/kestrove-app-group). - crates/kv-nmh: the native-messaging host, with an embedded Info.plist on macOS so it can run sandboxed (KV_NMH_BUNDLE_ID). - kv-ffi feature browser-bridge: broker, pairing channel and word list (moved from kestrove-desktop) and BrowserBridge for Swift. build-apple.sh enables it and builds a universal kestrove-nmh. - RUSTSEC-2026-0381/0382: rustls-webpki 0.103.16, webpki-roots 1.0.10 (distrusted emSign roots). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 44 +- Cargo.toml | 2 +- crates/kv-ffi/Cargo.toml | 10 + crates/kv-ffi/src/bridge/broker.rs | 718 ++++++++++++++++++++++++++++ crates/kv-ffi/src/bridge/channel.rs | 248 ++++++++++ crates/kv-ffi/src/bridge/mod.rs | 234 +++++++++ crates/kv-ffi/src/bridge/words.rs | 38 ++ crates/kv-ffi/src/lib.rs | 3 + crates/kv-ipc/Cargo.toml | 26 + crates/kv-ipc/src/framing.rs | 68 +++ crates/kv-ipc/src/ipc.rs | 533 +++++++++++++++++++++ crates/kv-ipc/src/lib.rs | 6 + crates/kv-ipc/src/private_file.rs | 27 ++ crates/kv-nmh/Cargo.toml | 15 + crates/kv-nmh/build.rs | 40 ++ crates/kv-nmh/src/main.rs | 65 +++ scripts/build-apple.sh | 20 +- 17 files changed, 2079 insertions(+), 18 deletions(-) create mode 100644 crates/kv-ffi/src/bridge/broker.rs create mode 100644 crates/kv-ffi/src/bridge/channel.rs create mode 100644 crates/kv-ffi/src/bridge/mod.rs create mode 100644 crates/kv-ffi/src/bridge/words.rs create mode 100644 crates/kv-ipc/Cargo.toml create mode 100644 crates/kv-ipc/src/framing.rs create mode 100644 crates/kv-ipc/src/ipc.rs create mode 100644 crates/kv-ipc/src/lib.rs create mode 100644 crates/kv-ipc/src/private_file.rs create mode 100644 crates/kv-nmh/Cargo.toml create mode 100644 crates/kv-nmh/build.rs create mode 100644 crates/kv-nmh/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 115bcd8..7d81936 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1313,11 +1313,14 @@ dependencies = [ name = "kv-ffi" version = "0.1.0" dependencies = [ + "aes-gcm", "base64 0.22.1", "ciborium", + "getrandom 0.2.17", "hkdf", "kv-crypto", "kv-import", + "kv-ipc", "kv-model", "kv-proto", "kv-store", @@ -1330,7 +1333,9 @@ dependencies = [ "thiserror", "tokio", "uniffi", + "url", "uuid", + "x25519-dalek", "zeroize", "zip", ] @@ -1355,6 +1360,16 @@ dependencies = [ "zip", ] +[[package]] +name = "kv-ipc" +version = "0.1.0" +dependencies = [ + "libc", + "serde_json", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "kv-model" version = "0.1.0" @@ -1375,6 +1390,14 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kv-nmh" +version = "0.1.0" +dependencies = [ + "kv-ipc", + "serde_json", +] + [[package]] name = "kv-proto" version = "0.1.0" @@ -1430,7 +1453,7 @@ dependencies = [ "tokio-rustls", "url", "uuid", - "webpki-roots 0.26.11", + "webpki-roots", "wiremock", "zeroize", ] @@ -2024,9 +2047,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", @@ -2949,18 +2972,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", ] @@ -3117,6 +3131,8 @@ checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", "rand_core", + "serde", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 52a8632..ff2d105 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,7 @@ rusqlite = { version = "0.32", features = ["bundled"] } opaque-ke = { version = "3", features = ["std"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-manual-roots-no-provider", "http2"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging"] } -webpki-roots = "0.26" +webpki-roots = "1" rustls-webpki = { version = "0.103", default-features = false, features = ["alloc", "ring"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "net", "io-util"] } ciborium = "0.2" diff --git a/crates/kv-ffi/Cargo.toml b/crates/kv-ffi/Cargo.toml index 06fce66..61184c9 100644 --- a/crates/kv-ffi/Cargo.toml +++ b/crates/kv-ffi/Cargo.toml @@ -31,6 +31,16 @@ thiserror.workspace = true serde_json.workspace = true zeroize.workspace = true uniffi = { workspace = true, features = ["cli", "tokio"] } +# browser-bridge: the app side of the browser extension protocol (pairing channel, broker, local IPC). +kv-ipc = { path = "../kv-ipc", optional = true } +x25519-dalek = { version = "2", features = ["static_secrets", "zeroize"], optional = true } +aes-gcm = { version = "0.10", optional = true } +getrandom = { version = "0.2", optional = true } +url = { workspace = true, optional = true } + +[features] +# Serves the browser extension through kestrove-nmh: on in the macOS app (via the Apple build) and the desktop app. +browser-bridge = ["dep:kv-ipc", "dep:x25519-dalek", "dep:aes-gcm", "dep:getrandom", "dep:url", "uuid/v4"] [dev-dependencies] kv-sync = { workspace = true, features = ["test-server"] } diff --git a/crates/kv-ffi/src/bridge/broker.rs b/crates/kv-ffi/src/bridge/broker.rs new file mode 100644 index 0000000..5c70f1a --- /dev/null +++ b/crates/kv-ffi/src/bridge/broker.rs @@ -0,0 +1,718 @@ +//! 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 crate::{CoreError, ItemDto, ItemKindDto, VaultCore}; +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use zeroize::Zeroizing; + +use super::channel::{random, verify_confirm, Ephemeral, Session}; +use kv_ipc::framing::{read_frame, write_json}; +use kv_ipc::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| crate::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::super::channel::{make_confirm, Session as Sess}; + use super::*; + + 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/crates/kv-ffi/src/bridge/channel.rs b/crates/kv-ffi/src/bridge/channel.rs new file mode 100644 index 0000000..4bf5275 --- /dev/null +++ b/crates/kv-ffi/src/bridge/channel.rs @@ -0,0 +1,248 @@ +//! 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 super::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/crates/kv-ffi/src/bridge/mod.rs b/crates/kv-ffi/src/bridge/mod.rs new file mode 100644 index 0000000..5a002f5 --- /dev/null +++ b/crates/kv-ffi/src/bridge/mod.rs @@ -0,0 +1,234 @@ +//! The app side of the browser extension (kestrove-spec/docs/05 "Desktop browsers"), feature `browser-bridge`. +//! +//! The extension talks to `kestrove-nmh` (crate kv-nmh), which relays end-to-end encrypted frames over local +//! IPC (crate kv-ipc) to whichever app serves the browser: the Kestrove app on macOS, the desktop app on +//! Windows and Linux. Both run this same code: [`broker::Broker`] directly from Rust, [`BrowserBridge`] +//! through UniFFI from Swift. + +pub mod broker; +pub mod channel; +pub mod words; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use kv_ipc::ipc::Listener; + +use crate::{CoreError, VaultCore}; +use broker::{Broker, PairingStore, PairingView, SaveView, StoredPairing, Ui}; + +/// A browser asks to pair. The app shows `words` and the user checks the extension shows the same six. +#[derive(Debug, Clone, uniffi::Record)] +pub struct BrowserPairingRequest { + pub id: String, + /// Browser and profile name the extension reported (informational). + pub name: String, + /// `chrome-extension:///` or the Firefox extension id, as the browser passed it to the host. + pub caller: String, + pub words: Vec, +} + +/// The extension saw a sign-in and offers to save (or update) the login. +#[derive(Debug, Clone, uniffi::Record)] +pub struct BrowserSaveRequest { + pub id: String, + pub origin: String, + pub username: String, + /// A login with this username already exists for the site: accepting updates its password. + pub update: bool, +} + +/// A paired browser, for the settings list. +#[derive(Debug, Clone, uniffi::Record)] +pub struct BrowserPairing { + pub id: String, + pub name: String, + pub caller: String, + pub words: Vec, + /// Unix seconds. + pub created_at: u64, +} + +/// Implemented by the app UI. Called on a background thread. +#[uniffi::export(with_foreign)] +pub trait BrowserBridgeUi: Send + Sync { + fn pairing_requested(&self, request: BrowserPairingRequest); + fn save_requested(&self, request: BrowserSaveRequest); + /// A fill or query arrived: counts as activity for auto-lock. + fn activity(&self); +} + +struct UiAdapter(Arc); + +impl Ui for UiAdapter { + fn pairing_requested(&self, req: &PairingView) { + self.0.pairing_requested(pairing_request(req.clone())); + } + fn save_requested(&self, req: &SaveView) { + self.0.save_requested(save_request(req.clone())); + } + fn activity(&self) { + self.0.activity(); + } +} + +fn pairing_request(v: PairingView) -> BrowserPairingRequest { + BrowserPairingRequest { id: v.id, name: v.name, caller: v.caller, words: v.words } +} + +fn save_request(v: SaveView) -> BrowserSaveRequest { + BrowserSaveRequest { id: v.id, origin: v.origin, username: v.username, update: v.update } +} + +fn pairing(p: StoredPairing) -> BrowserPairing { + BrowserPairing { id: p.id, name: p.name, caller: p.caller, words: p.words, created_at: p.created_at } +} + +fn invalid(msg: impl Into) -> CoreError { + CoreError::Invalid { msg: msg.into() } +} + +/// Serves `kestrove-nmh` on `/nmh.sock` until [`BrowserBridge::stop`] (or the process ends). +#[derive(uniffi::Object)] +pub struct BrowserBridge { + broker: Arc, + stopped: Arc, + ipc_dir: PathBuf, +} + +#[uniffi::export] +impl BrowserBridge { + /// Binds the socket in `ipc_dir` (created 0700) and loads the pairings from `pairings_file` (0600). + /// Fails if another instance already serves that directory. + #[uniffi::constructor] + pub fn start( + core: Arc, + ipc_dir: String, + pairings_file: String, + ui: Arc, + ) -> Result, CoreError> { + let ipc_dir = PathBuf::from(ipc_dir); + let pairings = PairingStore::open(PathBuf::from(pairings_file)) + .map_err(|e| invalid(format!("could not read the browser pairings: {e}")))?; + let broker = Broker::new(core, Arc::new(UiAdapter(ui)), pairings); + let listener = + Listener::bind_in(&ipc_dir).map_err(|e| invalid(format!("could not open the browser channel: {e}")))?; + let stopped = Arc::new(AtomicBool::new(false)); + let (b, stop) = (broker.clone(), stopped.clone()); + thread::Builder::new() + .name("kv-bridge-accept".into()) + .spawn(move || { + // The listener lives here; dropping it at the end removes the socket. + while !stop.load(Ordering::SeqCst) { + match listener.accept() { + Ok(conn) if !stop.load(Ordering::SeqCst) => { + let b = b.clone(); + let _ = thread::Builder::new().name("kv-bridge-conn".into()).spawn(move || { + let _ = b.serve(conn); + }); + } + Ok(_) => break, + Err(_) => thread::sleep(Duration::from_secs(1)), + } + } + }) + .map_err(|e| invalid(e.to_string()))?; + Ok(Arc::new(Self { broker, stopped, ipc_dir })) + } + + /// Stops accepting new connections and removes the socket. Open connections end with the browser's. + pub fn stop(&self) { + if !self.stopped.swap(true, Ordering::SeqCst) { + // Wake the blocking accept so the thread sees the flag. + let _ = kv_ipc::ipc::connect_in(&self.ipc_dir); + } + } + + pub fn pending_pairing(&self) -> Option { + self.broker.pending_pairing().map(pairing_request) + } + + /// The user compared the words. Accepting needs an unlocked vault. + pub fn resolve_pairing(&self, id: String, accept: bool) -> Result<(), CoreError> { + self.broker.resolve_pairing(&id, accept).map_err(invalid) + } + + pub fn pairings(&self) -> Vec { + self.broker.pairings().into_iter().map(pairing).collect() + } + + pub fn revoke_pairing(&self, id: String) -> Result<(), CoreError> { + self.broker.revoke_pairing(&id).map_err(invalid) + } + + pub fn pending_saves(&self) -> Vec { + self.broker.pending_saves().into_iter().map(save_request).collect() + } + + /// Returns the id of the created or updated item when accepted. + pub fn resolve_save(&self, id: String, accept: bool) -> Result, CoreError> { + self.broker.resolve_save(&id, accept).map_err(invalid) + } +} + +impl Drop for BrowserBridge { + fn drop(&mut self) { + self.stop(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kv_ipc::framing::{read_json, write_json}; + use serde_json::json; + + struct NoUi; + impl BrowserBridgeUi for NoUi { + fn pairing_requested(&self, _: BrowserPairingRequest) {} + fn save_requested(&self, _: BrowserSaveRequest) {} + fn activity(&self) {} + } + + #[test] + fn serves_the_socket_until_stopped() { + let dir = tempfile::tempdir().unwrap(); + let ipc = dir.path().join("ipc"); + let bridge = BrowserBridge::start( + VaultCore::new_in_memory(), + ipc.to_string_lossy().into(), + dir.path().join("pairings.json").to_string_lossy().into(), + Arc::new(NoUi), + ) + .unwrap(); + + let mut c = kv_ipc::ipc::connect_in(&ipc).unwrap(); + write_json(&mut c, &json!({ "t": "nmh_hello", "caller": "chrome-extension://test/" })).unwrap(); + assert_eq!(read_json(&mut c).unwrap(), Some(json!({ "t": "ok" }))); + write_json(&mut c, &json!({ "t": "ping" })).unwrap(); + assert_eq!(read_json(&mut c).unwrap().unwrap()["t"], "pong"); + assert!(bridge.pairings().is_empty()); + assert!(bridge.pending_pairing().is_none()); + + // A second app instance can't take over the socket. + let second = BrowserBridge::start( + VaultCore::new_in_memory(), + ipc.to_string_lossy().into(), + dir.path().join("other.json").to_string_lossy().into(), + Arc::new(NoUi), + ); + assert!(second.is_err()); + + bridge.stop(); + for _ in 0..50 { + if !ipc.join("nmh.sock").exists() { + break; + } + thread::sleep(Duration::from_millis(20)); + } + assert!(!ipc.join("nmh.sock").exists(), "socket removed after stop"); + } +} diff --git a/crates/kv-ffi/src/bridge/words.rs b/crates/kv-ffi/src/bridge/words.rs new file mode 100644 index 0000000..0c15e78 --- /dev/null +++ b/crates/kv-ffi/src/bridge/words.rs @@ -0,0 +1,38 @@ +//! 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); + } +} diff --git a/crates/kv-ffi/src/lib.rs b/crates/kv-ffi/src/lib.rs index 52613a4..524cd36 100644 --- a/crates/kv-ffi/src/lib.rs +++ b/crates/kv-ffi/src/lib.rs @@ -37,6 +37,9 @@ pub use passkeys::{kestrove_aaguid, PasskeyAssertion, PasskeyRegistration, Passk mod import; pub use import::{DuplicateHandling, ImportFormat, ImportOptions, ImportPreview, ImportReport}; mod onboarding; + +#[cfg(feature = "browser-bridge")] +pub mod bridge; pub use onboarding::{ encode_setup_payload, parse_setup_payload, AddDevicePayload, JoinConfirm, JoinProgress, JoinSession, SetupInfo, }; diff --git a/crates/kv-ipc/Cargo.toml b/crates/kv-ipc/Cargo.toml new file mode 100644 index 0000000..7ab254d --- /dev/null +++ b/crates/kv-ipc/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "kv-ipc" +description = "Local IPC and native-messaging framing between kestrove-nmh and the app that serves the browser extension." +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +serde_json.workspace = true + +[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_Pipes", + "Win32_System_Threading", +] } + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/kv-ipc/src/framing.rs b/crates/kv-ipc/src/framing.rs new file mode 100644 index 0000000..8919638 --- /dev/null +++ b/crates/kv-ipc/src/framing.rs @@ -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>> { + 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/crates/kv-ipc/src/ipc.rs b/crates/kv-ipc/src/ipc.rs new file mode 100644 index 0000000..d667a79 --- /dev/null +++ b/crates/kv-ipc/src/ipc.rs @@ -0,0 +1,533 @@ +//! 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`. On macOS the sandboxed Kestrove app serves the socket from +//! its App Group container (`~/Library/Group Containers//ipc/nmh.sock`); the `kestrove-nmh` +//! bundled in Kestrove.app finds the group in `Contents/Resources/kestrove-app-group` (sealed by the +//! app's signature). A host outside an app bundle (the desktop app, development) 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, connect_in, Conn, Listener}; + +/// Where the socket lives (Unix) or a display string for the pipe (Windows). +pub fn endpoint() -> io::Result { + imp::endpoint() +} + +/// Name of the file next to a bundled `kestrove-nmh` (`Contents/Resources/`) that names the Mac app's App Group. +pub const APP_GROUP_FILE: &str = "kestrove-app-group"; + +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); + + /// macOS limits `sun_path` to 104 bytes. + const MAX_SOCKET_PATH: usize = 103; + + 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)); + } + #[cfg(target_os = "macos")] + if let Some(d) = bundled_app_group_dir() { + return Ok(d); + } + platform_dir() + } + + /// The Mac app's socket directory, when this executable is the `kestrove-nmh` inside Kestrove.app. + #[cfg(target_os = "macos")] + fn bundled_app_group_dir() -> Option { + let exe = std::env::current_exe().ok()?; + let marker = exe.parent()?.parent()?.join("Resources").join(super::APP_GROUP_FILE); + let group = fs::read_to_string(marker).ok()?; + let group = group.trim(); + let valid = !group.is_empty() + && group.len() <= 128 + && !group.starts_with('.') + && group.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-'); + valid.then(|| home_dir().map(|h| h.join("Library/Group Containers").join(group).join("ipc")))? + } + + /// The real home directory (not a sandbox container's), from the password database. + #[cfg(target_os = "macos")] + fn home_dir() -> Option { + use std::ffi::{CStr, OsStr}; + use std::os::unix::ffi::OsStrExt; + let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; + let mut buf = vec![0 as libc::c_char; 4096]; + let mut out: *mut libc::passwd = std::ptr::null_mut(); + // SAFETY: all pointers are valid for the sizes passed; `out` is null or points at `pwd`. + let rc = unsafe { libc::getpwuid_r(uid(), &mut pwd, buf.as_mut_ptr(), buf.len(), &mut out) }; + if rc != 0 || out.is_null() || pwd.pw_dir.is_null() { + return None; + } + // SAFETY: pw_dir points into `buf`, NUL-terminated by getpwuid_r. + let dir = unsafe { CStr::from_ptr(pwd.pw_dir) }; + Some(PathBuf::from(OsStr::from_bytes(dir.to_bytes()))) + } + + #[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")) + } + + fn socket_in(dir: &std::path::Path) -> io::Result { + let path = dir.join("nmh.sock"); + if path.as_os_str().len() > MAX_SOCKET_PATH { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "IPC socket path is too long")); + } + Ok(path) + } + + /// 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 { + Self::bind_in(&base_dir()?) + } + + /// Binds `/nmh.sock`: the Mac app passes its App Group container's `ipc` directory. + pub fn bind_in(dir: &std::path::Path) -> io::Result { + let path = socket_in(dir)?; + secure_dir(dir)?; + 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 { + connect_in(&base_dir()?) + } + + pub fn connect_in(dir: &std::path::Path) -> io::Result { + let s = UnixStream::connect(socket_in(dir)?)?; + 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) + } + } + + /// Named pipes have no directory: the same as [`Listener::bind`]. + pub fn bind_in(_dir: &std::path::Path) -> io::Result { + Self::bind() + } + + 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)); + } + } + } + } + + /// Named pipes have no directory: the same as [`connect`]. + pub fn connect_in(_dir: &std::path::Path) -> io::Result { + connect() + } + + 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"); + } + + #[test] + fn explicit_directory() { + let dir = tempfile::tempdir().unwrap(); + let ipc_dir = dir.path().join("ipc"); + let l = Listener::bind_in(&ipc_dir).unwrap(); + assert!(ipc_dir.join("nmh.sock").exists()); + let t = std::thread::spawn(move || { + let mut c = l.accept().unwrap(); + let v = read_json(&mut c).unwrap().unwrap(); + write_json(&mut c, &v).unwrap(); + }); + let mut c = connect_in(&ipc_dir).unwrap(); + write_json(&mut c, &json!({"t": "ping"})).unwrap(); + assert_eq!(read_json(&mut c).unwrap(), Some(json!({"t": "ping"}))); + t.join().unwrap(); + let long = dir.path().join("x".repeat(120)); + assert_eq!(Listener::bind_in(&long).err().map(|e| e.kind()), Some(io::ErrorKind::InvalidInput)); + } +} diff --git a/crates/kv-ipc/src/lib.rs b/crates/kv-ipc/src/lib.rs new file mode 100644 index 0000000..9320d23 --- /dev/null +++ b/crates/kv-ipc/src/lib.rs @@ -0,0 +1,6 @@ +//! The local channel between `kestrove-nmh` (started by the browser) and the app that serves the extension: +//! the Kestrove app on macOS, the desktop app on Windows and Linux (kestrove-spec/docs/05). + +pub mod framing; +pub mod ipc; +pub mod private_file; diff --git a/crates/kv-ipc/src/private_file.rs b/crates/kv-ipc/src/private_file.rs new file mode 100644 index 0000000..fd3703a --- /dev/null +++ b/crates/kv-ipc/src/private_file.rs @@ -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>> { + 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/crates/kv-nmh/Cargo.toml b/crates/kv-nmh/Cargo.toml new file mode 100644 index 0000000..66d7cf2 --- /dev/null +++ b/crates/kv-nmh/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "kv-nmh" +description = "Native messaging host for the browser extension: relays encrypted frames to the running app." +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "kestrove-nmh" +path = "src/main.rs" + +[dependencies] +kv-ipc = { path = "../kv-ipc" } +serde_json.workspace = true diff --git a/crates/kv-nmh/build.rs b/crates/kv-nmh/build.rs new file mode 100644 index 0000000..bc7127f --- /dev/null +++ b/crates/kv-nmh/build.rs @@ -0,0 +1,40 @@ +//! macOS: embed an Info.plist (`__TEXT,__info_plist`). The copy inside Kestrove.app is signed sandboxed, and the +//! sandbox refuses to start an executable without a bundle identifier. `KV_NMH_BUNDLE_ID` is set by +//! scripts/build-apple.sh to the app's bundle id + ".nmh"; codesign takes the signing identifier from it. + +use std::env; +use std::fs; +use std::path::PathBuf; + +fn main() { + println!("cargo:rerun-if-env-changed=KV_NMH_BUNDLE_ID"); + if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") { + return; + } + let id = env::var("KV_NMH_BUNDLE_ID").unwrap_or_else(|_| "app.kestrove.nmh".into()); + assert!( + !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-'), + "KV_NMH_BUNDLE_ID must be a reverse-DNS identifier" + ); + let plist = format!( + r#" + + + + CFBundleIdentifier + {id} + CFBundleName + kestrove-nmh + CFBundleInfoDictionaryVersion + 6.0 + CFBundleShortVersionString + {version} + + +"#, + version = env::var("CARGO_PKG_VERSION").unwrap() + ); + let out = PathBuf::from(env::var("OUT_DIR").unwrap()).join("Info.plist"); + fs::write(&out, plist).unwrap(); + println!("cargo:rustc-link-arg-bins=-Wl,-sectcreate,__TEXT,__info_plist,{}", out.display()); +} diff --git a/crates/kv-nmh/src/main.rs b/crates/kv-nmh/src/main.rs new file mode 100644 index 0000000..ca50d46 --- /dev/null +++ b/crates/kv-nmh/src/main.rs @@ -0,0 +1,65 @@ +//! 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 kv_ipc::framing::{read_frame, read_json, write_frame, write_json}; +use kv_ipc::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/scripts/build-apple.sh b/scripts/build-apple.sh index 8611a83..8640413 100755 --- a/scripts/build-apple.sh +++ b/scripts/build-apple.sh @@ -1,16 +1,26 @@ #!/usr/bin/env bash -# Builds KestroveCoreFFI.xcframework + Swift bindings into ../kestrove-apple/Packages/KestroveCore. +# Builds KestroveCoreFFI.xcframework + Swift bindings into ../kestrove-apple/Packages/KestroveCore, and the +# universal kestrove-nmh the macOS app embeds to serve the browser extension (Packages/KestroveCore/bin). set -euo pipefail cd "$(dirname "$0")/.." OUT=../kestrove-apple/Packages/KestroveCore LIB=libkv_ffi.a rustup target add aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin x86_64-apple-darwin +# browser-bridge on every slice: the Swift bindings are generated once, so every slice must export the same API. for t in aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin x86_64-apple-darwin; do - cargo build -p kv-ffi --release --target "$t" + cargo build -p kv-ffi --release --target "$t" --features browser-bridge done -cargo build -p kv-ffi # host dylib for library-mode bindgen +export MACOSX_DEPLOYMENT_TARGET=14.0 +# The helper's bundle id (embedded Info.plist, see crates/kv-nmh/build.rs): the app's own + ".nmh". +BUNDLE_PREFIX=$(sed -n 's/^ *BUNDLE_PREFIX: *\([A-Za-z0-9.-]*\).*/\1/p' ../kestrove-apple/project.yml | head -1) +export KV_NMH_BUNDLE_ID="${BUNDLE_PREFIX:-app.kestrove}.nmh" +for t in aarch64-apple-darwin x86_64-apple-darwin; do + cargo build -p kv-nmh --release --target "$t" +done + +cargo build -p kv-ffi --features browser-bridge # host dylib for library-mode bindgen mkdir -p out/swift cargo run -p kv-ffi --bin uniffi-bindgen -- generate --library target/debug/libkv_ffi.dylib --language swift --out-dir out/swift @@ -30,4 +40,8 @@ xcodebuild -create-xcframework \ mkdir -p "$OUT/Sources/KestroveCore" cp out/swift/kv_ffi.swift "$OUT/Sources/KestroveCore/Generated.swift" + +mkdir -p "$OUT/bin" +lipo -create target/aarch64-apple-darwin/release/kestrove-nmh target/x86_64-apple-darwin/release/kestrove-nmh \ + -output "$OUT/bin/kestrove-nmh" echo "✅ XCFramework + bindings written to $OUT"