From e5bd6cabc901df4e7b174a778909d9203af8fc04 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Fri, 8 May 2026 17:02:22 +0200 Subject: [PATCH] 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;