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
+10
View File
@@ -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"] }
+718
View File
@@ -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<String>,
}
#[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<String>,
pub created_at: u64,
key: String,
}
impl StoredPairing {
fn key(&self) -> Option<Zeroizing<[u8; 32]>> {
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<PathBuf>,
items: Mutex<Vec<StoredPairing>>,
}
impl PairingStore {
pub fn in_memory() -> Self {
Self { path: None, items: Mutex::new(Vec::new()) }
}
pub fn open(path: PathBuf) -> io::Result<Self> {
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<StoredPairing>> {
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<StoredPairing> {
self.guard().clone()
}
fn get(&self, id: &str) -> Option<StoredPairing> {
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<bool> {
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<String>,
at: Instant,
}
pub struct Broker {
core: Arc<VaultCore>,
ui: Arc<dyn Ui>,
pairings: PairingStore,
pending_pairing: Mutex<Option<PendingPairing>>,
saves: Mutex<Vec<PendingSave>>,
}
fn lock<T>(m: &Mutex<T>) -> 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<const N: usize>(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<String> {
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<Session>,
}
impl Broker {
pub fn new(core: Arc<VaultCore>, ui: Arc<dyn Ui>, pairings: PairingStore) -> Arc<Self> {
Arc::new(Self { core, ui, pairings, pending_pairing: Mutex::new(None), saves: Mutex::new(Vec::new()) })
}
pub fn core(&self) -> &Arc<VaultCore> {
&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::<Value>(&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<Value> = 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<Value> {
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<PairingView> {
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<StoredPairing> {
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::<Vec<_>>(),
}),
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<ItemDto, Value> {
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<ItemDto> {
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<String>) -> 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<SaveView> {
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<Option<String>, 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<Broker>, 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:[email protected]"), 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());
}
}
+248
View File
@@ -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<const N: usize>() -> [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<Pairing> {
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<Pairing> {
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::<Sha256>::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::<Aes256Gcm>::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<u8> {
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::<Sha256>::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<u8>) {
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<Zeroizing<Vec<u8>>, 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());
}
}
+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");
}
}
+38
View File
@@ -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);
}
}
+3
View File
@@ -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,
};