Files
kestrove-core/crates/kv-ffi/src/bridge/mod.rs
T
samiandClaude Opus 5.5 8f0743d964 Passkeys for the browser extension, tags in item lists
The browser bridge answers passkey_query/get/create/result/cancel: the page's
RP ID is checked against the tab origin (no public suffixes), every request
waits for the user to approve it in the app, and the extension polls for the
result. Tags are normalised on save and included in item summaries.
confirm_master_password verifies the user where there are no biometrics.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 09:28:51 +07:00

298 lines
11 KiB
Rust

//! 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://<id>/` or the Firefox extension id, as the browser passed it to the host.
pub caller: String,
pub words: Vec<String>,
}
/// 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<BrowserPasskeyChoice>,
}
/// 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<String>,
/// 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<dyn BrowserBridgeUi>);
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<String>) -> CoreError {
CoreError::Invalid { msg: msg.into() }
}
/// Serves `kestrove-nmh` on `<ipc_dir>/nmh.sock` until [`BrowserBridge::stop`] (or the process ends).
#[derive(uniffi::Object)]
pub struct BrowserBridge {
broker: Arc<Broker>,
stopped: Arc<AtomicBool>,
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<VaultCore>,
ipc_dir: String,
pairings_file: String,
ui: Arc<dyn BrowserBridgeUi>,
) -> Result<Arc<Self>, 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<BrowserPairingRequest> {
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<BrowserPairing> {
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<BrowserSaveRequest> {
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<Option<String>, CoreError> {
self.broker.resolve_save(&id, accept).map_err(invalid)
}
pub fn pending_passkeys(&self) -> Vec<BrowserPasskeyRequest> {
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<String>) -> 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");
}
}