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]>
This commit is contained in:
Generated
+2
@@ -1316,12 +1316,14 @@ dependencies = [
|
|||||||
name = "kv-ffi"
|
name = "kv-ffi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ciborium",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"kv-crypto",
|
"kv-crypto",
|
||||||
"kv-model",
|
"kv-model",
|
||||||
"kv-proto",
|
"kv-proto",
|
||||||
"kv-store",
|
"kv-store",
|
||||||
"kv-sync",
|
"kv-sync",
|
||||||
|
"p256",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
|||||||
@@ -33,3 +33,5 @@ uniffi = { workspace = true, features = ["cli", "tokio"] }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
kv-sync = { workspace = true, features = ["test-server"] }
|
kv-sync = { workspace = true, features = ["test-server"] }
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
p256.workspace = true
|
||||||
|
ciborium.workspace = true
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ use zeroize::Zeroizing;
|
|||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
|
|
||||||
|
mod passkeys;
|
||||||
|
pub use passkeys::{kestrove_aaguid, PasskeyAssertion, PasskeyRegistration, PasskeySummary};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
||||||
pub enum CoreError {
|
pub enum CoreError {
|
||||||
#[error("vault is locked")]
|
#[error("vault is locked")]
|
||||||
|
|||||||
@@ -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<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;
|
||||||
@@ -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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
kv_sync::DeviceSigner::public_key(&self.0)
|
||||||
|
}
|
||||||
|
fn sign(&self, message: Vec<u8>) -> crate::Result<Vec<u8>> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user