Browser passkeys: ask to unlock instead of falling back when locked
ci / test (push) Canceled after 0s
ci / wasm (push) Canceled after 0s
ci / deny (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-24 14:58:04 +07:00
co-authored by Claude Opus 5.5
parent 8f0743d964
commit 8622123b00
2 changed files with 105 additions and 12 deletions
+4 -1
View File
@@ -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<BrowserPasskeyChoice>,
/// 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()
+101 -11
View File
@@ -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<PasskeyChoice>,
/// 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<u8>,
user_handle: Vec<u8>,
/// Get: the site's `allowCredentials` (or the one picked in the page). Create: `excludeCredentials`.
credentials: Vec<Vec<u8>>,
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<u8>, user_handle: Vec<u8>) -> Value {
/// One of the site's `excludeCredentials` is already in the vault for this RP.
fn excluded(&self, rp_id: &str, exclude: &[Vec<u8>]) -> 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<u8>,
user_handle: Vec<u8>,
credentials: Vec<Vec<u8>>,
) -> 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<PasskeyView> {
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<Broker>, Arc<Prompts>) {
let (b, ui, _) = broker_with_key();
(b, ui)
}
fn broker_with_key() -> (Arc<Broker>, Arc<Prompts>, 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, "[email protected]")["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, "[email protected]");
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"));