kv-model: passkey module (ES256, none attestation, AAGUID, KAT + independent verifier tests)
WIP: FFI surface and spec docs not yet added. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Generated
+2
@@ -1337,8 +1337,10 @@ dependencies = [
|
|||||||
name = "kv-model"
|
name = "kv-model"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ciborium",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"hmac",
|
"hmac",
|
||||||
|
"p256",
|
||||||
"psl",
|
"psl",
|
||||||
"rand",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -19,3 +19,7 @@ sha2.workspace = true
|
|||||||
data-encoding.workspace = true
|
data-encoding.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
psl.workspace = true
|
psl.workspace = true
|
||||||
|
p256.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
ciborium.workspace = true
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
pub mod generator;
|
pub mod generator;
|
||||||
pub mod item;
|
pub mod item;
|
||||||
pub mod matching;
|
pub mod matching;
|
||||||
|
pub mod passkey;
|
||||||
pub mod totp;
|
pub mod totp;
|
||||||
|
|
||||||
pub use item::{Item, ItemKind, Passkey};
|
pub use item::{Item, ItemKind, Passkey};
|
||||||
|
|||||||
@@ -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<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;
|
||||||
@@ -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<u8>, Vec<u8>, 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<u8> {
|
||||||
|
(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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user