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())