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 <[email protected]>
This commit is contained in:
@@ -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://<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,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
/// 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 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<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)
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user