Browser passkeys: ask to unlock instead of falling back when locked
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -63,8 +63,10 @@ pub struct BrowserPasskeyRequest {
|
|||||||
/// Create only: the site's name and the account name the site gave.
|
/// Create only: the site's name and the account name the site gave.
|
||||||
pub rp_name: String,
|
pub rp_name: String,
|
||||||
pub user_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>,
|
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.
|
/// 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_id: v.rp_id,
|
||||||
rp_name: v.rp_name,
|
rp_name: v.rp_name,
|
||||||
user_name: v.user_name,
|
user_name: v.user_name,
|
||||||
|
locked: v.locked,
|
||||||
choices: v
|
choices: v
|
||||||
.choices
|
.choices
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -65,13 +65,18 @@ pub struct PasskeyView {
|
|||||||
pub rp_name: String,
|
pub rp_name: String,
|
||||||
pub user_name: String,
|
pub user_name: String,
|
||||||
/// Get only: the passkeys that may answer (one entry if the site or the user already picked).
|
/// 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>,
|
pub choices: Vec<PasskeyChoice>,
|
||||||
|
/// Kestrove was locked when the page asked: the app asks to unlock first.
|
||||||
|
pub locked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct PendingPasskey {
|
pub(super) struct PendingPasskey {
|
||||||
view: PasskeyView,
|
view: PasskeyView,
|
||||||
client_data_hash: Vec<u8>,
|
client_data_hash: Vec<u8>,
|
||||||
user_handle: 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,
|
at: Instant,
|
||||||
state: State,
|
state: State,
|
||||||
}
|
}
|
||||||
@@ -165,7 +170,10 @@ impl Broker {
|
|||||||
}
|
}
|
||||||
allow = vec![chosen];
|
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) if c.is_empty() => return err("not_found"),
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return passkey_err(&e),
|
Err(e) => return passkey_err(&e),
|
||||||
@@ -178,8 +186,9 @@ impl Broker {
|
|||||||
rp_name: String::new(),
|
rp_name: String::new(),
|
||||||
user_name: String::new(),
|
user_name: String::new(),
|
||||||
choices,
|
choices,
|
||||||
|
locked,
|
||||||
};
|
};
|
||||||
self.queue_passkey(view, hash, vec![])
|
self.queue_passkey(view, hash, vec![], allow)
|
||||||
}
|
}
|
||||||
"passkey_create" => {
|
"passkey_create" => {
|
||||||
let (Some(hash), Some(user_handle), Some(exclude)) = (
|
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))) {
|
if !algs.is_some_and(|a| a.iter().any(|x| x.as_i64() == Some(ES256))) {
|
||||||
return err("unsupported");
|
return err("unsupported");
|
||||||
}
|
}
|
||||||
match self.core.passkeys_for_rp(rp_id.clone(), exclude.clone()) {
|
let locked = self.core.is_locked();
|
||||||
Ok(found) if !exclude.is_empty() && !found.is_empty() => return err("excluded"),
|
if !locked && self.excluded(&rp_id, &exclude) {
|
||||||
Ok(_) => {}
|
return err("excluded");
|
||||||
Err(e) => return passkey_err(&e),
|
|
||||||
}
|
}
|
||||||
let view = PasskeyView {
|
let view = PasskeyView {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
@@ -209,8 +217,9 @@ impl Broker {
|
|||||||
user_name: text(body.get("userName"), 256),
|
user_name: text(body.get("userName"), 256),
|
||||||
rp_id,
|
rp_id,
|
||||||
choices: vec![],
|
choices: vec![],
|
||||||
|
locked,
|
||||||
};
|
};
|
||||||
self.queue_passkey(view, hash, user_handle)
|
self.queue_passkey(view, hash, user_handle, exclude)
|
||||||
}
|
}
|
||||||
_ => err("unknown_type"),
|
_ => err("unknown_type"),
|
||||||
}
|
}
|
||||||
@@ -228,7 +237,19 @@ impl Broker {
|
|||||||
.collect())
|
.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();
|
self.ui.activity();
|
||||||
let id = view.id.clone();
|
let id = view.id.clone();
|
||||||
{
|
{
|
||||||
@@ -241,6 +262,7 @@ impl Broker {
|
|||||||
view: view.clone(),
|
view: view.clone(),
|
||||||
client_data_hash,
|
client_data_hash,
|
||||||
user_handle,
|
user_handle,
|
||||||
|
credentials,
|
||||||
at: Instant::now(),
|
at: Instant::now(),
|
||||||
state: State::Waiting,
|
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> {
|
pub fn pending_passkeys(&self) -> Vec<PasskeyView> {
|
||||||
let mut q = lock(&self.passkeys);
|
let mut q = lock(&self.passkeys);
|
||||||
q.retain(|p| !matches!(p.state, State::Waiting) || p.at.elapsed() < PASSKEY_TTL);
|
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()
|
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() {
|
if self.core.is_locked() {
|
||||||
return Err("Unlock Kestrove first.".into());
|
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 v = &p.view;
|
||||||
let reply = match v.kind {
|
let reply = match v.kind {
|
||||||
PasskeyKind::Get => {
|
PasskeyKind::Get => {
|
||||||
@@ -373,10 +423,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn broker() -> (Arc<Broker>, Arc<Prompts>) {
|
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();
|
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());
|
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";
|
const ORIGIN: &str = "https://accounts.google.com";
|
||||||
@@ -490,6 +545,41 @@ mod tests {
|
|||||||
assert_eq!(result(&b, &id)["type"], "passkey_pending");
|
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]
|
#[test]
|
||||||
fn rp_id_must_be_the_origin_or_a_registrable_suffix() {
|
fn rp_id_must_be_the_origin_or_a_registrable_suffix() {
|
||||||
assert!(rp_id_allowed("https://accounts.google.com", "google.com"));
|
assert!(rp_id_allowed("https://accounts.google.com", "google.com"));
|
||||||
|
|||||||
Reference in New Issue
Block a user