From 8c40b6cccc14e677087479a9b09aba32dbe63b5d Mon Sep 17 00:00:00 2001 From: samialabri Date: Sat, 17 Oct 2026 00:28:55 +0900 Subject: [PATCH] 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 --- Cargo.lock | 2 + crates/kv-ffi/Cargo.toml | 2 + crates/kv-ffi/src/lib.rs | 3 + crates/kv-ffi/src/passkeys.rs | 280 ++++++++++++++++++++++++++++ crates/kv-ffi/src/passkeys/tests.rs | 214 +++++++++++++++++++++ 5 files changed, 501 insertions(+) create mode 100644 crates/kv-ffi/src/passkeys.rs create mode 100644 crates/kv-ffi/src/passkeys/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 4086b01..f793284 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1316,12 +1316,14 @@ dependencies = [ name = "kv-ffi" version = "0.1.0" dependencies = [ + "ciborium", "hkdf", "kv-crypto", "kv-model", "kv-proto", "kv-store", "kv-sync", + "p256", "serde", "serde_json", "sha2", diff --git a/crates/kv-ffi/Cargo.toml b/crates/kv-ffi/Cargo.toml index 9f9772f..7bb2385 100644 --- a/crates/kv-ffi/Cargo.toml +++ b/crates/kv-ffi/Cargo.toml @@ -33,3 +33,5 @@ uniffi = { workspace = true, features = ["cli", "tokio"] } [dev-dependencies] kv-sync = { workspace = true, features = ["test-server"] } tempfile.workspace = true +p256.workspace = true +ciborium.workspace = true diff --git a/crates/kv-ffi/src/lib.rs b/crates/kv-ffi/src/lib.rs index 4721e1d..fab1645 100644 --- a/crates/kv-ffi/src/lib.rs +++ b/crates/kv-ffi/src/lib.rs @@ -32,6 +32,9 @@ use zeroize::Zeroizing; uniffi::setup_scaffolding!(); +mod passkeys; +pub use passkeys::{kestrove_aaguid, PasskeyAssertion, PasskeyRegistration, PasskeySummary}; + #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum CoreError { #[error("vault is locked")] diff --git a/crates/kv-ffi/src/passkeys.rs b/crates/kv-ffi/src/passkeys.rs new file mode 100644 index 0000000..c25bf4c --- /dev/null +++ b/crates/kv-ffi/src/passkeys.rs @@ -0,0 +1,280 @@ +//! 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; diff --git a/crates/kv-ffi/src/passkeys/tests.rs b/crates/kv-ffi/src/passkeys/tests.rs new file mode 100644 index 0000000..7cb4c44 --- /dev/null +++ b/crates/kv-ffi/src/passkeys/tests.rs @@ -0,0 +1,214 @@ +//! FFI-level passkey tests. Responses are checked like a relying party would: CBOR decoded with +//! `ciborium`, authenticatorData parsed by hand, signatures verified with `p256` against the COSE key +//! returned at registration. + +use std::sync::Arc; + +use ciborium::Value; +use kv_sync::{ + signing::SoftwareSigner, + test_server::{TestServer, SETUP_TOKEN}, +}; +use p256::{ + ecdsa::{signature::Verifier, Signature, VerifyingKey}, + EncodedPoint, +}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{CoreError, DeviceSigner, ItemKindDto, PlatformDto, VaultCore}; + +const PW: &str = "a long master password"; +const RP: &str = "example.com"; + +fn cdh(tag: &[u8]) -> Vec { + Sha256::digest(tag).to_vec() +} + +fn map_get(m: &Value, k: Value) -> &Value { + m.as_map().unwrap().iter().find(|(kk, _)| *kk == k).map(|(_, v)| v).unwrap() +} + +/// What a relying party checks at registration. Returns the credential public key. +fn rp_verify_registration(reg: &crate::PasskeyRegistration, rp_id: &str) -> VerifyingKey { + let att: Value = ciborium::from_reader(reg.attestation_object.as_slice()).unwrap(); + assert_eq!(map_get(&att, Value::Text("fmt".into())), &Value::Text("none".into())); + let d = map_get(&att, Value::Text("authData".into())).as_bytes().unwrap().clone(); + assert_eq!(d, reg.authenticator_data); + assert_eq!(&d[..32], Sha256::digest(rp_id.as_bytes()).as_slice()); + assert_eq!(d[32], 0x5d, "UP|UV|BE|BS|AT"); + assert_eq!(&d[33..37], &[0, 0, 0, 0]); + assert_eq!(Uuid::from_slice(&d[37..53]).unwrap().to_string(), crate::kestrove_aaguid()); + let n = u16::from_be_bytes([d[53], d[54]]) as usize; + assert_eq!(&d[55..55 + n], reg.credential_id.as_slice()); + assert!(n == 16 || n == 32); + let mut rest = &d[55 + n..]; + let key: Value = ciborium::from_reader(&mut rest).unwrap(); + assert!(rest.is_empty()); + assert_eq!(&d[55 + n..], reg.cose_public_key.as_slice()); + let i = |v: i64| Value::Integer(v.into()); + assert_eq!(map_get(&key, i(1)), &i(2)); + assert_eq!(map_get(&key, i(3)), &i(-7)); + assert_eq!(map_get(&key, i(-1)), &i(1)); + let x = map_get(&key, i(-2)).as_bytes().unwrap(); + let y = map_get(&key, i(-3)).as_bytes().unwrap(); + let point = EncodedPoint::from_affine_coordinates(x.as_slice().into(), y.as_slice().into(), false); + let vk = VerifyingKey::from_encoded_point(&point).unwrap(); + use p256::pkcs8::DecodePublicKey; + assert_eq!(VerifyingKey::from_public_key_der(®.public_key_spki).unwrap(), vk); + vk +} + +/// What a relying party checks at sign-in. +fn rp_verify_assertion(a: &crate::PasskeyAssertion, vk: &VerifyingKey, rp_id: &str, client_data_hash: &[u8]) { + let d = &a.authenticator_data; + assert_eq!(d.len(), 37); + assert_eq!(&d[..32], Sha256::digest(rp_id.as_bytes()).as_slice()); + assert_eq!(d[32], 0x1d, "UP|UV|BE|BS"); + assert_eq!(&d[33..37], &[0, 0, 0, 0], "synced passkeys keep signCount 0"); + let mut msg = d.clone(); + msg.extend_from_slice(client_data_hash); + vk.verify(&msg, &Signature::from_der(&a.signature).unwrap()).unwrap(); +} + +#[test] +fn create_list_assert_and_rp_isolation() { + let core = VaultCore::new_in_memory(); + core.create_account(PW.into()).unwrap(); + + let reg = core.create_passkey(RP.into(), b"uid-1".to_vec(), "alice".into(), cdh(b"reg"), None).unwrap(); + let vk = rp_verify_registration(®, RP); + + // A new login item was created; ItemDto has no passkey material. + let item = core.get_item(reg.item_id.clone()).unwrap(); + assert_eq!((item.kind, item.title.as_str()), (ItemKindDto::Login, RP)); + assert_eq!(item.username.as_deref(), Some("alice")); + assert_eq!(item.urls, vec!["https://example.com".to_string()]); + + let found = core.passkeys_for_rp(RP.into(), vec![]).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].credential_id, reg.credential_id); + assert_eq!((found[0].user_handle.as_slice(), found[0].user_name.as_str()), (&b"uid-1"[..], "alice")); + assert_eq!(core.passkeys_for_rp(RP.into(), vec![reg.credential_id.clone()]).unwrap().len(), 1); + assert!(core.passkeys_for_rp(RP.into(), vec![vec![1; 16]]).unwrap().is_empty()); + assert!(core.passkeys_for_rp("login.example.com".into(), vec![]).unwrap().is_empty()); + + let h = cdh(b"get"); + let a = core.get_assertion(reg.credential_id.clone(), RP.into(), h.clone()).unwrap(); + assert_eq!((a.user_handle.as_slice(), a.item_id.as_str()), (&b"uid-1"[..], reg.item_id.as_str())); + rp_verify_assertion(&a, &vk, RP, &h); + + // No cross-RP use, even for a sub- or parent domain. + for other in ["login.example.com", "com", "example.org"] { + assert!(matches!( + core.get_assertion(reg.credential_id.clone(), other.into(), h.clone()), + Err(CoreError::NotFound) + )); + } + assert!(matches!(core.get_assertion(vec![9; 16], RP.into(), h.clone()), Err(CoreError::NotFound))); + assert!(matches!( + core.get_assertion(reg.credential_id.clone(), RP.into(), vec![0; 31]), + Err(CoreError::Invalid { .. }) + )); + assert!(matches!( + core.get_assertion(reg.credential_id.clone(), "Example.com".into(), h.clone()), + Err(CoreError::Invalid { .. }) + )); + assert!(matches!( + core.create_passkey(RP.into(), vec![], "a".into(), h.clone(), None), + Err(CoreError::Invalid { .. }) + )); + + // Same rp_id + user handle again: replaced in the same item (CTAP2), not duplicated. + let reg2 = core.create_passkey(RP.into(), b"uid-1".to_vec(), "alice".into(), cdh(b"reg2"), None).unwrap(); + assert_eq!(reg2.item_id, reg.item_id); + assert_ne!(reg2.credential_id, reg.credential_id); + assert_eq!(core.list_passkeys().unwrap().len(), 1); + assert!(matches!(core.get_assertion(reg.credential_id.clone(), RP.into(), h.clone()), Err(CoreError::NotFound))); + + // Explicit item: added next to the password of an existing login. + let mut dto = core.get_item(reg.item_id.clone()).unwrap(); + dto.password = Some("pw".into()); + core.save_item(dto).unwrap(); // save_item keeps passkeys + let reg3 = + core.create_passkey(RP.into(), b"uid-2".to_vec(), "bob".into(), cdh(b"r3"), Some(reg.item_id.clone())).unwrap(); + assert_eq!(reg3.item_id, reg.item_id); + assert_eq!(core.passkeys_for_rp(RP.into(), vec![]).unwrap().len(), 2); + assert_eq!(core.get_item(reg.item_id.clone()).unwrap().password.as_deref(), Some("pw")); + assert!(!core.item_history(reg.item_id.clone()).unwrap().is_empty()); + + core.delete_passkey(reg3.credential_id.clone()).unwrap(); + assert_eq!(core.list_passkeys().unwrap().len(), 1); + + core.lock(); + assert!(matches!(core.passkeys_for_rp(RP.into(), vec![]), Err(CoreError::Locked))); + assert!(matches!(core.get_assertion(reg2.credential_id, RP.into(), h), Err(CoreError::Locked))); +} + +#[test] +fn passkeys_survive_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("v.sqlite").to_string_lossy().into_owned(); + let core = VaultCore::open(path.clone()).unwrap(); + let sk = core.create_account(PW.into()).unwrap(); + let reg = core.create_passkey(RP.into(), b"u".to_vec(), "me".into(), cdh(b"r"), None).unwrap(); + let vk = rp_verify_registration(®, RP); + drop(core); + + let core = VaultCore::open(path).unwrap(); + core.unlock(PW.into(), sk).unwrap(); + let h = cdh(b"g"); + rp_verify_assertion(&core.get_assertion(reg.credential_id, RP.into(), h.clone()).unwrap(), &vk, RP, &h); +} + +struct TestSigner(SoftwareSigner); + +impl DeviceSigner for TestSigner { + fn device_id(&self) -> String { + kv_sync::DeviceSigner::device_id(&self.0).to_string() + } + fn public_key(&self) -> Vec { + kv_sync::DeviceSigner::public_key(&self.0) + } + fn sign(&self, message: Vec) -> crate::Result> { + kv_sync::DeviceSigner::sign(&self.0, &message).map_err(crate::internal) + } +} + +fn connect(core: &VaultCore, server: &TestServer) { + core.configure_server(server.url(), vec![]).unwrap(); + core.set_device_signer( + Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))), + "test".into(), + PlatformDto::Ios, + ) + .unwrap(); +} + +#[tokio::test] +async fn passkey_created_on_one_device_signs_on_another() { + let server = TestServer::start().await; + let a = VaultCore::new_in_memory(); + let sk = a.create_account(PW.into()).unwrap(); + connect(&a, &server); + a.register_account(SETUP_TOKEN.into()).await.unwrap(); + let reg = a.create_passkey(RP.into(), b"u".to_vec(), "me".into(), cdh(b"r"), None).unwrap(); + let vk = rp_verify_registration(®, RP); + assert_eq!(a.sync_now().await.unwrap().pushed, 1); + + let b = VaultCore::new_in_memory(); + connect(&b, &server); + b.login(PW.into(), sk, None).await.unwrap(); + b.sync_now().await.unwrap(); + let on_b = b.passkeys_for_rp(RP.into(), vec![]).unwrap(); + assert_eq!(on_b.len(), 1); + assert_eq!(on_b[0].credential_id, reg.credential_id); + let h = cdh(b"on b"); + rp_verify_assertion(&b.get_assertion(reg.credential_id.clone(), RP.into(), h.clone()).unwrap(), &vk, RP, &h); + + // Deleting on B propagates to A. + b.delete_passkey(reg.credential_id.clone()).unwrap(); + b.sync_now().await.unwrap(); + a.sync_now().await.unwrap(); + assert!(a.list_passkeys().unwrap().is_empty()); +}