Browser bridge in the core (shared by the Mac and desktop apps); webpki security update
ci / test (push) Canceled after 0s
ci / wasm (push) Canceled after 0s
ci / deny (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s

- 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:
2026-10-21 10:02:45 +09:00
co-authored by Claude Opus 5.5
parent d45033f424
commit 9044b00a2b
17 changed files with 2079 additions and 18 deletions
+234
View File
@@ -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");
}
}