From 514929cac5fa7d74cc252d5b65d1773910b736a1 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 7 Jun 2026 21:36:51 +0100 Subject: [PATCH 1/2] test(ctap2): pin canonical cbor and cose golden vectors Characterize the exact wire bytes the CborRequest encode path emits for a fixed MakeCredential and GetAssertion request, and the COSE_Key bytes cosey emits for a fixed P-256 public key. These fail if serde-indexed map ordering or the cosey or serde-indexed byte layout drifts. --- libwebauthn/src/proto/ctap2/cbor/request.rs | 76 +++++++++++++++++++++ libwebauthn/src/proto/ctap2/cose.rs | 13 ++++ 2 files changed, 89 insertions(+) diff --git a/libwebauthn/src/proto/ctap2/cbor/request.rs b/libwebauthn/src/proto/ctap2/cbor/request.rs index 87c3257d..9c8e96dc 100644 --- a/libwebauthn/src/proto/ctap2/cbor/request.rs +++ b/libwebauthn/src/proto/ctap2/cbor/request.rs @@ -117,3 +117,79 @@ impl TryFrom<&Ctap2LargeBlobsRequest> for CborRequest { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::ctap2::model::{ + Ctap2CredentialType, Ctap2GetAssertionOptions, Ctap2MakeCredentialOptions, + Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity, + Ctap2PublicKeyCredentialType, Ctap2PublicKeyCredentialUserEntity, + }; + use serde_bytes::ByteBuf; + + // Deterministic, hand-pickable inputs: fixed byte fills and example.com. + fn fixed_make_credential_request() -> Ctap2MakeCredentialRequest { + Ctap2MakeCredentialRequest { + hash: ByteBuf::from([0x01u8; 32].to_vec()), + relying_party: Ctap2PublicKeyCredentialRpEntity { + id: "example.com".to_string(), + name: Some("Example".to_string()), + }, + user: Ctap2PublicKeyCredentialUserEntity { + id: ByteBuf::from([0x02u8; 16].to_vec()), + name: Some("alice".to_string()), + display_name: Some("Alice".to_string()), + }, + algorithms: vec![Ctap2CredentialType::default()], + exclude: Some(vec![Ctap2PublicKeyCredentialDescriptor { + id: ByteBuf::from([0x03u8; 16].to_vec()), + r#type: Ctap2PublicKeyCredentialType::PublicKey, + transports: None, + }]), + extensions: None, + options: Some(Ctap2MakeCredentialOptions { + require_resident_key: Some(true), + deprecated_require_user_verification: None, + }), + pin_auth_param: None, + pin_auth_proto: None, + enterprise_attestation: None, + } + } + + fn fixed_get_assertion_request() -> Ctap2GetAssertionRequest { + Ctap2GetAssertionRequest { + relying_party_id: "example.com".to_string(), + client_data_hash: ByteBuf::from([0x04u8; 32].to_vec()), + allow: vec![Ctap2PublicKeyCredentialDescriptor { + id: ByteBuf::from([0x05u8; 16].to_vec()), + r#type: Ctap2PublicKeyCredentialType::PublicKey, + transports: None, + }], + extensions: None, + options: Some(Ctap2GetAssertionOptions { + require_user_presence: true, + require_user_verification: false, + }), + pin_auth_param: None, + pin_auth_proto: None, + } + } + + #[test] + fn make_credential_request_golden_cbor() { + let cbor = CborRequest::try_from(&fixed_make_credential_request()).unwrap(); + assert_eq!(cbor.command, Ctap2CommandCode::AuthenticatorMakeCredential); + // Canonical indexed map, keys 0x01,0x02,0x03,0x04,0x05,0x07 in order. + assert_eq!(hex::encode(&cbor.encoded_data), "a6015820010101010101010101010101010101010101010101010101010101010101010102a26269646b6578616d706c652e636f6d646e616d65674578616d706c6503a36269645002020202020202020202020202020202646e616d6565616c6963656b646973706c61794e616d6565416c6963650481a263616c672664747970656a7075626c69632d6b65790581a2626964500303030303030303030303030303030364747970656a7075626c69632d6b657907a162726bf5"); + } + + #[test] + fn get_assertion_request_golden_cbor() { + let cbor = CborRequest::try_from(&fixed_get_assertion_request()).unwrap(); + assert_eq!(cbor.command, Ctap2CommandCode::AuthenticatorGetAssertion); + // Canonical indexed map with keys 0x01,0x02,0x03,0x05 in order, uv omitted. + assert_eq!(hex::encode(&cbor.encoded_data), "a4016b6578616d706c652e636f6d02582004040404040404040404040404040404040404040404040404040404040404040381a2626964500505050505050505050505050505050564747970656a7075626c69632d6b657905a1627570f5"); + } +} diff --git a/libwebauthn/src/proto/ctap2/cose.rs b/libwebauthn/src/proto/ctap2/cose.rs index 57e68b88..84e6ea67 100644 --- a/libwebauthn/src/proto/ctap2/cose.rs +++ b/libwebauthn/src/proto/ctap2/cose.rs @@ -550,4 +550,17 @@ mod tests { .unwrap(); assert!(to_spki(&bytes).is_err()); } + + /// Pin the exact COSE_Key bytes cosey emits for a fixed P-256 key. + #[test] + fn p256_public_key_golden_cose() { + use cosey::{Bytes, P256PublicKey, PublicKey}; + let key = PublicKey::P256Key(P256PublicKey { + x: Bytes::from_slice(&[0x06u8; 32]).unwrap(), + y: Bytes::from_slice(&[0x07u8; 32]).unwrap(), + }); + let bytes = cbor::to_vec(&key).unwrap(); + // {1: 2(EC2), 3: -7(ES256), -1: 1(P256), -2: x[32], -3: y[32]}. + assert_eq!(hex::encode(&bytes), "a501020326200121582006060606060606060606060606060606060606060606060606060606060606062258200707070707070707070707070707070707070707070707070707070707070707"); + } } From 1c678e251fbc4b3859217a5e7227d2eebd54722a Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Mon, 15 Jun 2026 23:29:02 +0100 Subject: [PATCH 2/2] test(ctap2): note how to refresh the golden vectors --- libwebauthn/src/proto/ctap2/cbor/request.rs | 1 + libwebauthn/src/proto/ctap2/cose.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/libwebauthn/src/proto/ctap2/cbor/request.rs b/libwebauthn/src/proto/ctap2/cbor/request.rs index 9c8e96dc..0d4bf2c5 100644 --- a/libwebauthn/src/proto/ctap2/cbor/request.rs +++ b/libwebauthn/src/proto/ctap2/cbor/request.rs @@ -120,6 +120,7 @@ impl TryFrom<&Ctap2LargeBlobsRequest> for CborRequest { #[cfg(test)] mod tests { + // To refresh after an intentional wire change, run the test and copy the actual (left) hex from the assert_eq! panic. use super::*; use crate::proto::ctap2::model::{ Ctap2CredentialType, Ctap2GetAssertionOptions, Ctap2MakeCredentialOptions, diff --git a/libwebauthn/src/proto/ctap2/cose.rs b/libwebauthn/src/proto/ctap2/cose.rs index 84e6ea67..a5b5c38d 100644 --- a/libwebauthn/src/proto/ctap2/cose.rs +++ b/libwebauthn/src/proto/ctap2/cose.rs @@ -551,6 +551,7 @@ mod tests { assert!(to_spki(&bytes).is_err()); } + // To refresh after an intentional wire change, run the test and copy the actual (left) hex from the assert_eq! panic. /// Pin the exact COSE_Key bytes cosey emits for a fixed P-256 key. #[test] fn p256_public_key_golden_cose() {