//! 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;