//! 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 passkey; 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, } /// One passkey the user can pick for a sign-in. #[derive(Debug, Clone, uniffi::Record)] pub struct BrowserPasskeyChoice { /// base64url; pass it back to `resolve_passkey`. pub credential_id: String, pub user_name: String, pub title: String, } /// A page in the browser asks to sign in with a passkey (`create` false) or to save a new one (`create` true). /// Show it, **verify the user** (Touch ID, Windows Hello or the master password), then `resolve_passkey`. #[derive(Debug, Clone, uniffi::Record)] pub struct BrowserPasskeyRequest { pub id: String, pub create: bool, /// The page, e.g. `https://accounts.google.com`. pub origin: String, pub rp_id: String, /// Create only: the site's name and the account name the site gave. pub rp_name: String, pub user_name: String, /// Sign-in only: the passkeys to choose from. pub choices: Vec, } /// 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); fn passkey_requested(&self, request: BrowserPasskeyRequest); /// The page stopped waiting for that passkey request (closed, navigated away, timed out): hide it. fn passkey_settled(&self, id: String); /// 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 passkey_requested(&self, req: &passkey::PasskeyView) { self.0.passkey_requested(passkey_request(req.clone())); } fn passkey_settled(&self, id: &str) { self.0.passkey_settled(id.to_string()); } fn activity(&self) { self.0.activity(); } } pub fn passkey_request(v: passkey::PasskeyView) -> BrowserPasskeyRequest { BrowserPasskeyRequest { id: v.id, create: v.kind == passkey::PasskeyKind::Create, origin: v.origin, rp_id: v.rp_id, rp_name: v.rp_name, user_name: v.user_name, choices: v .choices .into_iter() .map(|c| BrowserPasskeyChoice { credential_id: c.credential_id, user_name: c.user_name, title: c.title }) .collect(), } } 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) } pub fn pending_passkeys(&self) -> Vec { self.broker.pending_passkeys().into_iter().map(passkey_request).collect() } /// Answers a passkey request. **Verify the user right before accepting** (docs/05 "User verification"). /// `credential_id` picks the passkey for a sign-in (`None`: the first choice). pub fn resolve_passkey(&self, id: String, accept: bool, credential_id: Option) -> Result<(), CoreError> { self.broker.resolve_passkey(&id, accept, credential_id.as_deref()).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 passkey_requested(&self, _: BrowserPasskeyRequest) {} fn passkey_settled(&self, _: String) {} 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"); } }