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 <[email protected]>
This commit is contained in:
2026-10-24 09:28:51 +07:00
co-authored by Claude Opus 5.5
parent d9756d34c1
commit 8f0743d964
9 changed files with 672 additions and 8 deletions
+19 -5
View File
@@ -41,6 +41,10 @@ const MAX_PENDING_SAVES: usize = 8;
pub trait Ui: Send + Sync { pub trait Ui: Send + Sync {
fn pairing_requested(&self, req: &PairingView); fn pairing_requested(&self, req: &PairingView);
fn save_requested(&self, req: &SaveView); 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. /// A user-driven request (a fill) arrived: counts as activity for auto-lock.
fn activity(&self) {} fn activity(&self) {}
} }
@@ -149,14 +153,15 @@ struct PendingSave {
} }
pub struct Broker { pub struct Broker {
core: Arc<VaultCore>, pub(super) core: Arc<VaultCore>,
ui: Arc<dyn Ui>, pub(super) ui: Arc<dyn Ui>,
pairings: PairingStore, pairings: PairingStore,
pending_pairing: Mutex<Option<PendingPairing>>, pending_pairing: Mutex<Option<PendingPairing>>,
saves: Mutex<Vec<PendingSave>>, saves: Mutex<Vec<PendingSave>>,
pub(super) passkeys: Mutex<Vec<super::passkey::PendingPasskey>>,
} }
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> { pub(super) fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner()) 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) 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 }) json!({ "type": "error", "code": code })
} }
@@ -200,7 +205,14 @@ pub struct Conn {
impl Broker { impl Broker {
pub fn new(core: Arc<VaultCore>, ui: Arc<dyn Ui>, pairings: PairingStore) -> Arc<Self> { pub fn new(core: Arc<VaultCore>, ui: Arc<dyn Ui>, pairings: PairingStore) -> Arc<Self> {
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<VaultCore> { pub fn core(&self) -> &Arc<VaultCore> {
@@ -408,6 +420,7 @@ impl Broker {
Err(v) => v, Err(v) => v,
} }
} }
k if k.starts_with("passkey_") => self.handle_passkey(k, origin, body),
"save" => { "save" => {
let username = body.get("username").and_then(Value::as_str).unwrap_or(""); 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 { 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 { impl Ui for AutoUi {
fn pairing_requested(&self, _: &PairingView) {} fn pairing_requested(&self, _: &PairingView) {}
fn save_requested(&self, _: &SaveView) {} fn save_requested(&self, _: &SaveView) {}
fn passkey_requested(&self, _: &super::super::passkey::PasskeyView) {}
} }
fn broker_with_item() -> (Arc<Broker>, String) { fn broker_with_item() -> (Arc<Broker>, String) {
+63
View File
@@ -7,6 +7,7 @@
pub mod broker; pub mod broker;
pub mod channel; pub mod channel;
pub mod passkey;
pub mod words; pub mod words;
use std::path::PathBuf; use std::path::PathBuf;
@@ -41,6 +42,31 @@ pub struct BrowserSaveRequest {
pub update: bool, 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<BrowserPasskeyChoice>,
}
/// A paired browser, for the settings list. /// A paired browser, for the settings list.
#[derive(Debug, Clone, uniffi::Record)] #[derive(Debug, Clone, uniffi::Record)]
pub struct BrowserPairing { pub struct BrowserPairing {
@@ -57,6 +83,9 @@ pub struct BrowserPairing {
pub trait BrowserBridgeUi: Send + Sync { pub trait BrowserBridgeUi: Send + Sync {
fn pairing_requested(&self, request: BrowserPairingRequest); fn pairing_requested(&self, request: BrowserPairingRequest);
fn save_requested(&self, request: BrowserSaveRequest); 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. /// A fill or query arrived: counts as activity for auto-lock.
fn activity(&self); fn activity(&self);
} }
@@ -70,11 +99,33 @@ impl Ui for UiAdapter {
fn save_requested(&self, req: &SaveView) { fn save_requested(&self, req: &SaveView) {
self.0.save_requested(save_request(req.clone())); 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) { fn activity(&self) {
self.0.activity(); 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 { fn pairing_request(v: PairingView) -> BrowserPairingRequest {
BrowserPairingRequest { id: v.id, name: v.name, caller: v.caller, words: v.words } 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<Option<String>, CoreError> { pub fn resolve_save(&self, id: String, accept: bool) -> Result<Option<String>, CoreError> {
self.broker.resolve_save(&id, accept).map_err(invalid) self.broker.resolve_save(&id, accept).map_err(invalid)
} }
pub fn pending_passkeys(&self) -> Vec<BrowserPasskeyRequest> {
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<String>) -> Result<(), CoreError> {
self.broker.resolve_passkey(&id, accept, credential_id.as_deref()).map_err(invalid)
}
} }
impl Drop for BrowserBridge { impl Drop for BrowserBridge {
@@ -190,6 +251,8 @@ mod tests {
impl BrowserBridgeUi for NoUi { impl BrowserBridgeUi for NoUi {
fn pairing_requested(&self, _: BrowserPairingRequest) {} fn pairing_requested(&self, _: BrowserPairingRequest) {}
fn save_requested(&self, _: BrowserSaveRequest) {} fn save_requested(&self, _: BrowserSaveRequest) {}
fn passkey_requested(&self, _: BrowserPasskeyRequest) {}
fn passkey_settled(&self, _: String) {}
fn activity(&self) {} fn activity(&self) {}
} }
+507
View File
@@ -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<PasskeyChoice>,
}
pub(super) struct PendingPasskey {
view: PasskeyView,
client_data_hash: Vec<u8>,
user_handle: Vec<u8>,
at: Instant,
state: State,
}
enum State {
Waiting,
Done(Value, Instant),
}
fn b64(v: &[u8]) -> String {
B64URL.encode(v)
}
fn unb64(v: Option<&Value>) -> Option<Vec<u8>> {
B64URL.decode(v?.as_str()?.trim_end_matches('=')).ok()
}
fn ids(v: Option<&Value>) -> Option<Vec<Vec<u8>>> {
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::<Vec<_>>(),
}),
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<Vec<u8>>) -> Result<Vec<PasskeyChoice>, 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<u8>, user_handle: Vec<u8>) -> 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<PasskeyView> {
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<Vec<PasskeyView>>, Mutex<Vec<String>>);
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<Broker>, Arc<Prompts>) {
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, "[email protected]");
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", "[email protected]")
);
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"], "[email protected]");
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"[email protected]"));
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"));
}
}
+10 -1
View File
@@ -174,6 +174,7 @@ pub struct ItemSummary {
pub urls: Vec<String>, pub urls: Vec<String>,
pub favorite: bool, pub favorite: bool,
pub has_totp: bool, pub has_totp: bool,
pub tags: Vec<String>,
} }
#[derive(Debug, Clone, uniffi::Record)] #[derive(Debug, Clone, uniffi::Record)]
@@ -505,6 +506,13 @@ impl VaultCore {
self.lock_inner().unlocked.is_none() 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 { pub fn has_account(&self) -> bool {
self.lock_inner().account.is_some() self.lock_inner().account.is_some()
} }
@@ -628,7 +636,7 @@ impl VaultCore {
model.totp = item.totp; model.totp = item.totp;
model.notes = item.notes; model.notes = item.notes;
model.favorite = item.favorite; 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.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)?; 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(), urls: i.urls.clone(),
favorite: i.favorite, favorite: i.favorite,
has_totp: i.totp.is_some(), has_totp: i.totp.is_some(),
tags: i.tags.clone(),
} }
} }
+22
View File
@@ -694,3 +694,25 @@ async fn sign_out_device_keeps_the_account_on_the_server() {
a.sync_now().await.unwrap(); a.sync_now().await.unwrap();
assert_eq!(a.get_item(id).unwrap().password.as_deref(), Some("pw-a")); 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)));
}
+41
View File
@@ -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<S: AsRef<str>>(tags: &[S]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for t in tags {
let t = t.as_ref().trim().trim_start_matches('#');
let t: String = t.split_whitespace().collect::<Vec<_>>().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 { impl Drop for Item {
fn drop(&mut self) { fn drop(&mut self) {
self.password.zeroize(); self.password.zeroize();
@@ -88,3 +112,20 @@ impl Drop for Item {
self.passkeys.zeroize(); 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<String> = (0..50).map(|i| format!("t{i}")).collect();
assert_eq!(normalize_tags(&many).len(), MAX_TAGS);
}
}
+3 -1
View File
@@ -7,5 +7,7 @@ pub mod matching;
pub mod passkey; pub mod passkey;
pub mod totp; 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}; pub use matching::{match_url, MatchLevel};
+6
View File
@@ -37,6 +37,12 @@ pub fn match_url(item_url: &str, page_url: &str) -> MatchLevel {
same_registrable_domain(&ih, &ph) 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")] #[cfg(feature = "psl")]
fn same_registrable_domain(a: &str, b: &str) -> MatchLevel { fn same_registrable_domain(a: &str, b: &str) -> MatchLevel {
match (psl::domain_str(a), psl::domain_str(b)) { match (psl::domain_str(a), psl::domain_str(b)) {
+1 -1
View File
@@ -296,7 +296,7 @@ impl WebCore {
model.totp = item.totp; model.totp = item.totp;
model.notes = item.notes; model.notes = item.notes;
model.favorite = item.favorite; 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.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)?; self.write_item(&acct, &u, &mut model, existing.filter(|_| previous.is_some()), false)?;
Ok(id.to_string()) Ok(id.to_string())