diff --git a/Cargo.lock b/Cargo.lock index 6b7ac8a..4086b01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1337,8 +1337,10 @@ dependencies = [ name = "kv-model" version = "0.1.0" dependencies = [ + "ciborium", "data-encoding", "hmac", + "p256", "psl", "rand", "serde", diff --git a/crates/kv-model/Cargo.toml b/crates/kv-model/Cargo.toml index 9ff4980..0b21acb 100644 --- a/crates/kv-model/Cargo.toml +++ b/crates/kv-model/Cargo.toml @@ -19,3 +19,7 @@ sha2.workspace = true data-encoding.workspace = true url.workspace = true psl.workspace = true +p256.workspace = true + +[dev-dependencies] +ciborium.workspace = true diff --git a/crates/kv-model/src/lib.rs b/crates/kv-model/src/lib.rs index 9635351..4141bb0 100644 --- a/crates/kv-model/src/lib.rs +++ b/crates/kv-model/src/lib.rs @@ -4,6 +4,7 @@ pub mod generator; pub mod item; pub mod matching; +pub mod passkey; pub mod totp; pub use item::{Item, ItemKind, Passkey}; diff --git a/crates/kv-model/src/passkey.rs b/crates/kv-model/src/passkey.rs new file mode 100644 index 0000000..910b1b3 --- /dev/null +++ b/crates/kv-model/src/passkey.rs @@ -0,0 +1,271 @@ +//! WebAuthn platform-authenticator operations for stored passkeys (kestrove-spec/docs/05 "Passkeys"). +//! +//! Kestrove acts as a synced (multi-device) authenticator: ES256 only (COSE alg -7), attestation +//! format `none`, flags BE|BS always set and a signature counter that stays 0, as WebAuthn Level 3 +//! §6.1.1 allows for credentials that may exist on several devices. +//! +//! Everything here is pure: the caller decides where the [`Passkey`] is stored. Private keys only +//! leave this module inside a `Passkey`, which the owning [`crate::Item`] zeroizes on drop. + +use p256::{ + ecdsa::{signature::Signer, Signature, SigningKey, VerifyingKey}, + pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey}, +}; +use rand::{rngs::OsRng, RngCore}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use uuid::Uuid; + +use crate::Passkey; + +/// The Kestrove AAGUID, `e63b1a47-0244-453d-aef9-981639609295` (a random v4 UUID fixed forever). +/// Relying parties use it to show "Kestrove" next to the passkey. +pub const KESTROVE_AAGUID: Uuid = + Uuid::from_bytes([0xe6, 0x3b, 0x1a, 0x47, 0x02, 0x44, 0x45, 0x3d, 0xae, 0xf9, 0x98, 0x16, 0x39, 0x60, 0x92, 0x95]); + +/// COSE algorithm identifier of ES256 (ECDSA P-256 with SHA-256). +pub const COSE_ALG_ES256: i64 = -7; + +/// Length of new credential ids, in bytes. +pub const CREDENTIAL_ID_LEN: usize = 16; + +/// authenticatorData flag bits (WebAuthn §6.1). +pub mod flags { + /// User present. + pub const UP: u8 = 0x01; + /// User verified. + pub const UV: u8 = 0x04; + /// Backup eligible. + pub const BE: u8 = 0x08; + /// Backed up. + pub const BS: u8 = 0x10; + /// Attested credential data included. + pub const AT: u8 = 0x40; + /// Extension data included (never set by Kestrove). + pub const ED: u8 = 0x80; +} + +/// Flags of a registration response. +pub const REGISTRATION_FLAGS: u8 = flags::UP | flags::UV | flags::BE | flags::BS | flags::AT; +/// Flags of an assertion response. +pub const ASSERTION_FLAGS: u8 = flags::UP | flags::UV | flags::BE | flags::BS; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum PasskeyError { + #[error("invalid relying party id")] + InvalidRpId, + #[error("user handle must be 1 to 64 bytes")] + InvalidUserHandle, + #[error("client data hash must be 32 bytes")] + InvalidClientDataHash, + /// The credential belongs to another relying party. + #[error("relying party mismatch")] + RpMismatch, + #[error("stored passkey key is corrupt")] + BadKey, +} + +/// What a registration returns to the platform (no private material). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Registration { + pub credential_id: Vec, + /// COSE_Key (EC2, P-256, ES256), CTAP2 canonical CBOR. + pub cose_public_key: Vec, + /// SubjectPublicKeyInfo DER (Android's `response.publicKey`). + pub public_key_spki: Vec, + pub authenticator_data: Vec, + /// `{"fmt": "none", "attStmt": {}, "authData": …}`, CTAP2 canonical CBOR. + pub attestation_object: Vec, +} + +/// What an assertion returns to the platform. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Assertion { + pub authenticator_data: Vec, + /// ES256 over `authenticatorData || clientDataHash`, ASN.1 DER (WebAuthn §6.5.6). + pub signature: Vec, +} + +/// An RP ID is a registrable domain or host as the platform already validated it: lowercase ASCII +/// (IDNs in punycode), no scheme, port, path or trailing dot. Matching is always exact. +pub fn validate_rp_id(rp_id: &str) -> Result<(), PasskeyError> { + let ok = !rp_id.is_empty() + && rp_id.len() <= 253 + && !rp_id.starts_with(['.', '-']) + && !rp_id.ends_with(['.', '-']) + && !rp_id.contains("..") + && rp_id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-'); + if ok { + Ok(()) + } else { + Err(PasskeyError::InvalidRpId) + } +} + +fn check_client_data_hash(client_data_hash: &[u8]) -> Result<(), PasskeyError> { + if client_data_hash.len() == 32 { + Ok(()) + } else { + Err(PasskeyError::InvalidClientDataHash) + } +} + +/// Generates a new ES256 passkey with a random credential id. +pub fn generate(rp_id: &str, user_handle: &[u8], user_name: &str, now: i64) -> Result { + validate_rp_id(rp_id)?; + if user_handle.is_empty() || user_handle.len() > 64 { + return Err(PasskeyError::InvalidUserHandle); + } + let mut credential_id = vec![0u8; CREDENTIAL_ID_LEN]; + OsRng.fill_bytes(&mut credential_id); + let key = SigningKey::random(&mut OsRng); + from_signing_key(&key, credential_id, rp_id, user_handle, user_name, now) +} + +/// Builds a passkey around an existing key (used by [`generate`] and known-answer tests). +pub fn from_signing_key( + key: &SigningKey, + credential_id: Vec, + rp_id: &str, + user_handle: &[u8], + user_name: &str, + now: i64, +) -> Result { + let pkcs8 = key.to_pkcs8_der().map_err(|_| PasskeyError::BadKey)?; + Ok(Passkey { + credential_id, + rp_id: rp_id.to_owned(), + user_handle: user_handle.to_vec(), + user_name: user_name.to_owned(), + private_key: pkcs8.as_bytes().to_vec(), + sign_count: 0, + created_at: now, + }) +} + +fn signing_key(pk: &Passkey) -> Result { + // `SigningKey` zeroizes its scalar on drop. + SigningKey::from_pkcs8_der(&pk.private_key).map_err(|_| PasskeyError::BadKey) +} + +/// The registration response for a freshly generated passkey. +pub fn registration(pk: &Passkey) -> Result { + validate_rp_id(&pk.rp_id)?; + let vk = *signing_key(pk)?.verifying_key(); + let cose_public_key = cose_key(&vk); + let mut authenticator_data = auth_data_header(&pk.rp_id, REGISTRATION_FLAGS, pk.sign_count); + authenticator_data.extend_from_slice(KESTROVE_AAGUID.as_bytes()); + let id_len = u16::try_from(pk.credential_id.len()).map_err(|_| PasskeyError::BadKey)?; + authenticator_data.extend_from_slice(&id_len.to_be_bytes()); + authenticator_data.extend_from_slice(&pk.credential_id); + authenticator_data.extend_from_slice(&cose_public_key); + let public_key_spki = vk.to_public_key_der().map_err(|_| PasskeyError::BadKey)?.into_vec(); + Ok(Registration { + credential_id: pk.credential_id.clone(), + attestation_object: attestation_object_none(&authenticator_data), + cose_public_key, + public_key_spki, + authenticator_data, + }) +} + +/// Signs an assertion for `rp_id`. Fails with [`PasskeyError::RpMismatch`] unless `rp_id` is +/// exactly the passkey's RP ID. +pub fn assert(pk: &Passkey, rp_id: &str, client_data_hash: &[u8]) -> Result { + validate_rp_id(rp_id)?; + check_client_data_hash(client_data_hash)?; + if pk.rp_id != rp_id { + return Err(PasskeyError::RpMismatch); + } + let authenticator_data = auth_data_header(rp_id, ASSERTION_FLAGS, pk.sign_count); + let mut message = authenticator_data.clone(); + message.extend_from_slice(client_data_hash); + let signature = sign_der(&signing_key(pk)?, &message); + Ok(Assertion { authenticator_data, signature }) +} + +/// ECDSA P-256 / SHA-256 (RFC 6979 deterministic nonce), DER-encoded. +pub fn sign_der(key: &SigningKey, message: &[u8]) -> Vec { + let sig: Signature = key.sign(message); + sig.to_der().as_bytes().to_vec() +} + +/// `rpIdHash || flags || signCount` (37 bytes). +fn auth_data_header(rp_id: &str, flags: u8, sign_count: u32) -> Vec { + let mut out = Vec::with_capacity(37 + 16 + 2 + CREDENTIAL_ID_LEN + 77); + out.extend_from_slice(&Sha256::digest(rp_id.as_bytes())); + out.push(flags); + out.extend_from_slice(&sign_count.to_be_bytes()); + out +} + +// ---- CTAP2 canonical CBOR (only the few shapes needed here) ---- + +fn cbor_head(out: &mut Vec, major: u8, len: u64) { + let m = major << 5; + match len { + 0..=23 => out.push(m | len as u8), + 24..=0xff => out.extend_from_slice(&[m | 24, len as u8]), + 0x100..=0xffff => { + out.push(m | 25); + out.extend_from_slice(&(len as u16).to_be_bytes()); + } + _ => { + out.push(m | 26); + out.extend_from_slice(&(len as u32).to_be_bytes()); + } + } +} + +fn cbor_int(out: &mut Vec, v: i64) { + if v >= 0 { + cbor_head(out, 0, v as u64); + } else { + cbor_head(out, 1, (-1 - v) as u64); + } +} + +fn cbor_bytes(out: &mut Vec, b: &[u8]) { + cbor_head(out, 2, b.len() as u64); + out.extend_from_slice(b); +} + +fn cbor_text(out: &mut Vec, s: &str) { + cbor_head(out, 3, s.len() as u64); + out.extend_from_slice(s.as_bytes()); +} + +/// COSE_Key `{1: 2 (EC2), 3: -7 (ES256), -1: 1 (P-256), -2: x, -3: y}` (RFC 9053), keys in +/// canonical order. +pub fn cose_key(vk: &VerifyingKey) -> Vec { + let point = vk.to_encoded_point(false); + let (Some(x), Some(y)) = (point.x(), point.y()) else { unreachable!("uncompressed point has coordinates") }; + let mut out = Vec::with_capacity(77); + cbor_head(&mut out, 5, 5); + cbor_int(&mut out, 1); + cbor_int(&mut out, 2); + cbor_int(&mut out, 3); + cbor_int(&mut out, COSE_ALG_ES256); + cbor_int(&mut out, -1); + cbor_int(&mut out, 1); + cbor_int(&mut out, -2); + cbor_bytes(&mut out, x); + cbor_int(&mut out, -3); + cbor_bytes(&mut out, y); + out +} + +fn attestation_object_none(authenticator_data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(authenticator_data.len() + 32); + cbor_head(&mut out, 5, 3); + cbor_text(&mut out, "fmt"); + cbor_text(&mut out, "none"); + cbor_text(&mut out, "attStmt"); + cbor_head(&mut out, 5, 0); + cbor_text(&mut out, "authData"); + cbor_bytes(&mut out, authenticator_data); + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/kv-model/src/passkey/tests.rs b/crates/kv-model/src/passkey/tests.rs new file mode 100644 index 0000000..ff604e8 --- /dev/null +++ b/crates/kv-model/src/passkey/tests.rs @@ -0,0 +1,146 @@ +//! Checks responses with an independent verifier: authenticatorData parsed by hand, CBOR decoded with +//! `ciborium`, signatures verified with `p256` against the COSE key. + +use ciborium::Value; +use p256::{ + ecdsa::{signature::Verifier, Signature, SigningKey, VerifyingKey}, + EncodedPoint, +}; +use sha2::{Digest, Sha256}; + +use super::*; + +const CDH: [u8; 32] = [7; 32]; + +struct AuthData { + rp_id_hash: [u8; 32], + flags: u8, + sign_count: u32, + attested: Option<(Vec, Vec, Value)>, +} + +fn parse_auth_data(d: &[u8]) -> AuthData { + assert!(d.len() >= 37); + let flags = d[32]; + let attested = (flags & flags::AT != 0).then(|| { + let aaguid = d[37..53].to_vec(); + let n = u16::from_be_bytes([d[53], d[54]]) as usize; + let cred_id = d[55..55 + n].to_vec(); + let mut rest = &d[55 + n..]; + let key: Value = ciborium::from_reader(&mut rest).unwrap(); + assert!(rest.is_empty(), "no trailing data (ED is not set)"); + (aaguid, cred_id, key) + }); + if flags & flags::AT == 0 { + assert_eq!(d.len(), 37); + } + AuthData { + rp_id_hash: d[..32].try_into().unwrap(), + flags, + sign_count: u32::from_be_bytes(d[33..37].try_into().unwrap()), + attested, + } +} + +fn map_get<'a>(m: &'a Value, k: &Value) -> &'a Value { + m.as_map().unwrap().iter().find(|(kk, _)| kk == k).map(|(_, v)| v).unwrap() +} + +fn verifying_key_from_cose(key: &Value) -> VerifyingKey { + let i = |n: i64| Value::Integer(n.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); + VerifyingKey::from_encoded_point(&point).unwrap() +} + +#[test] +fn registration_and_assertion_verify_independently() { + let pk = generate("example.com", b"user-1", "alice", 1_700_000_000).unwrap(); + assert_eq!(pk.credential_id.len(), CREDENTIAL_ID_LEN); + let reg = registration(&pk).unwrap(); + + 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())); + assert_eq!(map_get(&att, &Value::Text("attStmt".into())), &Value::Map(vec![])); + let auth_data = map_get(&att, &Value::Text("authData".into())).as_bytes().unwrap(); + assert_eq!(auth_data, ®.authenticator_data); + + let ad = parse_auth_data(auth_data); + assert_eq!(ad.rp_id_hash.as_slice(), Sha256::digest(b"example.com").as_slice()); + assert_eq!(ad.flags, 0x5d); + assert_eq!(ad.sign_count, 0); + let (aaguid, cred_id, key) = ad.attested.unwrap(); + assert_eq!(aaguid, KESTROVE_AAGUID.as_bytes()); + assert_eq!(cred_id, pk.credential_id); + let vk = verifying_key_from_cose(&key); + let cose: Value = ciborium::from_reader(reg.cose_public_key.as_slice()).unwrap(); + assert_eq!(cose, key); + assert_eq!(vk, VerifyingKey::from_public_key_der_for_test(®.public_key_spki)); + + let a = super::assert(&pk, "example.com", &CDH).unwrap(); + let ad = parse_auth_data(&a.authenticator_data); + assert_eq!(ad.rp_id_hash.as_slice(), Sha256::digest(b"example.com").as_slice()); + assert_eq!((ad.flags, ad.sign_count), (0x1d, 0)); + assert!(ad.attested.is_none()); + let mut msg = a.authenticator_data.clone(); + msg.extend_from_slice(&CDH); + vk.verify(&msg, &Signature::from_der(&a.signature).unwrap()).unwrap(); + msg[0] ^= 1; + assert!(vk.verify(&msg, &Signature::from_der(&a.signature).unwrap()).is_err()); +} + +trait SpkiForTest { + fn from_public_key_der_for_test(der: &[u8]) -> Self; +} +impl SpkiForTest for VerifyingKey { + fn from_public_key_der_for_test(der: &[u8]) -> Self { + use p256::pkcs8::DecodePublicKey; + VerifyingKey::from_public_key_der(der).unwrap() + } +} + +#[test] +fn rp_id_is_matched_exactly() { + let pk = generate("example.com", b"u", "a", 0).unwrap(); + for other in ["login.example.com", "example.org", "xample.com"] { + assert_eq!(super::assert(&pk, other, &CDH), Err(PasskeyError::RpMismatch)); + } + for bad in ["", "Example.com", "https://example.com", "example.com:443", "example.com.", "a..b"] { + assert_eq!(validate_rp_id(bad), Err(PasskeyError::InvalidRpId), "{bad}"); + } + assert_eq!(super::assert(&pk, "example.com", &[0; 31]), Err(PasskeyError::InvalidClientDataHash)); + assert_eq!(generate("example.com", b"", "a", 0).unwrap_err(), PasskeyError::InvalidUserHandle); + assert_eq!(generate("example.com", &[0; 65], "a", 0).unwrap_err(), PasskeyError::InvalidUserHandle); +} + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +/// RFC 6979 A.2.5 (P-256, SHA-256, message "sample") and the well-known rpIdHash of example.com. +#[test] +fn known_answer_vectors() { + let key = SigningKey::from_slice(&hex("c9afa9d845ba75166b5c215767b1d6934e50c3db36e89b127b8a622b120f6721")).unwrap(); + let cose = cose_key(key.verifying_key()); + let mut expected = hex("a5010203262001215820"); + expected.extend(hex("60fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb6")); + expected.extend(hex("225820")); + expected.extend(hex("7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299")); + assert_eq!(cose, expected); + + let sig = Signature::from_der(&sign_der(&key, b"sample")).unwrap(); + let (r, s) = (sig.r().to_bytes().to_vec(), sig.s().to_bytes().to_vec()); + assert_eq!(r, hex("efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716")); + assert_eq!(s, hex("f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8")); + + let pk = from_signing_key(&key, vec![0xab; 16], "example.com", b"u", "a", 0).unwrap(); + let a = super::assert(&pk, "example.com", &CDH).unwrap(); + let mut ad = hex("a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947"); + ad.extend([0x1d, 0, 0, 0, 0]); + assert_eq!(a.authenticator_data, ad); + assert_eq!(KESTROVE_AAGUID.to_string(), "e63b1a47-0244-453d-aef9-981639609295"); +}