Files
2026-10-16 22:59:02 +09:00

272 lines
9.5 KiB
Rust

//! 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<u8>,
/// COSE_Key (EC2, P-256, ES256), CTAP2 canonical CBOR.
pub cose_public_key: Vec<u8>,
/// SubjectPublicKeyInfo DER (Android's `response.publicKey`).
pub public_key_spki: Vec<u8>,
pub authenticator_data: Vec<u8>,
/// `{"fmt": "none", "attStmt": {}, "authData": …}`, CTAP2 canonical CBOR.
pub attestation_object: Vec<u8>,
}
/// What an assertion returns to the platform.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Assertion {
pub authenticator_data: Vec<u8>,
/// ES256 over `authenticatorData || clientDataHash`, ASN.1 DER (WebAuthn §6.5.6).
pub signature: Vec<u8>,
}
/// 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<Passkey, PasskeyError> {
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<u8>,
rp_id: &str,
user_handle: &[u8],
user_name: &str,
now: i64,
) -> Result<Passkey, PasskeyError> {
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, PasskeyError> {
// `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<Registration, PasskeyError> {
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<Assertion, PasskeyError> {
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<u8> {
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<u8> {
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<u8>, 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<u8>, 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<u8>, b: &[u8]) {
cbor_head(out, 2, b.len() as u64);
out.extend_from_slice(b);
}
fn cbor_text(out: &mut Vec<u8>, 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<u8> {
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<u8> {
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;