//! Passkeys (WebAuthn platform authenticator) for the iOS/macOS credential provider and the Android //! Credential Manager provider (kestrove-spec/docs/05 "Passkeys"). //! //! The app does the user verification (Face ID, fingerprint, master password) and the origin ↔ RP ID //! check, then calls these. Private keys never leave the core: no DTO carries them. use kv_model::{ passkey::{self, PasskeyError, KESTROVE_AAGUID}, Item, Passkey, }; use kv_proto::ItemRecord; use kv_store::{HistoryEntry, LocalItem}; use uuid::Uuid; use zeroize::Zeroizing; use crate::{decrypt, internal, now, parse_id, AccountRecord, CoreError, Result, Unlocked, VaultCore}; /// Returned by `create_passkey`: everything `ASPasskeyRegistrationCredential` and Android's /// `CreatePublicKeyCredentialResponse` need. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct PasskeyRegistration { /// The login item that now holds the passkey. pub item_id: String, pub credential_id: Vec, /// COSE_Key (EC2 / P-256 / ES256, alg -7), CTAP2 canonical CBOR. pub cose_public_key: Vec, /// SubjectPublicKeyInfo DER of the same key (Android's `response.publicKey`). pub public_key_spki: Vec, /// rpIdHash ‖ flags (UP|UV|BE|BS|AT = 0x5D) ‖ signCount 0 ‖ attested credential data. pub authenticator_data: Vec, /// CBOR `{"fmt": "none", "attStmt": {}, "authData": authenticator_data}`. pub attestation_object: Vec, } /// A stored passkey without private material (credential pickers, `ASPasskeyCredentialIdentity`). #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct PasskeySummary { pub item_id: String, pub credential_id: Vec, pub rp_id: String, pub user_handle: Vec, pub user_name: String, pub created_at: i64, } /// Returned by `get_assertion`: everything `ASPasskeyAssertionCredential` and Android's /// `GetCredentialResponse` need (the app adds clientDataJSON when it built one). #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct PasskeyAssertion { pub item_id: String, pub credential_id: Vec, pub rp_id: String, pub user_handle: Vec, pub user_name: String, /// rpIdHash ‖ flags (UP|UV|BE|BS = 0x1D) ‖ signCount 0. 37 bytes. pub authenticator_data: Vec, /// ES256 over `authenticator_data ‖ client_data_hash`, ASN.1 DER. pub signature: Vec, } /// The Kestrove AAGUID as a UUID string (also inside every registration's authenticatorData). #[uniffi::export] pub fn kestrove_aaguid() -> String { KESTROVE_AAGUID.to_string() } impl From for CoreError { fn from(e: PasskeyError) -> Self { match e { // Never reveal that the credential exists for another RP. PasskeyError::RpMismatch => CoreError::NotFound, PasskeyError::BadKey => CoreError::Internal { msg: e.to_string() }, other => CoreError::Invalid { msg: other.to_string() }, } } } fn summary(item: &Item, pk: &Passkey) -> PasskeySummary { PasskeySummary { item_id: item.id.to_string(), credential_id: pk.credential_id.clone(), rp_id: pk.rp_id.clone(), user_handle: pk.user_handle.clone(), user_name: pk.user_name.clone(), created_at: pk.created_at, } } #[uniffi::export] impl VaultCore { /// Creates an ES256 passkey for `rp_id` (WebAuthn `authenticatorMakeCredential`) and stores it. /// /// * `item_id`: the login item to add it to (`ASPasskeyCredentialRequest`'s record, or the item /// the user picked). `None`: the item that already holds a passkey for the same `rp_id` and /// `user_handle`, else a new login item titled `rp_id`. /// * A passkey with the same `rp_id` and `user_handle` in that item is replaced (CTAP2 rule). /// * `client_data_hash` must be 32 bytes; attestation is `none`, so it is not signed. /// /// The caller must have verified the user and checked that the caller's origin may use `rp_id`, /// and should check `excludeCredentials` with `passkeys_for_rp` first. pub fn create_passkey( &self, rp_id: String, user_handle: Vec, user_name: String, client_data_hash: Vec, item_id: Option, ) -> Result { if client_data_hash.len() != 32 { return Err(PasskeyError::InvalidClientDataHash.into()); } let (acct, u) = self.unlocked_snapshot()?; let vault = acct.primary_vault; let now = now(); let new_key = passkey::generate(&rp_id, &user_handle, &user_name, now)?; let reg = passkey::registration(&new_key)?; let target = match item_id { Some(id) => { let id = parse_id(&id)?; let li = self.store.get(vault, id)?.ok_or(CoreError::NotFound)?; let item = decrypt(&acct, &u.vault_key, &li.record)?.ok_or(CoreError::NotFound)?; if item.kind != kv_model::ItemKind::Login { return Err(CoreError::Invalid { msg: "passkeys can only be added to login items".into() }); } Some((li, item)) } None => self.find_item(&acct, &u, |pk| pk.rp_id == rp_id && pk.user_handle == user_handle)?, }; let (existing, mut model) = match target { Some((li, item)) => (Some(li), item), None => { let mut item = Item::new_login(rp_id.clone(), now); item.urls = vec![format!("https://{rp_id}")]; (None, item) } }; if model.username.as_deref().is_none_or(str::is_empty) && !user_name.is_empty() { model.username = Some(user_name); } model.passkeys.retain(|pk| !(pk.rp_id == rp_id && pk.user_handle == user_handle)); model.passkeys.push(new_key); let id = model.id; self.write_item(&acct, &u, &mut model, existing)?; Ok(PasskeyRegistration { item_id: id.to_string(), credential_id: reg.credential_id, cose_public_key: reg.cose_public_key, public_key_spki: reg.public_key_spki, authenticator_data: reg.authenticator_data, attestation_object: reg.attestation_object, }) } /// Passkeys for exactly `rp_id`. A non-empty `allow_credentials` (WebAuthn `allowCredentials` /// ids) restricts the result to those credential ids. No private material. pub fn passkeys_for_rp(&self, rp_id: String, allow_credentials: Vec>) -> Result> { passkey::validate_rp_id(&rp_id)?; let mut out = self.collect_passkeys(|pk| { pk.rp_id == rp_id && (allow_credentials.is_empty() || allow_credentials.contains(&pk.credential_id)) })?; out.sort_by(|a, b| a.user_name.cmp(&b.user_name).then(b.created_at.cmp(&a.created_at))); Ok(out) } /// Every stored passkey (for `ASCredentialIdentityStore` / Credential Manager entries after a sync). pub fn list_passkeys(&self) -> Result> { self.collect_passkeys(|_| true) } /// Signs a WebAuthn assertion (`authenticatorGetAssertion`) with the passkey `credential_id`. /// `NotFound` unless that credential exists and its RP ID is exactly `rp_id`. /// `client_data_hash` is SHA-256(clientDataJSON), 32 bytes. Nothing is written: the signature /// counter of a synced passkey stays 0. pub fn get_assertion( &self, credential_id: Vec, rp_id: String, client_data_hash: Vec, ) -> Result { passkey::validate_rp_id(&rp_id)?; let (acct, u) = self.unlocked_snapshot()?; let (_, item) = self.find_item(&acct, &u, |pk| pk.credential_id == credential_id)?.ok_or(CoreError::NotFound)?; let pk = item.passkeys.iter().find(|pk| pk.credential_id == credential_id).ok_or(CoreError::NotFound)?; let a = passkey::assert(pk, &rp_id, &client_data_hash)?; Ok(PasskeyAssertion { item_id: item.id.to_string(), credential_id: pk.credential_id.clone(), rp_id: pk.rp_id.clone(), user_handle: pk.user_handle.clone(), user_name: pk.user_name.clone(), authenticator_data: a.authenticator_data, signature: a.signature, }) } /// Removes one passkey from its item (the item itself stays; the old version goes to history). pub fn delete_passkey(&self, credential_id: Vec) -> Result<()> { let (acct, u) = self.unlocked_snapshot()?; let (li, mut item) = self.find_item(&acct, &u, |pk| pk.credential_id == credential_id)?.ok_or(CoreError::NotFound)?; item.passkeys.retain(|pk| pk.credential_id != credential_id); self.write_item(&acct, &u, &mut item, Some(li)) } } impl VaultCore { fn collect_passkeys(&self, keep: impl Fn(&Passkey) -> bool) -> Result> { let (acct, u) = self.unlocked_snapshot()?; let mut out = Vec::new(); for li in self.store.list(acct.primary_vault)? { let Some(item) = decrypt(&acct, &u.vault_key, &li.record)? else { continue }; out.extend(item.passkeys.iter().filter(|pk| keep(pk)).map(|pk| summary(&item, pk))); } Ok(out) } /// The first live item holding a passkey that matches. fn find_item( &self, acct: &AccountRecord, u: &Unlocked, matches: impl Fn(&Passkey) -> bool, ) -> Result> { for li in self.store.list(acct.primary_vault)? { let Some(item) = decrypt(acct, &u.vault_key, &li.record)? else { continue }; if item.passkeys.iter().any(&matches) { return Ok(Some((li, item))); } } Ok(None) } /// Seals and stores a modified item like `save_item` does: next `seq`, previous version to history. fn write_item( &self, acct: &AccountRecord, u: &Unlocked, model: &mut Item, existing: Option, ) -> Result<()> { let vault = acct.primary_vault; let id = model.id; let now = now(); if existing.is_some() { model.modified_at = now.max(model.modified_at + 1); } model.seq = kv_sync::engine::next_local_version(self.store.as_ref(), acct.account_id, vault, &u.vault_key, id)?; let json = Zeroizing::new(serde_json::to_vec(model).map_err(internal)?); let sealed = kv_crypto::keys::seal_item(acct.account_id, vault, id, &u.vault_key, &json)?; if let Some(li) = existing.filter(|li| !li.record.deleted) { if let (Some(k), Some(p)) = (li.record.wrapped_item_key, li.record.payload) { self.store.push_history( vault, id, &HistoryEntry { revision: li.record.revision, wrapped_item_key: k, payload: p, saved_at: now }, )?; } } let device = self .lock_inner() .device .as_ref() .map_or(Uuid::nil(), |d| kv_sync::DeviceSigner::device_id(d.signer.as_ref())); let record = ItemRecord { item_id: id, revision: 0, deleted: false, wrapped_item_key: Some(sealed.wrapped_item_key), payload: Some(sealed.payload), updated_by: device, updated_at: String::new(), }; Ok(self.store.put_local(vault, &record)?) } } #[cfg(test)] mod tests;