From 8622123b003595d0749786879485d405c09482bd Mon Sep 17 00:00:00 2001 From: samialabri Date: Sat, 24 Oct 2026 14:58:04 +0700 Subject: [PATCH] Browser passkeys: ask to unlock instead of falling back when locked Co-Authored-By: Claude Opus 5.5 --- crates/kv-ffi/src/bridge/mod.rs | 5 +- crates/kv-ffi/src/bridge/passkey.rs | 112 +++++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/crates/kv-ffi/src/bridge/mod.rs b/crates/kv-ffi/src/bridge/mod.rs index c2e99a1..b1bf034 100644 --- a/crates/kv-ffi/src/bridge/mod.rs +++ b/crates/kv-ffi/src/bridge/mod.rs @@ -63,8 +63,10 @@ pub struct BrowserPasskeyRequest { /// 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. + /// Sign-in only: the passkeys to choose from. Empty while `locked`. pub choices: Vec, + /// Kestrove was locked when the page asked: unlock, then call `pending_passkeys` again for the choices. + pub locked: bool, } /// A paired browser, for the settings list. @@ -118,6 +120,7 @@ pub fn passkey_request(v: passkey::PasskeyView) -> BrowserPasskeyRequest { rp_id: v.rp_id, rp_name: v.rp_name, user_name: v.user_name, + locked: v.locked, choices: v .choices .into_iter() diff --git a/crates/kv-ffi/src/bridge/passkey.rs b/crates/kv-ffi/src/bridge/passkey.rs index 6fa2001..834569b 100644 --- a/crates/kv-ffi/src/bridge/passkey.rs +++ b/crates/kv-ffi/src/bridge/passkey.rs @@ -65,13 +65,18 @@ pub struct PasskeyView { pub rp_name: String, pub user_name: String, /// Get only: the passkeys that may answer (one entry if the site or the user already picked). + /// Empty while `locked`: they're known once the user unlocks. pub choices: Vec, + /// Kestrove was locked when the page asked: the app asks to unlock first. + pub locked: bool, } pub(super) struct PendingPasskey { view: PasskeyView, client_data_hash: Vec, user_handle: Vec, + /// Get: the site's `allowCredentials` (or the one picked in the page). Create: `excludeCredentials`. + credentials: Vec>, at: Instant, state: State, } @@ -165,7 +170,10 @@ impl Broker { } allow = vec![chosen]; } - let choices = match self.choices(&rp_id, allow) { + // Locked: still ask, so the user can unlock and use their passkey; which ones match is known then. + let locked = self.core.is_locked(); + let choices = match self.choices(&rp_id, allow.clone()) { + _ if locked => vec![], Ok(c) if c.is_empty() => return err("not_found"), Ok(c) => c, Err(e) => return passkey_err(&e), @@ -178,8 +186,9 @@ impl Broker { rp_name: String::new(), user_name: String::new(), choices, + locked, }; - self.queue_passkey(view, hash, vec![]) + self.queue_passkey(view, hash, vec![], allow) } "passkey_create" => { let (Some(hash), Some(user_handle), Some(exclude)) = ( @@ -196,10 +205,9 @@ impl Broker { if !algs.is_some_and(|a| a.iter().any(|x| x.as_i64() == Some(ES256))) { return err("unsupported"); } - match self.core.passkeys_for_rp(rp_id.clone(), exclude.clone()) { - Ok(found) if !exclude.is_empty() && !found.is_empty() => return err("excluded"), - Ok(_) => {} - Err(e) => return passkey_err(&e), + let locked = self.core.is_locked(); + if !locked && self.excluded(&rp_id, &exclude) { + return err("excluded"); } let view = PasskeyView { id: uuid::Uuid::new_v4().to_string(), @@ -209,8 +217,9 @@ impl Broker { user_name: text(body.get("userName"), 256), rp_id, choices: vec![], + locked, }; - self.queue_passkey(view, hash, user_handle) + self.queue_passkey(view, hash, user_handle, exclude) } _ => err("unknown_type"), } @@ -228,7 +237,19 @@ impl Broker { .collect()) } - fn queue_passkey(&self, view: PasskeyView, client_data_hash: Vec, user_handle: Vec) -> Value { + /// One of the site's `excludeCredentials` is already in the vault for this RP. + fn excluded(&self, rp_id: &str, exclude: &[Vec]) -> bool { + !exclude.is_empty() + && self.core.passkeys_for_rp(rp_id.to_string(), exclude.to_vec()).is_ok_and(|f| !f.is_empty()) + } + + fn queue_passkey( + &self, + view: PasskeyView, + client_data_hash: Vec, + user_handle: Vec, + credentials: Vec>, + ) -> Value { self.ui.activity(); let id = view.id.clone(); { @@ -241,6 +262,7 @@ impl Broker { view: view.clone(), client_data_hash, user_handle, + credentials, at: Instant::now(), state: State::Waiting, }); @@ -267,10 +289,30 @@ impl Broker { } } - /// Passkey requests waiting for the user, oldest first. + /// Passkey requests waiting for the user, oldest first. Requests made while locked get their choices once + /// the vault is unlocked; a sign-in with no matching passkey then ends (the browser takes over). pub fn pending_passkeys(&self) -> Vec { let mut q = lock(&self.passkeys); q.retain(|p| !matches!(p.state, State::Waiting) || p.at.elapsed() < PASSKEY_TTL); + if !self.core.is_locked() { + for p in q.iter_mut().filter(|p| p.view.locked && matches!(p.state, State::Waiting)) { + p.view.locked = false; + match p.view.kind { + PasskeyKind::Get => match self.choices(&p.view.rp_id, p.credentials.clone()) { + Ok(c) if !c.is_empty() => p.view.choices = c, + Ok(_) => p.state = State::Done(err("not_found"), Instant::now()), + Err(e) => p.state = State::Done(passkey_err(&e), Instant::now()), + }, + PasskeyKind::Create if self.excluded(&p.view.rp_id, &p.credentials) => { + p.state = State::Done(err("excluded"), Instant::now()); + } + PasskeyKind::Create => {} + } + if !matches!(p.state, State::Waiting) { + self.ui.passkey_settled(&p.view.id); + } + } + } q.iter().filter(|p| matches!(p.state, State::Waiting)).map(|p| p.view.clone()).collect() } @@ -292,6 +334,14 @@ impl Broker { if self.core.is_locked() { return Err("Unlock Kestrove first.".into()); } + if p.view.locked { + drop(q); + // Unlocked since the request came in: work out its passkeys first, then answer it. + if !self.pending_passkeys().iter().any(|v| v.id == id) { + return Ok(()); // no passkey for this site after all, or already saved: the page is told + } + return self.resolve_passkey(id, accept, credential_id); + } let v = &p.view; let reply = match v.kind { PasskeyKind::Get => { @@ -373,10 +423,15 @@ mod tests { } fn broker() -> (Arc, Arc) { + let (b, ui, _) = broker_with_key(); + (b, ui) + } + + fn broker_with_key() -> (Arc, Arc, String) { let core = VaultCore::new_in_memory(); - core.create_account("correct horse battery".into()).unwrap(); + let sk = core.create_account("correct horse battery".into()).unwrap(); let ui = Arc::new(Prompts::default()); - (Broker::new(core, ui.clone(), PairingStore::in_memory()), ui) + (Broker::new(core, ui.clone(), PairingStore::in_memory()), ui, sk) } const ORIGIN: &str = "https://accounts.google.com"; @@ -490,6 +545,41 @@ mod tests { assert_eq!(result(&b, &id)["type"], "passkey_pending"); } + #[test] + fn locked_sign_in_asks_to_unlock_then_continues() { + let (b, ui, sk) = broker_with_key(); + let id = create(&b, "sami@gmail.com")["requestId"].as_str().unwrap().to_string(); + b.resolve_passkey(&id, true, None).unwrap(); + assert_eq!(result(&b, &id)["type"], "passkey_registration"); + b.core().lock(); + + let get = |rp: &str, origin: &str| { + b.handle_request( + &json!({ "type": "passkey_get", "origin": origin, "rpId": rp, "clientDataHash": hash("g") }), + ) + }; + let id = get("google.com", ORIGIN)["requestId"].as_str().unwrap().to_string(); + let shown = ui.0.lock().unwrap().last().cloned().unwrap(); + assert!(shown.locked && shown.choices.is_empty()); + assert!(b.resolve_passkey(&id, true, None).is_err(), "still locked"); + + // A site with no Kestrove passkey also asks while locked, and ends once unlocked. + let other = get("github.com", "https://github.com")["requestId"].as_str().unwrap().to_string(); + + b.core().unlock("correct horse battery".into(), sk).unwrap(); + let pending = b.pending_passkeys(); + assert_eq!(pending.len(), 1); + assert!(!pending[0].locked); + assert_eq!(pending[0].choices[0].user_name, "sami@gmail.com"); + let gh = + b.handle_request(&json!({ "type": "passkey_result", "origin": "https://github.com", "requestId": other })); + assert_eq!(gh["code"], "not_found"); + assert!(ui.1.lock().unwrap().contains(&other)); + + b.resolve_passkey(&id, true, None).unwrap(); + assert_eq!(result(&b, &id)["type"], "passkey_assertion"); + } + #[test] fn rp_id_must_be_the_origin_or_a_registrable_suffix() { assert!(rp_id_allowed("https://accounts.google.com", "google.com"));