Files
kestrove-core/crates/kv-ffi/src/passkeys.rs
T
samiandClaude Opus 5.5 8c40b6cccc kv-ffi: passkey API (create_passkey, passkeys_for_rp, get_assertion, list_passkeys, delete_passkey, kestrove_aaguid)
New module crates/kv-ffi/src/passkeys.rs; lib.rs only gains the mod/pub use lines.
Tests verify registrations and assertions like an RP (ciborium + p256), check RP isolation,
reopening the vault and use on a second device after a sync.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:28:55 +09:00

281 lines
12 KiB
Rust

//! 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<u8>,
/// COSE_Key (EC2 / P-256 / ES256, alg -7), CTAP2 canonical CBOR.
pub cose_public_key: Vec<u8>,
/// SubjectPublicKeyInfo DER of the same key (Android's `response.publicKey`).
pub public_key_spki: Vec<u8>,
/// rpIdHash ‖ flags (UP|UV|BE|BS|AT = 0x5D) ‖ signCount 0 ‖ attested credential data.
pub authenticator_data: Vec<u8>,
/// CBOR `{"fmt": "none", "attStmt": {}, "authData": authenticator_data}`.
pub attestation_object: Vec<u8>,
}
/// 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<u8>,
pub rp_id: String,
pub user_handle: Vec<u8>,
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<u8>,
pub rp_id: String,
pub user_handle: Vec<u8>,
pub user_name: String,
/// rpIdHash ‖ flags (UP|UV|BE|BS = 0x1D) ‖ signCount 0. 37 bytes.
pub authenticator_data: Vec<u8>,
/// ES256 over `authenticator_data ‖ client_data_hash`, ASN.1 DER.
pub signature: Vec<u8>,
}
/// 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<PasskeyError> 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<u8>,
user_name: String,
client_data_hash: Vec<u8>,
item_id: Option<String>,
) -> Result<PasskeyRegistration> {
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<Vec<u8>>) -> Result<Vec<PasskeySummary>> {
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<Vec<PasskeySummary>> {
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<u8>,
rp_id: String,
client_data_hash: Vec<u8>,
) -> Result<PasskeyAssertion> {
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<u8>) -> 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<Vec<PasskeySummary>> {
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<Option<(LocalItem, Item)>> {
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<LocalItem>,
) -> 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;