- kv-proto: email on setup, AccountInfo.email/is_admin, ServerInfo.registration, admin DTOs. - kv-sync: prelogin by email or account id (setup QR / Emergency Kit), register with email, set_email, /v1/admin/* client calls. - kv-ffi: register_account(code, email), login(.., email), account_details, set_account_email, admin_create_invite. kv-wasm: the same plus the full admin API for the web vault. - Live test: two users on one server stay isolated; a new device signs in by email. Co-Authored-By: Claude Opus 5.5 <[email protected]>
218 lines
9.2 KiB
Rust
218 lines
9.2 KiB
Rust
//! 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 reset(&self) -> crate::Result<()> {
|
|
Err(crate::internal("not supported in this test"))
|
|
}
|
|
}
|
|
|
|
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(), None).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, 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());
|
|
}
|