From e5bd6cabc901df4e7b174a778909d9203af8fc04 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Fri, 8 May 2026 17:02:22 +0200 Subject: [PATCH 1/5] ctap2.1: add wire types for credBlob, minPinLength, authenticatorConfig --- CHANGELOG.md | 4 + src/arbitrary.rs | 60 ++++++++++ src/ctap2.rs | 26 ++++- src/ctap2/authenticator_config.rs | 177 ++++++++++++++++++++++++++++++ src/ctap2/get_assertion.rs | 19 ++++ src/ctap2/get_info.rs | 15 ++- src/ctap2/make_credential.rs | 61 +++++++++- src/sizes.rs | 2 + 8 files changed, 354 insertions(+), 10 deletions(-) create mode 100644 src/ctap2/authenticator_config.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1f8d2..cfd2f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [Unreleased]: https://github.com/trussed-dev/ctap-types/compare/0.5.0...HEAD - `ctap2::get_info`: Fix field order of the `CtapOptions` and `Certifications` structs to produce canonical CBOR +- Add support for missing CTAP 2.1 features: + - Add `AuthenticatorConfig` command. + - Add `credBlob` extension and split `make_credential::Extensions` into `ExtensionsInput` and `ExtensionsOutput`. + - Add `minPinLength` extension. ## [0.5.0] 2026-03-23 diff --git a/src/arbitrary.rs b/src/arbitrary.rs index 70655dd..4bf3180 100644 --- a/src/arbitrary.rs +++ b/src/arbitrary.rs @@ -294,6 +294,66 @@ impl<'a> Arbitrary<'a> for webauthn::PublicKeyCredentialUserEntity { } } +// cannot be derived because of missing impl for &'a serde_bytes::Bytes (cred_blob). +// Mirrors the make_credential::Request handling of `pin_auth: Option<&Bytes>`. +impl<'a> Arbitrary<'a> for ctap2::make_credential::ExtensionsInput<'a> { + fn arbitrary(u: &mut Unstructured<'a>) -> Result { + let cred_protect = u.arbitrary()?; + let hmac_secret = u.arbitrary()?; + let large_blob_key = u.arbitrary()?; + #[cfg(feature = "third-party-payment")] + let third_party_payment = u.arbitrary()?; + let cred_blob = if bool::arbitrary(u)? { + Some(serde_bytes::Bytes::new(u.arbitrary()?)) + } else { + None + }; + Ok(Self { + cred_protect, + hmac_secret, + large_blob_key, + #[cfg(feature = "third-party-payment")] + third_party_payment, + cred_blob, + }) + } +} + +// cannot be derived because of missing impl for Vec<&'a str, N> (rp ID list). +impl<'a> Arbitrary<'a> for ctap2::authenticator_config::SubcommandParameters<'a> { + fn arbitrary(u: &mut Unstructured<'a>) -> Result { + let new_min_pin_length = u.arbitrary()?; + let min_pin_length_rp_ids = arbitrary_option(u, arbitrary_vec)?; + let force_change_pin = u.arbitrary()?; + Ok(Self { + new_min_pin_length, + min_pin_length_rp_ids, + force_change_pin, + }) + } +} + +// cannot be derived because of missing impl for &'a serde_bytes::Bytes (pin_auth) +// + Vec<_> in SubcommandParameters. +impl<'a> Arbitrary<'a> for ctap2::authenticator_config::Request<'a> { + fn arbitrary(u: &mut Unstructured<'a>) -> Result { + let sub_command = u.arbitrary()?; + let sub_command_params = u.arbitrary()?; + let pin_protocol = u.arbitrary()?; + let pin_auth = if bool::arbitrary(u)? { + Some(serde_bytes::Bytes::new(u.arbitrary()?)) + } else { + None + }; + Ok(Self { + sub_command, + sub_command_params, + pin_protocol, + pin_auth, + }) + } +} + fn arbitrary_byte_array<'a, const N: usize>(u: &mut Unstructured<'_>) -> Result<&'a ByteArray> { let bytes: &[u8; N] = u.bytes(N)?.try_into().unwrap(); // TODO: conversion should be provided by serde_bytes diff --git a/src/ctap2.rs b/src/ctap2.rs index 98dfc7a..ddf32e8 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -11,6 +11,7 @@ use crate::{sizes::*, Bytes, TryFromStrError}; pub use crate::operation::{Operation, VendorOperation}; +pub mod authenticator_config; pub mod client_pin; pub mod credential_management; pub mod get_assertion; @@ -45,6 +46,8 @@ pub enum Request<'a> { Selection, // 0xC LargeBlobs(large_blobs::Request<'a>), + // 0xD + AuthenticatorConfig(authenticator_config::Request<'a>), // vendor, to be embellished // Q: how to handle the associated CBOR structures Vendor(crate::operation::VendorOperation), @@ -118,10 +121,14 @@ impl<'a> Request<'a> { Request::LargeBlobs(cbor_deserialize(data).map_err(CtapMappingError::ParsingError)?) } + Operation::Config => Request::AuthenticatorConfig( + cbor_deserialize(data).map_err(CtapMappingError::ParsingError)?, + ), + // NB: FIDO Alliance "stole" 0x40 and 0x41, so these are not available Operation::Vendor(vendor_operation) => Request::Vendor(vendor_operation), - Operation::BioEnrollment | Operation::PreviewBioEnrollment | Operation::Config => { + Operation::BioEnrollment | Operation::PreviewBioEnrollment => { debug_now!("unhandled CBOR operation {:?}", operation); return Err(CtapMappingError::InvalidCommand(op).into()); } @@ -143,6 +150,7 @@ pub enum Response { Selection, CredentialManagement(credential_management::Response), LargeBlobs(large_blobs::Response), + AuthenticatorConfig, // Q: how to handle the associated CBOR structures Vendor, } @@ -161,7 +169,7 @@ impl Response { GetAssertion(response) | GetNextAssertion(response) => cbor_serialize(response, data), CredentialManagement(response) => cbor_serialize(response, data), LargeBlobs(response) => cbor_serialize(response, data), - Reset | Selection | Vendor => Ok([].as_slice()), + Reset | Selection | AuthenticatorConfig | Vendor => Ok([].as_slice()), }; if let Ok(slice) = outcome { *status = 0; @@ -445,6 +453,11 @@ pub trait Authenticator { Err(Error::InvalidCommand) } + fn authenticator_config(&mut self, request: &authenticator_config::Request) -> Result<()> { + let _ = request; + Err(Error::InvalidCommand) + } + /// Dispatches the enum of possible requests into the appropriate trait method. #[inline(never)] fn call_ctap2(&mut self, request: &Request) -> Result { @@ -533,6 +546,15 @@ pub trait Authenticator { )) } + // 0xD + Request::AuthenticatorConfig(request) => { + debug_now!("CTAP2.CFG"); + self.authenticator_config(request).inspect_err(|_e| { + debug!("error: {:?}", _e); + })?; + Ok(Response::AuthenticatorConfig) + } + // Not stable Request::Vendor(op) => { debug_now!("CTAP2.V"); diff --git a/src/ctap2/authenticator_config.rs b/src/ctap2/authenticator_config.rs new file mode 100644 index 0000000..1462c62 --- /dev/null +++ b/src/ctap2/authenticator_config.rs @@ -0,0 +1,177 @@ +//! `authenticatorConfig` (CTAP 2.1 §6.11), command `0x0D`. +//! +//! Carries `setMinPINLength`, `toggleAlwaysUv`, and (CTAP 2.3) `enableLongTouchForReset` +//! sub-commands, plus the spec-required `enableEnterpriseAttestation` and +//! `vendorPrototype` placeholders. + +use serde_indexed::{DeserializeIndexed, SerializeIndexed}; +use serde_repr::{Deserialize_repr, Serialize_repr}; + +use crate::Vec; + +pub const MAX_MIN_PIN_LENGTH_RP_IDS: usize = 4; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize_repr, Deserialize_repr)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[non_exhaustive] +#[repr(u8)] +pub enum Subcommand { + EnableEnterpriseAttestation = 0x01, + ToggleAlwaysUv = 0x02, + SetMinPINLength = 0x03, + VendorPrototype = 0xff, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, SerializeIndexed, DeserializeIndexed)] +#[non_exhaustive] +#[serde_indexed(offset = 1)] +pub struct SubcommandParameters<'a> { + // 0x01 + #[serde(skip_serializing_if = "Option::is_none")] + pub new_min_pin_length: Option, + // 0x02 + #[serde(skip_serializing_if = "Option::is_none")] + pub min_pin_length_rp_ids: Option>, + // 0x03 + #[serde(skip_serializing_if = "Option::is_none")] + pub force_change_pin: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, SerializeIndexed, DeserializeIndexed)] +#[non_exhaustive] +#[serde_indexed(offset = 1)] +pub struct Request<'a> { + // 0x01 + pub sub_command: Subcommand, + // 0x02 + #[serde(skip_serializing_if = "Option::is_none")] + pub sub_command_params: Option>, + // 0x03 + #[serde(skip_serializing_if = "Option::is_none")] + pub pin_protocol: Option, + // 0x04 + #[serde(skip_serializing_if = "Option::is_none")] + pub pin_auth: Option<&'a serde_bytes::Bytes>, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_test::{assert_de_tokens, assert_ser_tokens, assert_tokens, Token}; + + #[test] + fn test_serde_subcommand() { + for (sub, byte) in [ + (Subcommand::EnableEnterpriseAttestation, 0x01), + (Subcommand::ToggleAlwaysUv, 0x02), + (Subcommand::SetMinPINLength, 0x03), + (Subcommand::VendorPrototype, 0xff), + ] { + assert_tokens(&sub, &[Token::U8(byte)]); + } + } + + #[test] + fn test_de_request_toggle_always_uv() { + // A bare ToggleAlwaysUv request has no params and (typically) no pinAuth. + let req = Request { + sub_command: Subcommand::ToggleAlwaysUv, + sub_command_params: None, + pin_protocol: None, + pin_auth: None, + }; + assert_de_tokens( + &req, + &[ + Token::Map { len: Some(1) }, + Token::U64(1), + Token::U8(0x02), + Token::MapEnd, + ], + ); + } + + #[test] + fn test_de_request_set_min_pin_length() { + let mut rp_ids = Vec::new(); + rp_ids.push("login.example.com").unwrap(); + let req = Request { + sub_command: Subcommand::SetMinPINLength, + sub_command_params: Some(SubcommandParameters { + new_min_pin_length: Some(6), + min_pin_length_rp_ids: Some(rp_ids), + force_change_pin: Some(true), + }), + pin_protocol: Some(2), + pin_auth: None, + }; + // Deserialization side: serde_indexed reads each present key directly, + // without `Token::Some` wrappers (presence of the key encodes Some). + assert_de_tokens( + &req, + &[ + Token::Map { len: Some(3) }, + Token::U64(1), + Token::U8(0x03), + Token::U64(2), + Token::Map { len: Some(3) }, + Token::U64(1), + Token::U8(6), + Token::U64(2), + Token::Seq { len: Some(1) }, + Token::BorrowedStr("login.example.com"), + Token::SeqEnd, + Token::U64(3), + Token::Bool(true), + Token::MapEnd, + Token::U64(3), + Token::U8(2), + Token::MapEnd, + ], + ); + } + + #[test] + fn test_ser_request_set_min_pin_length() { + let mut rp_ids = Vec::new(); + rp_ids.push("login.example.com").unwrap(); + let req = Request { + sub_command: Subcommand::SetMinPINLength, + sub_command_params: Some(SubcommandParameters { + new_min_pin_length: Some(6), + min_pin_length_rp_ids: Some(rp_ids), + force_change_pin: Some(true), + }), + pin_protocol: Some(2), + pin_auth: None, + }; + // Serialization side: `Some` is emitted for each present optional. + assert_ser_tokens( + &req, + &[ + Token::Map { len: Some(3) }, + Token::U64(1), + Token::U8(0x03), + Token::U64(2), + Token::Some, + Token::Map { len: Some(3) }, + Token::U64(1), + Token::Some, + Token::U8(6), + Token::U64(2), + Token::Some, + Token::Seq { len: Some(1) }, + Token::BorrowedStr("login.example.com"), + Token::SeqEnd, + Token::U64(3), + Token::Some, + Token::Bool(true), + Token::MapEnd, + Token::U64(3), + Token::Some, + Token::U8(2), + Token::MapEnd, + ], + ); + } +} diff --git a/src/ctap2/get_assertion.rs b/src/ctap2/get_assertion.rs index f37bd87..1507c85 100644 --- a/src/ctap2/get_assertion.rs +++ b/src/ctap2/get_assertion.rs @@ -24,6 +24,12 @@ pub struct HmacSecretInput { #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[non_exhaustive] pub struct ExtensionsInput { + /// `credBlob` (CTAP 2.1 §11.1) retrieval flag. `Some(true)` asks the + /// authenticator to return the blob stored at MakeCredential time. + #[serde(rename = "credBlob")] + #[serde(skip_serializing_if = "Option::is_none")] + pub cred_blob: Option, + #[serde(rename = "hmac-secret")] #[serde(skip_serializing_if = "Option::is_none")] pub hmac_secret: Option, @@ -42,6 +48,13 @@ pub struct ExtensionsInput { #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[non_exhaustive] pub struct ExtensionsOutput { + /// `credBlob` retrieval result: the bytes stored at MakeCredential time, up + /// to `maxCredBlobLength` (≥ 32). Absent if the platform did not request + /// `credBlob` or if no blob is associated with the credential. + #[serde(rename = "credBlob")] + #[serde(skip_serializing_if = "Option::is_none")] + pub cred_blob: Option>, + #[serde(rename = "hmac-secret")] #[serde(skip_serializing_if = "Option::is_none")] // *either* enc(output1) *or* enc(output1 || output2) @@ -57,6 +70,7 @@ impl ExtensionsOutput { #[inline] pub fn is_set(&self) -> bool { let Self { + cred_blob, hmac_secret, #[cfg(feature = "third-party-payment")] third_party_payment, @@ -64,6 +78,9 @@ impl ExtensionsOutput { if hmac_secret.is_some() { return true; } + if cred_blob.is_some() { + return true; + } #[cfg(feature = "third-party-payment")] if third_party_payment.is_some() { return true; @@ -181,6 +198,7 @@ mod tests { pin_protocol: Some(1), }), large_blob_key: Some(true), + cred_blob: Some(true), #[cfg(feature = "third-party-payment")] third_party_payment: Some(true), }; @@ -191,6 +209,7 @@ mod tests { fn test_extensions_output_canonical() { let output = ExtensionsOutput { hmac_secret: Some([0xff; 80].try_into().unwrap()), + cred_blob: Some([0xff; 32].try_into().unwrap()), #[cfg(feature = "third-party-payment")] third_party_payment: Some(true), }; diff --git a/src/ctap2/get_info.rs b/src/ctap2/get_info.rs index 6557ce6..2736791 100644 --- a/src/ctap2/get_info.rs +++ b/src/ctap2/get_info.rs @@ -14,7 +14,7 @@ pub struct Response { // 0x02 #[serde(skip_serializing_if = "Option::is_none")] - pub extensions: Option>, + pub extensions: Option>, // 0x03 pub aaguid: Bytes<16>, @@ -66,7 +66,7 @@ pub struct Response { // FIDO_2_1 #[cfg(feature = "get-info-full")] #[serde(skip_serializing_if = "Option::is_none")] - pub min_pin_length: Option, + pub min_pin_length: Option, // 0x0E // FIDO_2_1 @@ -250,15 +250,19 @@ impl TryFrom<&str> for Version { #[serde(into = "&str", try_from = "&str")] pub enum Extension { CredProtect, + CredBlob, HmacSecret, LargeBlobKey, + MinPinLength, ThirdPartyPayment, } impl Extension { const CRED_PROTECT: &'static str = "credProtect"; + const CRED_BLOB: &'static str = "credBlob"; const HMAC_SECRET: &'static str = "hmac-secret"; const LARGE_BLOB_KEY: &'static str = "largeBlobKey"; + const MIN_PIN_LENGTH: &'static str = "minPinLength"; const THIRD_PARTY_PAYMENT: &'static str = "thirdPartyPayment"; } @@ -266,8 +270,10 @@ impl From for &str { fn from(extension: Extension) -> Self { match extension { Extension::CredProtect => Extension::CRED_PROTECT, + Extension::CredBlob => Extension::CRED_BLOB, Extension::HmacSecret => Extension::HMAC_SECRET, Extension::LargeBlobKey => Extension::LARGE_BLOB_KEY, + Extension::MinPinLength => Extension::MIN_PIN_LENGTH, Extension::ThirdPartyPayment => Extension::THIRD_PARTY_PAYMENT, } } @@ -279,8 +285,10 @@ impl TryFrom<&str> for Extension { fn try_from(s: &str) -> Result { match s { Self::CRED_PROTECT => Ok(Self::CredProtect), + Self::CRED_BLOB => Ok(Self::CredBlob), Self::HMAC_SECRET => Ok(Self::HmacSecret), Self::LARGE_BLOB_KEY => Ok(Self::LargeBlobKey), + Self::MIN_PIN_LENGTH => Ok(Self::MinPinLength), Self::THIRD_PARTY_PAYMENT => Ok(Self::ThirdPartyPayment), _ => Err(TryFromStrError), } @@ -465,8 +473,11 @@ mod tests { fn test_serde_extension() { let extensions = [ (Extension::CredProtect, "credProtect"), + (Extension::CredBlob, "credBlob"), (Extension::HmacSecret, "hmac-secret"), (Extension::LargeBlobKey, "largeBlobKey"), + (Extension::MinPinLength, "minPinLength"), + (Extension::ThirdPartyPayment, "thirdPartyPayment"), ]; for (extension, s) in extensions { assert_tokens(&extension, &[Token::BorrowedStr(s)]); diff --git a/src/ctap2/make_credential.rs b/src/ctap2/make_credential.rs index f5ce688..534a13a 100644 --- a/src/ctap2/make_credential.rs +++ b/src/ctap2/make_credential.rs @@ -24,10 +24,20 @@ impl TryFrom for CredentialProtectionPolicy { } } +/// Extensions input to `authenticatorMakeCredential`. #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[non_exhaustive] -pub struct Extensions { +// `Arbitrary` impl lives in `crate::arbitrary` because `&'a serde_bytes::Bytes` +// (cred_blob) doesn't satisfy `Arbitrary<'_>` and the derive macro can't +// special-case it. Same pattern as `make_credential::Request<'a>`. +pub struct ExtensionsInput<'a> { + /// `credBlob` (CTAP 2.1 §11.1): platform-supplied bytes to associate with + /// the credential. Up to `maxCredBlobLength` (≥ 32) bytes per credential. + #[serde(rename = "credBlob")] + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(borrow)] + pub cred_blob: Option<&'a serde_bytes::Bytes>, + #[serde(rename = "credProtect")] #[serde(skip_serializing_if = "Option::is_none")] pub cred_protect: Option, @@ -47,6 +57,32 @@ pub struct Extensions { pub third_party_payment: Option, } +/// Extensions output emitted in `authenticatorData.extensions` after +/// `authenticatorMakeCredential`. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ExtensionsOutput { + /// `credBlob` storage acknowledgement: `Some(true)` if the platform-supplied + /// blob was stored, `Some(false)` if not stored, absent if the platform did + /// not request the extension. + #[serde(rename = "credBlob")] + #[serde(skip_serializing_if = "Option::is_none")] + pub cred_blob: Option, + + #[serde(rename = "credProtect")] + #[serde(skip_serializing_if = "Option::is_none")] + pub cred_protect: Option, + + #[serde(rename = "hmac-secret")] + #[serde(skip_serializing_if = "Option::is_none")] + pub hmac_secret: Option, + + #[cfg(feature = "third-party-payment")] + #[serde(rename = "thirdPartyPayment")] + #[serde(skip_serializing_if = "Option::is_none")] + pub third_party_payment: Option, +} + #[derive(Clone, Debug, Eq, PartialEq, DeserializeIndexed)] #[non_exhaustive] #[serde_indexed(offset = 1)] @@ -58,7 +94,7 @@ pub struct Request<'a> { #[serde(skip_serializing_if = "Option::is_none")] pub exclude_list: Option, 16>>, #[serde(skip_serializing_if = "Option::is_none")] - pub extensions: Option, + pub extensions: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub options: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -74,7 +110,7 @@ pub struct Request<'a> { pub type AttestationObject = Response; pub type AuthenticatorData<'a> = - super::AuthenticatorData<'a, AttestedCredentialData<'a>, Extensions>; + super::AuthenticatorData<'a, AttestedCredentialData<'a>, ExtensionsOutput>; // NOTE: This is not CBOR, it has a custom encoding... // https://www.w3.org/TR/webauthn/#sec-attested-credential-data @@ -179,13 +215,26 @@ mod tests { } #[test] - fn test_extensions_canonical() { - let extensions = Extensions { + fn test_extensions_input_canonical() { + let extensions = ExtensionsInput { cred_protect: Some(1), hmac_secret: Some(true), large_blob_key: Some(true), #[cfg(feature = "third-party-payment")] third_party_payment: Some(true), + cred_blob: Some(serde_bytes::Bytes::new(b"1234")), + }; + crate::test::assert_canonical_cbor(&extensions); + } + + #[test] + fn test_extensions_output_canonical() { + let extensions = ExtensionsOutput { + cred_protect: Some(1), + hmac_secret: Some(true), + #[cfg(feature = "third-party-payment")] + third_party_payment: Some(true), + cred_blob: Some(true), }; crate::test::assert_canonical_cbor(&extensions); } diff --git a/src/sizes.rs b/src/sizes.rs index 3f36595..eb2c321 100644 --- a/src/sizes.rs +++ b/src/sizes.rs @@ -30,3 +30,5 @@ pub const THEORETICAL_MAX_MESSAGE_SIZE: usize = PACKET_SIZE - 7 + 128 * (PACKET_ pub const LARGE_BLOB_MAX_FRAGMENT_LENGTH: usize = 0; #[cfg(feature = "large-blobs")] pub const LARGE_BLOB_MAX_FRAGMENT_LENGTH: usize = 3008; + +pub const MAX_CRED_BLOB_LENGTH: usize = 32; From 130c4663ab414962e06089527f0df706a15cb3eb Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Fri, 8 May 2026 17:03:30 +0200 Subject: [PATCH 2/5] ctap2.2: add wire types for FIDO_2_2 and hmac-secret-mc --- CHANGELOG.md | 2 ++ src/arbitrary.rs | 2 ++ src/ctap2/get_info.rs | 7 ++++++- src/ctap2/make_credential.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd2f7c..4615c50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `AuthenticatorConfig` command. - Add `credBlob` extension and split `make_credential::Extensions` into `ExtensionsInput` and `ExtensionsOutput`. - Add `minPinLength` extension. +- Add support for missing CTAP 2.2 features: + - Add `hmac-secret-mc` extension. ## [0.5.0] 2026-03-23 diff --git a/src/arbitrary.rs b/src/arbitrary.rs index 4bf3180..ae04757 100644 --- a/src/arbitrary.rs +++ b/src/arbitrary.rs @@ -308,6 +308,7 @@ impl<'a> Arbitrary<'a> for ctap2::make_credential::ExtensionsInput<'a> { } else { None }; + let hmac_secret_mc = u.arbitrary()?; Ok(Self { cred_protect, hmac_secret, @@ -315,6 +316,7 @@ impl<'a> Arbitrary<'a> for ctap2::make_credential::ExtensionsInput<'a> { #[cfg(feature = "third-party-payment")] third_party_payment, cred_blob, + hmac_secret_mc, }) } } diff --git a/src/ctap2/get_info.rs b/src/ctap2/get_info.rs index 2736791..00d6814 100644 --- a/src/ctap2/get_info.rs +++ b/src/ctap2/get_info.rs @@ -14,7 +14,7 @@ pub struct Response { // 0x02 #[serde(skip_serializing_if = "Option::is_none")] - pub extensions: Option>, + pub extensions: Option>, // 0x03 pub aaguid: Bytes<16>, @@ -252,6 +252,7 @@ pub enum Extension { CredProtect, CredBlob, HmacSecret, + HmacSecretMc, LargeBlobKey, MinPinLength, ThirdPartyPayment, @@ -261,6 +262,7 @@ impl Extension { const CRED_PROTECT: &'static str = "credProtect"; const CRED_BLOB: &'static str = "credBlob"; const HMAC_SECRET: &'static str = "hmac-secret"; + const HMAC_SECRET_MC: &'static str = "hmac-secret-mc"; const LARGE_BLOB_KEY: &'static str = "largeBlobKey"; const MIN_PIN_LENGTH: &'static str = "minPinLength"; const THIRD_PARTY_PAYMENT: &'static str = "thirdPartyPayment"; @@ -272,6 +274,7 @@ impl From for &str { Extension::CredProtect => Extension::CRED_PROTECT, Extension::CredBlob => Extension::CRED_BLOB, Extension::HmacSecret => Extension::HMAC_SECRET, + Extension::HmacSecretMc => Extension::HMAC_SECRET_MC, Extension::LargeBlobKey => Extension::LARGE_BLOB_KEY, Extension::MinPinLength => Extension::MIN_PIN_LENGTH, Extension::ThirdPartyPayment => Extension::THIRD_PARTY_PAYMENT, @@ -287,6 +290,7 @@ impl TryFrom<&str> for Extension { Self::CRED_PROTECT => Ok(Self::CredProtect), Self::CRED_BLOB => Ok(Self::CredBlob), Self::HMAC_SECRET => Ok(Self::HmacSecret), + Self::HMAC_SECRET_MC => Ok(Self::HmacSecretMc), Self::LARGE_BLOB_KEY => Ok(Self::LargeBlobKey), Self::MIN_PIN_LENGTH => Ok(Self::MinPinLength), Self::THIRD_PARTY_PAYMENT => Ok(Self::ThirdPartyPayment), @@ -475,6 +479,7 @@ mod tests { (Extension::CredProtect, "credProtect"), (Extension::CredBlob, "credBlob"), (Extension::HmacSecret, "hmac-secret"), + (Extension::HmacSecretMc, "hmac-secret-mc"), (Extension::LargeBlobKey, "largeBlobKey"), (Extension::MinPinLength, "minPinLength"), (Extension::ThirdPartyPayment, "thirdPartyPayment"), diff --git a/src/ctap2/make_credential.rs b/src/ctap2/make_credential.rs index 534a13a..daeabc2 100644 --- a/src/ctap2/make_credential.rs +++ b/src/ctap2/make_credential.rs @@ -51,6 +51,13 @@ pub struct ExtensionsInput<'a> { #[serde(skip_serializing_if = "Option::is_none")] pub large_blob_key: Option, + /// `hmac-secret-mc` (CTAP 2.2 §11.4.5 / WebAuthn L3): platform-supplied + /// hmac-secret request evaluated at MakeCredential time, returning + /// hmac-secret outputs alongside the freshly-minted credential. + #[serde(rename = "hmac-secret-mc")] + #[serde(skip_serializing_if = "Option::is_none")] + pub hmac_secret_mc: Option, + #[cfg(feature = "third-party-payment")] #[serde(rename = "thirdPartyPayment")] #[serde(skip_serializing_if = "Option::is_none")] @@ -77,6 +84,13 @@ pub struct ExtensionsOutput { #[serde(skip_serializing_if = "Option::is_none")] pub hmac_secret: Option, + /// `hmac-secret-mc` (CTAP 2.2): encrypted hmac-secret outputs produced at + /// MakeCredential time. Wire format mirrors GetAssertion's `hmac-secret` + /// output — `enc(output1)` or `enc(output1 || output2)`, up to 80 bytes. + #[serde(rename = "hmac-secret-mc")] + #[serde(skip_serializing_if = "Option::is_none")] + pub hmac_secret_mc: Option>, + #[cfg(feature = "third-party-payment")] #[serde(rename = "thirdPartyPayment")] #[serde(skip_serializing_if = "Option::is_none")] @@ -190,6 +204,8 @@ pub struct UnsignedExtensionOutputs {} #[cfg(test)] mod tests { use super::*; + use crate::ctap2::get_assertion::HmacSecretInput; + use cosey::EcdhEsHkdf256PublicKey; use serde_test::{assert_ser_tokens, Token}; #[test] @@ -223,6 +239,15 @@ mod tests { #[cfg(feature = "third-party-payment")] third_party_payment: Some(true), cred_blob: Some(serde_bytes::Bytes::new(b"1234")), + hmac_secret_mc: Some(HmacSecretInput { + key_agreement: EcdhEsHkdf256PublicKey { + x: [0xff; 32].try_into().unwrap(), + y: [0xff; 32].try_into().unwrap(), + }, + salt_enc: [0xff; 80].try_into().unwrap(), + salt_auth: [0xff; 32].try_into().unwrap(), + pin_protocol: Some(1), + }), }; crate::test::assert_canonical_cbor(&extensions); } @@ -235,6 +260,7 @@ mod tests { #[cfg(feature = "third-party-payment")] third_party_payment: Some(true), cred_blob: Some(true), + hmac_secret_mc: Some([0xff; 80].try_into().unwrap()), }; crate::test::assert_canonical_cbor(&extensions); } From af3a162115df6ea78b8308ffee3c54bd7103c29b Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Thu, 21 May 2026 16:46:42 +0200 Subject: [PATCH 3/5] ctap2.3: add wire types for FIDO_2_3, smart-card transport, enableLongTouchForReset --- CHANGELOG.md | 4 ++++ src/ctap2/authenticator_config.rs | 2 ++ src/ctap2/get_info.rs | 21 +++++++++++++++++---- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4615c50..6c0de07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `minPinLength` extension. - Add support for missing CTAP 2.2 features: - Add `hmac-secret-mc` extension. +- Add support for CTAP 2.3: + - Add `Version::Fido2_3` variant. + - Add `Transport::SmartCard` variant. + - `ctap2::authenticator_config`: Add `Subcommand::EnableLongTouchForReset`. ## [0.5.0] 2026-03-23 diff --git a/src/ctap2/authenticator_config.rs b/src/ctap2/authenticator_config.rs index 1462c62..a4e96e3 100644 --- a/src/ctap2/authenticator_config.rs +++ b/src/ctap2/authenticator_config.rs @@ -19,6 +19,7 @@ pub enum Subcommand { EnableEnterpriseAttestation = 0x01, ToggleAlwaysUv = 0x02, SetMinPINLength = 0x03, + EnableLongTouchForReset = 0x04, VendorPrototype = 0xff, } @@ -65,6 +66,7 @@ mod tests { (Subcommand::EnableEnterpriseAttestation, 0x01), (Subcommand::ToggleAlwaysUv, 0x02), (Subcommand::SetMinPINLength, 0x03), + (Subcommand::EnableLongTouchForReset, 0x04), (Subcommand::VendorPrototype, 0xff), ] { assert_tokens(&sub, &[Token::U8(byte)]); diff --git a/src/ctap2/get_info.rs b/src/ctap2/get_info.rs index 00d6814..75a6854 100644 --- a/src/ctap2/get_info.rs +++ b/src/ctap2/get_info.rs @@ -10,7 +10,7 @@ pub type AuthenticatorInfo = Response; #[serde_indexed(offset = 1)] pub struct Response { // 0x01 - pub versions: Vec, + pub versions: Vec, // 0x02 #[serde(skip_serializing_if = "Option::is_none")] @@ -44,7 +44,7 @@ pub struct Response { // 0x09 // FIDO_2_1 #[serde(skip_serializing_if = "Option::is_none")] - pub transports: Option>, + pub transports: Option>, // 0x0A // FIDO_2_1 @@ -154,7 +154,7 @@ impl Default for Response { #[derive(Debug)] pub struct ResponseBuilder { - pub versions: Vec, + pub versions: Vec, pub aaguid: Bytes<16>, } @@ -210,6 +210,7 @@ pub enum Version { Fido2_0, Fido2_1, Fido2_1Pre, + Fido2_3, U2fV2, } @@ -217,6 +218,7 @@ impl Version { const FIDO_2_0: &'static str = "FIDO_2_0"; const FIDO_2_1: &'static str = "FIDO_2_1"; const FIDO_2_1_PRE: &'static str = "FIDO_2_1_PRE"; + const FIDO_2_3: &'static str = "FIDO_2_3"; const U2F_V2: &'static str = "U2F_V2"; } @@ -226,6 +228,7 @@ impl From for &str { Version::Fido2_0 => Version::FIDO_2_0, Version::Fido2_1 => Version::FIDO_2_1, Version::Fido2_1Pre => Version::FIDO_2_1_PRE, + Version::Fido2_3 => Version::FIDO_2_3, Version::U2fV2 => Version::U2F_V2, } } @@ -239,6 +242,7 @@ impl TryFrom<&str> for Version { Self::FIDO_2_0 => Ok(Self::Fido2_0), Self::FIDO_2_1 => Ok(Self::Fido2_1), Self::FIDO_2_1_PRE => Ok(Self::Fido2_1Pre), + Self::FIDO_2_3 => Ok(Self::Fido2_3), Self::U2F_V2 => Ok(Self::U2fV2), _ => Err(TryFromStrError), } @@ -304,11 +308,13 @@ impl TryFrom<&str> for Extension { #[serde(into = "&str", try_from = "&str")] pub enum Transport { Nfc, + SmartCard, Usb, } impl Transport { const NFC: &'static str = "nfc"; + const SMART_CARD: &'static str = "smart-card"; const USB: &'static str = "usb"; } @@ -316,6 +322,7 @@ impl From for &str { fn from(transport: Transport) -> Self { match transport { Transport::Nfc => Transport::NFC, + Transport::SmartCard => Transport::SMART_CARD, Transport::Usb => Transport::USB, } } @@ -327,6 +334,7 @@ impl TryFrom<&str> for Transport { fn try_from(s: &str) -> Result { match s { Self::NFC => Ok(Self::Nfc), + Self::SMART_CARD => Ok(Self::SmartCard), Self::USB => Ok(Self::Usb), _ => Err(TryFromStrError), } @@ -466,6 +474,7 @@ mod tests { (Version::Fido2_0, "FIDO_2_0"), (Version::Fido2_1, "FIDO_2_1"), (Version::Fido2_1Pre, "FIDO_2_1_PRE"), + (Version::Fido2_3, "FIDO_2_3"), (Version::U2fV2, "U2F_V2"), ]; for (version, s) in versions { @@ -491,7 +500,11 @@ mod tests { #[test] fn test_serde_transport() { - let transports = [(Transport::Nfc, "nfc"), (Transport::Usb, "usb")]; + let transports = [ + (Transport::Nfc, "nfc"), + (Transport::SmartCard, "smart-card"), + (Transport::Usb, "usb"), + ]; for (transport, s) in transports { assert_tokens(&transport, &[Token::BorrowedStr(s)]); } From bb4f857a2c00d88014ce2beb05ad5c9d0eedd0bd Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Thu, 21 May 2026 17:06:08 +0200 Subject: [PATCH 4/5] Add constants for enum sizes --- CHANGELOG.md | 1 + src/ctap2/get_info.rs | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0de07..668f49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `Version::Fido2_3` variant. - Add `Transport::SmartCard` variant. - `ctap2::authenticator_config`: Add `Subcommand::EnableLongTouchForReset`. +- Add `EXTENSION_COUNT`, `TRANSPORT_COUNT` and `VERSION_COUNT` constants for the size of the corresponding enums. ## [0.5.0] 2026-03-23 diff --git a/src/ctap2/get_info.rs b/src/ctap2/get_info.rs index 75a6854..358749b 100644 --- a/src/ctap2/get_info.rs +++ b/src/ctap2/get_info.rs @@ -10,11 +10,11 @@ pub type AuthenticatorInfo = Response; #[serde_indexed(offset = 1)] pub struct Response { // 0x01 - pub versions: Vec, + pub versions: Vec, // 0x02 #[serde(skip_serializing_if = "Option::is_none")] - pub extensions: Option>, + pub extensions: Option>, // 0x03 pub aaguid: Bytes<16>, @@ -44,7 +44,7 @@ pub struct Response { // 0x09 // FIDO_2_1 #[serde(skip_serializing_if = "Option::is_none")] - pub transports: Option>, + pub transports: Option>, // 0x0A // FIDO_2_1 @@ -154,7 +154,7 @@ impl Default for Response { #[derive(Debug)] pub struct ResponseBuilder { - pub versions: Vec, + pub versions: Vec, pub aaguid: Bytes<16>, } @@ -214,6 +214,8 @@ pub enum Version { U2fV2, } +pub const VERSION_COUNT: usize = 5; + impl Version { const FIDO_2_0: &'static str = "FIDO_2_0"; const FIDO_2_1: &'static str = "FIDO_2_1"; @@ -249,6 +251,8 @@ impl TryFrom<&str> for Version { } } +pub const EXTENSION_COUNT: usize = 7; + #[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[non_exhaustive] #[serde(into = "&str", try_from = "&str")] @@ -303,6 +307,8 @@ impl TryFrom<&str> for Extension { } } +pub const TRANSPORT_COUNT: usize = 3; + #[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[non_exhaustive] #[serde(into = "&str", try_from = "&str")] @@ -470,7 +476,7 @@ mod tests { #[test] fn test_serde_version() { - let versions = [ + let versions: [_; VERSION_COUNT] = [ (Version::Fido2_0, "FIDO_2_0"), (Version::Fido2_1, "FIDO_2_1"), (Version::Fido2_1Pre, "FIDO_2_1_PRE"), @@ -484,7 +490,7 @@ mod tests { #[test] fn test_serde_extension() { - let extensions = [ + let extensions: [_; EXTENSION_COUNT] = [ (Extension::CredProtect, "credProtect"), (Extension::CredBlob, "credBlob"), (Extension::HmacSecret, "hmac-secret"), @@ -500,7 +506,7 @@ mod tests { #[test] fn test_serde_transport() { - let transports = [ + let transports: [_; TRANSPORT_COUNT] = [ (Transport::Nfc, "nfc"), (Transport::SmartCard, "smart-card"), (Transport::Usb, "usb"), From b8ec306edf981f12ab29a9a9593645caffe0c03a Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Thu, 21 May 2026 17:08:18 +0200 Subject: [PATCH 5/5] Release v0.6.0-rc.1 --- CHANGELOG.md | 8 +++++++- Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 668f49f..89f83b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -[Unreleased]: https://github.com/trussed-dev/ctap-types/compare/0.5.0...HEAD +[Unreleased]: https://github.com/trussed-dev/ctap-types/compare/0.6.0-rc.1...HEAD + +- + +## [0.6.0-rc.1] 2026-05-21 + +[0.6.0-rc.1]: https://github.com/trussed-dev/ctap-types/compare/0.5.0...0.6.0-rc.1 - `ctap2::get_info`: Fix field order of the `CtapOptions` and `Certifications` structs to produce canonical CBOR - Add support for missing CTAP 2.1 features: diff --git a/Cargo.toml b/Cargo.toml index 474593d..46dc8b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ctap-types" -version = "0.5.0" +version = "0.6.0-rc.1" authors = ["Nicolas Stalder ", "The Trussed developers"] edition = "2021" license = "Apache-2.0 OR MIT"