From 8f0743d964a7cd1eeca8c5e16f0ae10dc6f3cacf Mon Sep 17 00:00:00 2001 From: samialabri Date: Sat, 24 Oct 2026 09:28:51 +0700 Subject: [PATCH] Passkeys for the browser extension, tags in item lists The browser bridge answers passkey_query/get/create/result/cancel: the page's RP ID is checked against the tab origin (no public suffixes), every request waits for the user to approve it in the app, and the extension polls for the result. Tags are normalised on save and included in item summaries. confirm_master_password verifies the user where there are no biometrics. Co-Authored-By: Claude Opus 5.5 --- crates/kv-ffi/src/bridge/broker.rs | 24 +- crates/kv-ffi/src/bridge/mod.rs | 63 ++++ crates/kv-ffi/src/bridge/passkey.rs | 507 ++++++++++++++++++++++++++++ crates/kv-ffi/src/lib.rs | 11 +- crates/kv-ffi/src/tests.rs | 22 ++ crates/kv-model/src/item.rs | 41 +++ crates/kv-model/src/lib.rs | 4 +- crates/kv-model/src/matching.rs | 6 + crates/kv-wasm/src/core.rs | 2 +- 9 files changed, 672 insertions(+), 8 deletions(-) create mode 100644 crates/kv-ffi/src/bridge/passkey.rs diff --git a/crates/kv-ffi/src/bridge/broker.rs b/crates/kv-ffi/src/bridge/broker.rs index 5c70f1a..a0f2641 100644 --- a/crates/kv-ffi/src/bridge/broker.rs +++ b/crates/kv-ffi/src/bridge/broker.rs @@ -41,6 +41,10 @@ const MAX_PENDING_SAVES: usize = 8; pub trait Ui: Send + Sync { fn pairing_requested(&self, req: &PairingView); fn save_requested(&self, req: &SaveView); + /// A page asked for a passkey: show it, verify the user, then call [`Broker::resolve_passkey`]. + fn passkey_requested(&self, req: &super::passkey::PasskeyView); + /// The page gave up on a passkey request (closed, navigated, aborted): hide it. + fn passkey_settled(&self, _id: &str) {} /// A user-driven request (a fill) arrived: counts as activity for auto-lock. fn activity(&self) {} } @@ -149,14 +153,15 @@ struct PendingSave { } pub struct Broker { - core: Arc, - ui: Arc, + pub(super) core: Arc, + pub(super) ui: Arc, pairings: PairingStore, pending_pairing: Mutex>, saves: Mutex>, + pub(super) passkeys: Mutex>, } -fn lock(m: &Mutex) -> MutexGuard<'_, T> { +pub(super) fn lock(m: &Mutex) -> MutexGuard<'_, T> { m.lock().unwrap_or_else(|e| e.into_inner()) } @@ -164,7 +169,7 @@ fn now_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } -fn err(code: &str) -> Value { +pub(super) fn err(code: &str) -> Value { json!({ "type": "error", "code": code }) } @@ -200,7 +205,14 @@ pub struct Conn { impl Broker { pub fn new(core: Arc, ui: Arc, pairings: PairingStore) -> Arc { - Arc::new(Self { core, ui, pairings, pending_pairing: Mutex::new(None), saves: Mutex::new(Vec::new()) }) + Arc::new(Self { + core, + ui, + pairings, + pending_pairing: Mutex::new(None), + saves: Mutex::new(Vec::new()), + passkeys: Mutex::new(Vec::new()), + }) } pub fn core(&self) -> &Arc { @@ -408,6 +420,7 @@ impl Broker { Err(v) => v, } } + k if k.starts_with("passkey_") => self.handle_passkey(k, origin, body), "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 { @@ -531,6 +544,7 @@ mod tests { impl Ui for AutoUi { fn pairing_requested(&self, _: &PairingView) {} fn save_requested(&self, _: &SaveView) {} + fn passkey_requested(&self, _: &super::super::passkey::PasskeyView) {} } fn broker_with_item() -> (Arc, String) { diff --git a/crates/kv-ffi/src/bridge/mod.rs b/crates/kv-ffi/src/bridge/mod.rs index 5a002f5..c2e99a1 100644 --- a/crates/kv-ffi/src/bridge/mod.rs +++ b/crates/kv-ffi/src/bridge/mod.rs @@ -7,6 +7,7 @@ pub mod broker; pub mod channel; +pub mod passkey; pub mod words; use std::path::PathBuf; @@ -41,6 +42,31 @@ pub struct BrowserSaveRequest { pub update: bool, } +/// One passkey the user can pick for a sign-in. +#[derive(Debug, Clone, uniffi::Record)] +pub struct BrowserPasskeyChoice { + /// base64url; pass it back to `resolve_passkey`. + pub credential_id: String, + pub user_name: String, + pub title: String, +} + +/// A page in the browser asks to sign in with a passkey (`create` false) or to save a new one (`create` true). +/// Show it, **verify the user** (Touch ID, Windows Hello or the master password), then `resolve_passkey`. +#[derive(Debug, Clone, uniffi::Record)] +pub struct BrowserPasskeyRequest { + pub id: String, + pub create: bool, + /// The page, e.g. `https://accounts.google.com`. + pub origin: String, + pub rp_id: String, + /// 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. + pub choices: Vec, +} + /// A paired browser, for the settings list. #[derive(Debug, Clone, uniffi::Record)] pub struct BrowserPairing { @@ -57,6 +83,9 @@ pub struct BrowserPairing { pub trait BrowserBridgeUi: Send + Sync { fn pairing_requested(&self, request: BrowserPairingRequest); fn save_requested(&self, request: BrowserSaveRequest); + fn passkey_requested(&self, request: BrowserPasskeyRequest); + /// The page stopped waiting for that passkey request (closed, navigated away, timed out): hide it. + fn passkey_settled(&self, id: String); /// A fill or query arrived: counts as activity for auto-lock. fn activity(&self); } @@ -70,11 +99,33 @@ impl Ui for UiAdapter { fn save_requested(&self, req: &SaveView) { self.0.save_requested(save_request(req.clone())); } + fn passkey_requested(&self, req: &passkey::PasskeyView) { + self.0.passkey_requested(passkey_request(req.clone())); + } + fn passkey_settled(&self, id: &str) { + self.0.passkey_settled(id.to_string()); + } fn activity(&self) { self.0.activity(); } } +pub fn passkey_request(v: passkey::PasskeyView) -> BrowserPasskeyRequest { + BrowserPasskeyRequest { + id: v.id, + create: v.kind == passkey::PasskeyKind::Create, + origin: v.origin, + rp_id: v.rp_id, + rp_name: v.rp_name, + user_name: v.user_name, + choices: v + .choices + .into_iter() + .map(|c| BrowserPasskeyChoice { credential_id: c.credential_id, user_name: c.user_name, title: c.title }) + .collect(), + } +} + fn pairing_request(v: PairingView) -> BrowserPairingRequest { BrowserPairingRequest { id: v.id, name: v.name, caller: v.caller, words: v.words } } @@ -172,6 +223,16 @@ impl BrowserBridge { pub fn resolve_save(&self, id: String, accept: bool) -> Result, CoreError> { self.broker.resolve_save(&id, accept).map_err(invalid) } + + pub fn pending_passkeys(&self) -> Vec { + self.broker.pending_passkeys().into_iter().map(passkey_request).collect() + } + + /// Answers a passkey request. **Verify the user right before accepting** (docs/05 "User verification"). + /// `credential_id` picks the passkey for a sign-in (`None`: the first choice). + pub fn resolve_passkey(&self, id: String, accept: bool, credential_id: Option) -> Result<(), CoreError> { + self.broker.resolve_passkey(&id, accept, credential_id.as_deref()).map_err(invalid) + } } impl Drop for BrowserBridge { @@ -190,6 +251,8 @@ mod tests { impl BrowserBridgeUi for NoUi { fn pairing_requested(&self, _: BrowserPairingRequest) {} fn save_requested(&self, _: BrowserSaveRequest) {} + fn passkey_requested(&self, _: BrowserPasskeyRequest) {} + fn passkey_settled(&self, _: String) {} fn activity(&self) {} } diff --git a/crates/kv-ffi/src/bridge/passkey.rs b/crates/kv-ffi/src/bridge/passkey.rs new file mode 100644 index 0000000..6fa2001 --- /dev/null +++ b/crates/kv-ffi/src/bridge/passkey.rs @@ -0,0 +1,507 @@ +//! Passkeys for the browser extension (kestrove-spec/docs/05 "Passkeys in the browser"). +//! +//! The extension answers a page's `navigator.credentials.create/get` with Kestrove's passkeys. It builds +//! `clientDataJSON` itself from the tab's real origin and sends only its hash. Every request needs the +//! user to approve it in the app, which verifies the user (Touch ID, Windows Hello or the master password) +//! right before it calls [`Broker::resolve_passkey`]. The extension polls `passkey_result` meanwhile, so +//! the connection stays strictly request → response and fills keep working. +//! +//! | extension → app | app → extension | +//! |---|---| +//! | `{type:"passkey_query", origin, rpId, allowCredentials}` | `{type:"passkeys", items:[{credentialId, userName, title}]}` | +//! | `{type:"passkey_get", origin, rpId, clientDataHash, allowCredentials, credentialId?}` | `{type:"passkey_prompted", requestId}` / error `not_found` | +//! | `{type:"passkey_create", origin, rpId, rpName, userHandle, userName, clientDataHash, excludeCredentials, algorithms}` | `{type:"passkey_prompted", requestId}` / error `excluded`, `unsupported` | +//! | `{type:"passkey_result", requestId}` | `{type:"passkey_pending"}` / `passkey_assertion` / `passkey_registration` / error `cancelled` | +//! | `{type:"passkey_cancel", requestId}` | `{type:"passkey_cancelled"}` | +//! +//! Binary fields are base64url without padding, as in WebAuthn's JSON forms. + +use std::time::{Duration, Instant}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL; +use base64::Engine; +use serde::Serialize; +use serde_json::{json, Value}; + +use super::broker::{err, lock, Broker}; +use crate::CoreError; + +/// How long a passkey request waits for the user (WebAuthn's own default timeout is 5 minutes). +const PASSKEY_TTL: Duration = Duration::from_secs(180); +/// How long a finished result waits to be collected. +const RESULT_TTL: Duration = Duration::from_secs(60); +const MAX_PENDING: usize = 4; +/// COSE ES256, the only algorithm Kestrove's passkeys use (docs/05). +const ES256: i64 = -7; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PasskeyKind { + /// Sign in with an existing passkey. + Get, + /// Save a new passkey. + Create, +} + +/// One passkey the user can pick when signing in. +#[derive(Debug, Clone, Serialize)] +pub struct PasskeyChoice { + /// base64url. + pub credential_id: String, + pub user_name: String, + /// The login item's title. + pub title: String, +} + +/// What the app shows for a passkey request. +#[derive(Debug, Clone, Serialize)] +pub struct PasskeyView { + pub id: String, + pub kind: PasskeyKind, + /// The page's origin, e.g. `https://accounts.google.com`. + pub origin: String, + pub rp_id: String, + /// Create only: the site's display name and the account's user name. + 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). + pub choices: Vec, +} + +pub(super) struct PendingPasskey { + view: PasskeyView, + client_data_hash: Vec, + user_handle: Vec, + at: Instant, + state: State, +} + +enum State { + Waiting, + Done(Value, Instant), +} + +fn b64(v: &[u8]) -> String { + B64URL.encode(v) +} + +fn unb64(v: Option<&Value>) -> Option> { + B64URL.decode(v?.as_str()?.trim_end_matches('=')).ok() +} + +fn ids(v: Option<&Value>) -> Option>> { + match v { + None | Some(Value::Null) => Some(vec![]), + Some(Value::Array(a)) if a.len() <= 64 => a.iter().map(|x| unb64(Some(x))).collect(), + _ => None, + } +} + +fn text(v: Option<&Value>, max: usize) -> String { + v.and_then(Value::as_str).unwrap_or("").chars().take(max).collect() +} + +/// WebAuthn §5.1.4.1 / §5.1.3 step 8: `rp_id` must be the origin's host or a registrable suffix of it, +/// on a secure origin (https, or http on localhost). Public suffixes (`com`, `co.uk`, `github.io`) never are. +pub fn rp_id_allowed(origin: &str, rp_id: &str) -> bool { + let Ok(u) = url::Url::parse(origin) else { return false }; + let Some(host) = u.host_str().map(str::to_ascii_lowercase) else { return false }; + let localhost = host == "localhost" || host.ends_with(".localhost"); + if !(u.scheme() == "https" || (u.scheme() == "http" && localhost)) { + return false; + } + if kv_model::passkey::validate_rp_id(rp_id).is_err() { + return false; + } + if host != rp_id && !host.ends_with(&format!(".{rp_id}")) { + return false; + } + localhost || kv_model::registrable_domain(rp_id).is_some() +} + +impl Broker { + /// Handles the `passkey_*` requests. `origin` is already normalized. + pub(super) fn handle_passkey(&self, kind: &str, origin: String, body: &Value) -> Value { + let rp_id = text(body.get("rpId"), 253).to_ascii_lowercase(); + let request_id = || body.get("requestId").and_then(Value::as_str).unwrap_or("").to_string(); + match kind { + "passkey_result" => return self.passkey_result(&request_id(), &origin), + "passkey_cancel" => { + lock(&self.passkeys).retain(|p| !(p.view.id == request_id() && p.view.origin == origin)); + self.ui.passkey_settled(&request_id()); + return json!({ "type": "passkey_cancelled" }); + } + _ => {} + } + if !rp_id_allowed(&origin, &rp_id) { + return err("origin_mismatch"); + } + match kind { + "passkey_query" => { + let Some(allow) = ids(body.get("allowCredentials")) else { return err("bad_request") }; + match self.choices(&rp_id, allow) { + Ok(c) => json!({ + "type": "passkeys", + "items": c.iter().map(|c| json!({ + "credentialId": c.credential_id, "userName": c.user_name, "title": c.title, + })).collect::>(), + }), + Err(e) => passkey_err(&e), + } + } + "passkey_get" => { + let (Some(hash), Some(mut allow)) = + (unb64(body.get("clientDataHash")), ids(body.get("allowCredentials"))) + else { + return err("bad_request"); + }; + if hash.len() != 32 { + return err("bad_request"); + } + // The user already picked one in the page (conditional UI): only that one, if the site allows it. + if let Some(chosen) = unb64(body.get("credentialId")) { + if !allow.is_empty() && !allow.contains(&chosen) { + return err("not_found"); + } + allow = vec![chosen]; + } + let choices = match self.choices(&rp_id, allow) { + Ok(c) if c.is_empty() => return err("not_found"), + Ok(c) => c, + Err(e) => return passkey_err(&e), + }; + let view = PasskeyView { + id: uuid::Uuid::new_v4().to_string(), + kind: PasskeyKind::Get, + origin, + rp_id, + rp_name: String::new(), + user_name: String::new(), + choices, + }; + self.queue_passkey(view, hash, vec![]) + } + "passkey_create" => { + let (Some(hash), Some(user_handle), Some(exclude)) = ( + unb64(body.get("clientDataHash")), + unb64(body.get("userHandle")), + ids(body.get("excludeCredentials")), + ) else { + return err("bad_request"); + }; + if hash.len() != 32 || user_handle.is_empty() || user_handle.len() > 64 { + return err("bad_request"); + } + let algs = body.get("algorithms").and_then(Value::as_array); + 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 view = PasskeyView { + id: uuid::Uuid::new_v4().to_string(), + kind: PasskeyKind::Create, + origin, + rp_name: text(body.get("rpName"), 120), + user_name: text(body.get("userName"), 256), + rp_id, + choices: vec![], + }; + self.queue_passkey(view, hash, user_handle) + } + _ => err("unknown_type"), + } + } + + fn choices(&self, rp_id: &str, allow: Vec>) -> Result, CoreError> { + let found = self.core.passkeys_for_rp(rp_id.to_string(), allow)?; + Ok(found + .into_iter() + .map(|p| PasskeyChoice { + title: self.core.get_item(p.item_id.clone()).map(|i| i.title).unwrap_or_else(|_| p.rp_id.clone()), + credential_id: b64(&p.credential_id), + user_name: p.user_name, + }) + .collect()) + } + + fn queue_passkey(&self, view: PasskeyView, client_data_hash: Vec, user_handle: Vec) -> Value { + self.ui.activity(); + let id = view.id.clone(); + { + let mut q = lock(&self.passkeys); + q.retain(|p| p.at.elapsed() < PASSKEY_TTL && p.view.origin != view.origin); + if q.len() >= MAX_PENDING { + q.remove(0); + } + q.push(PendingPasskey { + view: view.clone(), + client_data_hash, + user_handle, + at: Instant::now(), + state: State::Waiting, + }); + } + self.ui.passkey_requested(&view); + json!({ "type": "passkey_prompted", "requestId": id }) + } + + fn passkey_result(&self, id: &str, origin: &str) -> Value { + let mut q = lock(&self.passkeys); + q.retain(|p| match &p.state { + State::Waiting => p.at.elapsed() < PASSKEY_TTL, + State::Done(_, at) => at.elapsed() < RESULT_TTL, + }); + let Some(pos) = q.iter().position(|p| p.view.id == id && p.view.origin == origin) else { + return err("cancelled"); + }; + match &q[pos].state { + State::Waiting => json!({ "type": "passkey_pending" }), + State::Done(..) => match q.remove(pos).state { + State::Done(v, _) => v, + State::Waiting => unreachable!(), + }, + } + } + + /// Passkey requests waiting for the user, oldest first. + 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); + q.iter().filter(|p| matches!(p.state, State::Waiting)).map(|p| p.view.clone()).collect() + } + + /// The user answered a passkey request in the app. **The app must have verified the user** (biometrics, + /// device password or master password) right before calling this with `accept`. `credential_id` + /// (base64url) is the passkey picked for a sign-in; `None` takes the first choice. + pub fn resolve_passkey(&self, id: &str, accept: bool, credential_id: Option<&str>) -> Result<(), String> { + let mut q = lock(&self.passkeys); + let Some(p) = q.iter_mut().find(|p| p.view.id == id && matches!(p.state, State::Waiting)) else { + return Err("This passkey request expired.".into()); + }; + if p.at.elapsed() >= PASSKEY_TTL { + return Err("This passkey request expired.".into()); + } + if !accept { + p.state = State::Done(err("cancelled"), Instant::now()); + return Ok(()); + } + if self.core.is_locked() { + return Err("Unlock Kestrove first.".into()); + } + let v = &p.view; + let reply = match v.kind { + PasskeyKind::Get => { + let chosen = match credential_id { + Some(c) => v.choices.iter().find(|x| x.credential_id == c), + None => v.choices.first(), + }; + let Some(raw) = chosen.and_then(|c| B64URL.decode(&c.credential_id).ok()) else { + return Err("Pick one of the passkeys shown.".into()); + }; + match self.core.get_assertion(raw, v.rp_id.clone(), p.client_data_hash.clone()) { + Ok(a) => json!({ + "type": "passkey_assertion", + "credentialId": b64(&a.credential_id), + "authenticatorData": b64(&a.authenticator_data), + "signature": b64(&a.signature), + "userHandle": b64(&a.user_handle), + }), + Err(e) => return Err(e.to_string()), + } + } + PasskeyKind::Create => { + match self.core.create_passkey( + v.rp_id.clone(), + p.user_handle.clone(), + v.user_name.clone(), + p.client_data_hash.clone(), + None, + ) { + Ok(r) => json!({ + "type": "passkey_registration", + "credentialId": b64(&r.credential_id), + "attestationObject": b64(&r.attestation_object), + "authenticatorData": b64(&r.authenticator_data), + "publicKey": b64(&r.public_key_spki), + "publicKeyAlgorithm": ES256, + }), + Err(e) => return Err(e.to_string()), + } + } + }; + p.state = State::Done(reply, Instant::now()); + Ok(()) + } +} + +fn passkey_err(e: &CoreError) -> Value { + match e { + CoreError::Locked | CoreError::NoAccount => err("locked"), + CoreError::NotFound => err("not_found"), + CoreError::Invalid { .. } => err("bad_request"), + _ => err("internal"), + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; + use p256::pkcs8::DecodePublicKey; + use sha2::{Digest, Sha256}; + + use super::super::broker::{PairingStore, PairingView, SaveView, Ui}; + use super::*; + use crate::VaultCore; + + #[derive(Default)] + struct Prompts(Mutex>, Mutex>); + impl Ui for Prompts { + fn pairing_requested(&self, _: &PairingView) {} + fn save_requested(&self, _: &SaveView) {} + fn passkey_requested(&self, v: &PasskeyView) { + self.0.lock().unwrap().push(v.clone()); + } + fn passkey_settled(&self, id: &str) { + self.1.lock().unwrap().push(id.to_string()); + } + } + + fn broker() -> (Arc, Arc) { + let core = VaultCore::new_in_memory(); + core.create_account("correct horse battery".into()).unwrap(); + let ui = Arc::new(Prompts::default()); + (Broker::new(core, ui.clone(), PairingStore::in_memory()), ui) + } + + const ORIGIN: &str = "https://accounts.google.com"; + + fn hash(tag: &str) -> String { + b64(&Sha256::digest(tag.as_bytes())) + } + + fn create(b: &Broker, user: &str) -> Value { + b.handle_request(&json!({ + "type": "passkey_create", "origin": ORIGIN, "rpId": "google.com", "rpName": "Google", + "userHandle": b64(user.as_bytes()), "userName": user, "clientDataHash": hash("create"), + "algorithms": [-8, -7, -257], + })) + } + + fn result(b: &Broker, id: &str) -> Value { + b.handle_request(&json!({ "type": "passkey_result", "origin": ORIGIN, "requestId": id })) + } + + #[test] + fn create_then_sign_in_after_approval() { + let (b, ui) = broker(); + let r = create(&b, "sami@gmail.com"); + assert_eq!(r["type"], "passkey_prompted", "{r}"); + let id = r["requestId"].as_str().unwrap().to_string(); + let shown = ui.0.lock().unwrap().last().cloned().unwrap(); + assert_eq!( + (shown.kind, shown.rp_id.as_str(), shown.user_name.as_str()), + (PasskeyKind::Create, "google.com", "sami@gmail.com") + ); + assert_eq!(result(&b, &id)["type"], "passkey_pending"); + // Another site can't collect it. + let other = + b.handle_request(&json!({ "type": "passkey_result", "origin": "https://evil.test", "requestId": id })); + assert_eq!(other["code"], "cancelled"); + + b.resolve_passkey(&id, true, None).unwrap(); + let reg = result(&b, &id); + assert_eq!(reg["type"], "passkey_registration", "{reg}"); + assert_eq!(reg["publicKeyAlgorithm"], -7); + let vk = VerifyingKey::from_public_key_der(&unb64(reg.get("publicKey")).unwrap()).unwrap(); + let auth = unb64(reg.get("authenticatorData")).unwrap(); + assert_eq!(&auth[..32], Sha256::digest(b"google.com").as_slice()); + assert_eq!(result(&b, &id)["code"], "cancelled", "a result is handed out once"); + + // The same account again: excluded. + let again = b.handle_request(&json!({ + "type": "passkey_create", "origin": ORIGIN, "rpId": "google.com", "userHandle": b64(b"x"), + "userName": "x", "clientDataHash": hash("c2"), "algorithms": [-7], "excludeCredentials": [reg["credentialId"]], + })); + assert_eq!(again["code"], "excluded"); + + let q = b.handle_request(&json!({ "type": "passkey_query", "origin": ORIGIN, "rpId": "google.com" })); + assert_eq!(q["items"][0]["userName"], "sami@gmail.com"); + assert_eq!(q["items"][0]["title"], "google.com"); + + let r = b.handle_request(&json!({ + "type": "passkey_get", "origin": ORIGIN, "rpId": "google.com", "clientDataHash": hash("get"), + })); + let id = r["requestId"].as_str().unwrap().to_string(); + assert_eq!(ui.0.lock().unwrap().last().unwrap().choices.len(), 1); + b.resolve_passkey(&id, true, Some(reg["credentialId"].as_str().unwrap())).unwrap(); + let a = result(&b, &id); + assert_eq!(a["type"], "passkey_assertion", "{a}"); + assert_eq!(a["userHandle"], b64(b"sami@gmail.com")); + let mut msg = unb64(a.get("authenticatorData")).unwrap(); + msg.extend_from_slice(&Sha256::digest(b"get")); + vk.verify(&msg, &Signature::from_der(&unb64(a.get("signature")).unwrap()).unwrap()).unwrap(); + } + + #[test] + fn refusals() { + let (b, ui) = broker(); + // Nothing saved for the site: the browser's own passkey UI takes over. + let r = b.handle_request(&json!({ + "type": "passkey_get", "origin": ORIGIN, "rpId": "google.com", "clientDataHash": hash("g"), + })); + assert_eq!(r["code"], "not_found"); + // A page can't use another site's RP ID, or a public suffix. + for rp in ["evil.test", "com", "accounts.google.com.evil"] { + let r = b.handle_request(&json!({ "type": "passkey_query", "origin": ORIGIN, "rpId": rp })); + assert_eq!(r["code"], "origin_mismatch", "{rp}"); + } + // Only ES256. + let r = b.handle_request(&json!({ + "type": "passkey_create", "origin": ORIGIN, "rpId": "google.com", "userHandle": b64(b"u"), + "userName": "u", "clientDataHash": hash("c"), "algorithms": [-257], + })); + assert_eq!(r["code"], "unsupported"); + let r = b.handle_request(&json!({ + "type": "passkey_create", "origin": ORIGIN, "rpId": "google.com", "userHandle": b64(b"u"), + "userName": "u", "clientDataHash": b64(b"short"), "algorithms": [-7], + })); + assert_eq!(r["code"], "bad_request"); + + // Declined in the app, or cancelled by the page. + let id = create(&b, "a")["requestId"].as_str().unwrap().to_string(); + b.resolve_passkey(&id, false, None).unwrap(); + assert_eq!(result(&b, &id)["code"], "cancelled"); + let id = create(&b, "b")["requestId"].as_str().unwrap().to_string(); + b.handle_request(&json!({ "type": "passkey_cancel", "origin": ORIGIN, "requestId": id })); + assert_eq!(ui.1.lock().unwrap().last().unwrap(), &id); + assert!(b.resolve_passkey(&id, true, None).is_err()); + assert!(b.pending_passkeys().is_empty()); + + // Locked: approval waits for unlock. + let id = create(&b, "c")["requestId"].as_str().unwrap().to_string(); + b.core().lock(); + assert!(b.resolve_passkey(&id, true, None).is_err()); + assert_eq!(result(&b, &id)["type"], "passkey_pending"); + } + + #[test] + 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", "accounts.google.com")); + assert!(rp_id_allowed("https://github.com", "github.com")); + assert!(rp_id_allowed("http://localhost:8080", "localhost")); + assert!(!rp_id_allowed("https://accounts.google.com", "com")); + assert!(!rp_id_allowed("https://evil.github.io", "github.io")); + assert!(!rp_id_allowed("https://google.com.evil.test", "google.com")); + assert!(!rp_id_allowed("https://notgoogle.com", "google.com")); + assert!(!rp_id_allowed("http://google.com", "google.com")); + assert!(!rp_id_allowed("https://google.com", "accounts.google.com")); + assert!(!rp_id_allowed("https://google.com", "Google.com")); + } +} diff --git a/crates/kv-ffi/src/lib.rs b/crates/kv-ffi/src/lib.rs index 0299feb..837a630 100644 --- a/crates/kv-ffi/src/lib.rs +++ b/crates/kv-ffi/src/lib.rs @@ -174,6 +174,7 @@ pub struct ItemSummary { pub urls: Vec, pub favorite: bool, pub has_totp: bool, + pub tags: Vec, } #[derive(Debug, Clone, uniffi::Record)] @@ -505,6 +506,13 @@ impl VaultCore { self.lock_inner().unlocked.is_none() } + /// User verification with the master password where the device has no biometrics (docs/05 "User + /// verification"): `BadCredentials` unless `password` opens this account. Needs the vault unlocked. + pub fn confirm_master_password(&self, password: String) -> Result<()> { + let password = Zeroizing::new(password); + self.verify_master_password(&password) + } + pub fn has_account(&self) -> bool { self.lock_inner().account.is_some() } @@ -628,7 +636,7 @@ impl VaultCore { model.totp = item.totp; model.notes = item.notes; model.favorite = item.favorite; - model.tags = item.tags; + model.tags = kv_model::normalize_tags(&item.tags); model.modified_at = now.max(previous.as_ref().map_or(0, |p| p.modified_at + 1)); model.seq = kv_sync::engine::next_local_version(self.store.as_ref(), acct.account_id, vault, &u.vault_key, id)?; @@ -1312,6 +1320,7 @@ fn summary(i: &Item) -> ItemSummary { urls: i.urls.clone(), favorite: i.favorite, has_totp: i.totp.is_some(), + tags: i.tags.clone(), } } diff --git a/crates/kv-ffi/src/tests.rs b/crates/kv-ffi/src/tests.rs index 2071f77..ed4d236 100644 --- a/crates/kv-ffi/src/tests.rs +++ b/crates/kv-ffi/src/tests.rs @@ -694,3 +694,25 @@ async fn sign_out_device_keeps_the_account_on_the_server() { a.sync_now().await.unwrap(); assert_eq!(a.get_item(id).unwrap().password.as_deref(), Some("pw-a")); } + +#[test] +fn tags_are_normalised_and_listed() { + let core = VaultCore::new_in_memory(); + core.create_account("correct horse battery".into()).unwrap(); + let mut item = login_item("Bank", "pw"); + item.tags = vec![" #Finance ".into(), "finance".into(), "family trip".into(), "".into()]; + let id = core.save_item(item).unwrap(); + assert_eq!(core.get_item(id.clone()).unwrap().tags, ["Finance", "family trip"]); + let s = core.list_items().unwrap(); + assert_eq!(s.iter().find(|i| i.id == id).unwrap().tags, ["Finance", "family trip"]); +} + +#[test] +fn master_password_confirmation() { + let core = VaultCore::new_in_memory(); + core.create_account("correct horse battery".into()).unwrap(); + core.confirm_master_password("correct horse battery".into()).unwrap(); + assert!(matches!(core.confirm_master_password("wrong".into()), Err(CoreError::BadCredentials))); + core.lock(); + assert!(matches!(core.confirm_master_password("correct horse battery".into()), Err(CoreError::Locked))); +} diff --git a/crates/kv-model/src/item.rs b/crates/kv-model/src/item.rs index f0b27d6..0eed6f7 100644 --- a/crates/kv-model/src/item.rs +++ b/crates/kv-model/src/item.rs @@ -80,6 +80,30 @@ impl Item { } } +/// Longest tag kept, in characters. +pub const MAX_TAG_LEN: usize = 64; +/// Most tags kept on one item. +pub const MAX_TAGS: usize = 32; + +/// The tags as every app stores them: trimmed, without a leading `#`, inner whitespace collapsed, +/// at most [`MAX_TAG_LEN`] characters, case-insensitive duplicates dropped (first spelling wins), +/// in the given order, at most [`MAX_TAGS`]. +pub fn normalize_tags>(tags: &[S]) -> Vec { + let mut out: Vec = Vec::new(); + for t in tags { + let t = t.as_ref().trim().trim_start_matches('#'); + let t: String = t.split_whitespace().collect::>().join(" ").chars().take(MAX_TAG_LEN).collect(); + if t.is_empty() || out.iter().any(|o| o.to_lowercase() == t.to_lowercase()) { + continue; + } + out.push(t); + if out.len() == MAX_TAGS { + break; + } + } + out +} + impl Drop for Item { fn drop(&mut self) { self.password.zeroize(); @@ -88,3 +112,20 @@ impl Drop for Item { self.passkeys.zeroize(); } } + +#[cfg(test)] +mod tag_tests { + use super::*; + + #[test] + fn tags_are_normalised() { + assert_eq!( + normalize_tags(&[" #Work ", "work", "", " family trip ", "#", "Bank"]), + ["Work", "family trip", "Bank"] + ); + let long = "x".repeat(100); + assert_eq!(normalize_tags(&[long.as_str()])[0].chars().count(), MAX_TAG_LEN); + let many: Vec = (0..50).map(|i| format!("t{i}")).collect(); + assert_eq!(normalize_tags(&many).len(), MAX_TAGS); + } +} diff --git a/crates/kv-model/src/lib.rs b/crates/kv-model/src/lib.rs index 4141bb0..0f15742 100644 --- a/crates/kv-model/src/lib.rs +++ b/crates/kv-model/src/lib.rs @@ -7,5 +7,7 @@ pub mod matching; pub mod passkey; pub mod totp; -pub use item::{Item, ItemKind, Passkey}; +pub use item::{normalize_tags, Item, ItemKind, Passkey, MAX_TAGS, MAX_TAG_LEN}; +#[cfg(feature = "psl")] +pub use matching::registrable_domain; pub use matching::{match_url, MatchLevel}; diff --git a/crates/kv-model/src/matching.rs b/crates/kv-model/src/matching.rs index 8ae672f..d9cff6b 100644 --- a/crates/kv-model/src/matching.rs +++ b/crates/kv-model/src/matching.rs @@ -37,6 +37,12 @@ pub fn match_url(item_url: &str, page_url: &str) -> MatchLevel { same_registrable_domain(&ih, &ph) } +/// The registrable domain (eTLD+1) of `host`, or `None` for a public suffix such as `com` or `github.io`. +#[cfg(feature = "psl")] +pub fn registrable_domain(host: &str) -> Option<&str> { + psl::domain_str(host) +} + #[cfg(feature = "psl")] fn same_registrable_domain(a: &str, b: &str) -> MatchLevel { match (psl::domain_str(a), psl::domain_str(b)) { diff --git a/crates/kv-wasm/src/core.rs b/crates/kv-wasm/src/core.rs index 1cf20be..8fde6cc 100644 --- a/crates/kv-wasm/src/core.rs +++ b/crates/kv-wasm/src/core.rs @@ -296,7 +296,7 @@ impl WebCore { model.totp = item.totp; model.notes = item.notes; model.favorite = item.favorite; - model.tags = item.tags; + model.tags = kv_model::normalize_tags(&item.tags); model.modified_at = now.max(previous.as_ref().map_or(0, |p| p.modified_at + 1)); self.write_item(&acct, &u, &mut model, existing.filter(|_| previous.is_some()), false)?; Ok(id.to_string())