From 1787e82bcd1cc7c7da81c9eea0f37ee01392dc85 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Fri, 8 May 2026 23:35:42 +0200 Subject: [PATCH 1/8] ctap2.1: add authenticatorConfig (0x0D) command and authnrCfg option --- src/ctap2.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/ctap2.rs b/src/ctap2.rs index 183d9bd..adf0aa1 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -80,6 +80,7 @@ impl Authenticator for crate::Authenti options.large_blobs = Some(self.config.supports_large_blobs()); options.pin_uv_auth_token = Some(true); options.make_cred_uv_not_rqd = Some(true); + options.authnr_cfg = Some(true); let mut transports = Vec::new(); if self.config.nfc_transport { @@ -572,6 +573,54 @@ impl Authenticator for crate::Authenti .user_present(&mut self.trussed, constants::FIDO2_UP_TIMEOUT) } + #[inline(never)] + fn authenticator_config( + &mut self, + request: &ctap2::authenticator_config::Request<'_>, + ) -> Result<()> { + use ctap2::authenticator_config::Subcommand; + + // CTAP 2.1 §6.11.4 step 5: a PIN/UV-auth token is required. We have no + // built-in UV (no biometrics), so this also implies a client PIN must + // be set. + if !self.state.persistent.pin_is_set() { + return Err(Error::PinNotSet); + } + let pin_protocol = request.pin_protocol.ok_or(Error::MissingParameter)?; + let pin_protocol = self.parse_pin_protocol(pin_protocol)?; + let pin_auth = request.pin_auth.ok_or(Error::MissingParameter)?; + + // pinUvAuthData = 0xff * 32 || 0x0d || subCommand || subCommandParams (CBOR) + let mut data: Bytes<{ 32 + 2 + sizes::MAX_CREDENTIAL_ID_LENGTH }> = Bytes::new(); + data.resize(32, 0xff).map_err(|_| Error::Other)?; + data.push(0x0d).map_err(|_| Error::Other)?; + data.push(request.sub_command as u8) + .map_err(|_| Error::Other)?; + if let Some(params) = request.sub_command_params.as_ref() { + cbor_smol::cbor_serialize_to(params, &mut data).map_err(|_| Error::Other)?; + } + + let mut pin_protocol_impl = self.pin_protocol(pin_protocol); + let pin_token = pin_protocol_impl.verify_pin_token(&data, pin_auth)?; + pin_token.require_permissions(Permissions::AUTHENTICATOR_CONFIGURATION)?; + + // Subcommand handlers land in subsequent commits (C4: setMinPINLength, + // C5: toggleAlwaysUv, C11: enableLongTouchForReset). For now, refuse + // every subcommand cleanly so platforms can still feature-detect via + // the `authnrCfg` GetInfo flag without us pretending to support things + // we do not. + match request.sub_command { + Subcommand::EnableEnterpriseAttestation + | Subcommand::ToggleAlwaysUv + | Subcommand::SetMinPINLength + | Subcommand::EnableLongTouchForReset + | Subcommand::VendorPrototype => Err(Error::InvalidSubcommand), + // `Subcommand` is `#[non_exhaustive]`; refuse anything we did not + // explicitly enumerate above. + _ => Err(Error::InvalidSubcommand), + } + } + #[inline(never)] fn client_pin( &mut self, From 3a3197cf4d7ebb90e41b4a853d16e5d777514c11 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Thu, 21 May 2026 21:34:54 +0200 Subject: [PATCH 2/8] fixup! ctap2.1: add authenticatorConfig (0x0D) command and authnrCfg option Fix error codes and check order. --- src/ctap2.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index adf0aa1..5db0df7 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -580,15 +580,9 @@ impl Authenticator for crate::Authenti ) -> Result<()> { use ctap2::authenticator_config::Subcommand; - // CTAP 2.1 §6.11.4 step 5: a PIN/UV-auth token is required. We have no - // built-in UV (no biometrics), so this also implies a client PIN must - // be set. - if !self.state.persistent.pin_is_set() { - return Err(Error::PinNotSet); - } + let pin_auth = request.pin_auth.ok_or(Error::PinRequired)?; let pin_protocol = request.pin_protocol.ok_or(Error::MissingParameter)?; let pin_protocol = self.parse_pin_protocol(pin_protocol)?; - let pin_auth = request.pin_auth.ok_or(Error::MissingParameter)?; // pinUvAuthData = 0xff * 32 || 0x0d || subCommand || subCommandParams (CBOR) let mut data: Bytes<{ 32 + 2 + sizes::MAX_CREDENTIAL_ID_LENGTH }> = Bytes::new(); From 9aec0cacee679ac3676a686c59f8a986cd58d6ba Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Fri, 22 May 2026 17:21:33 +0200 Subject: [PATCH 3/8] fixup! ctap2.1: add authenticatorConfig (0x0D) command and authnrCfg option --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 037dd42..833e187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update to `ctap-types` v0.6.0-rc.1. - Set `algorithms`, `firmware_version` and `remaining_discoverable_credentials` in `get_info` and add `firmware_version` to `Config`. - Implement the `credBlob` extension. +- Implement the `authenticatorConfig` command without subcommands. - Load full credential from filesstem for getAssertion if an allow list is used with a discoverable credential. ## [v0.3.0](https://github.com/trussed-dev/fido-authenticator/releases/tag/v0.3.0) (2026-03-25) From 21480a9a16ee648e7a94b66d5b0e17a98d1e9dfe Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Sat, 9 May 2026 07:15:30 +0200 Subject: [PATCH 4/8] ctap2.1: implement setMinPINLength + minPinLength extension + forcePINChange --- src/ctap2.rs | 59 ++++++++++++++++++++++++++++++++++++++------ src/state.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index 5db0df7..f0723b2 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -61,6 +61,7 @@ impl Authenticator for crate::Authenti if self.config.supports_large_blobs() { extensions.push(Extension::LargeBlobKey).unwrap(); } + extensions.push(Extension::MinPinLength).unwrap(); extensions.push(Extension::ThirdPartyPayment).unwrap(); let mut pin_protocols = Vec::new(); @@ -81,6 +82,7 @@ impl Authenticator for crate::Authenti options.pin_uv_auth_token = Some(true); options.make_cred_uv_not_rqd = Some(true); options.authnr_cfg = Some(true); + options.set_min_pin_length = Some(true); let mut transports = Vec::new(); if self.config.nfc_transport { @@ -125,6 +127,10 @@ impl Authenticator for crate::Authenti response.remaining_discoverable_credentials = remaining_discoverable_credentials.map(|count| count as usize); response.max_cred_blob_length = Some(MAX_CRED_BLOB_LENGTH); + response.min_pin_length = Some(self.state.persistent.min_pin_length() as usize); + response.force_pin_change = Some(self.state.persistent.force_pin_change()); + response.max_rpids_for_set_min_pin_length = + Some(state::PersistentState::MAX_MIN_PIN_LENGTH_RP_IDS); response.attestation_formats = Some(attestation_formats); response } @@ -598,15 +604,13 @@ impl Authenticator for crate::Authenti let pin_token = pin_protocol_impl.verify_pin_token(&data, pin_auth)?; pin_token.require_permissions(Permissions::AUTHENTICATOR_CONFIGURATION)?; - // Subcommand handlers land in subsequent commits (C4: setMinPINLength, - // C5: toggleAlwaysUv, C11: enableLongTouchForReset). For now, refuse - // every subcommand cleanly so platforms can still feature-detect via - // the `authnrCfg` GetInfo flag without us pretending to support things - // we do not. match request.sub_command { + Subcommand::SetMinPINLength => self.config_set_min_pin_length(request), + // C5 wires `ToggleAlwaysUv`, C11 wires `EnableLongTouchForReset`. + // EnterpriseAttestation / VendorPrototype are deliberately not + // supported on this device. Subcommand::EnableEnterpriseAttestation | Subcommand::ToggleAlwaysUv - | Subcommand::SetMinPINLength | Subcommand::EnableLongTouchForReset | Subcommand::VendorPrototype => Err(Error::InvalidSubcommand), // `Subcommand` is `#[non_exhaustive]`; refuse anything we did not @@ -1126,6 +1130,39 @@ impl Authenticator for crate::Authenti // impl Authenticator for crate::Authenticator impl crate::Authenticator { + fn config_set_min_pin_length( + &mut self, + request: &ctap2::authenticator_config::Request<'_>, + ) -> Result<()> { + let params = request + .sub_command_params + .as_ref() + .ok_or(Error::MissingParameter)?; + + if let Some(new_value) = params.new_min_pin_length { + self.state + .persistent + .set_min_pin_length(&mut self.trussed, new_value)?; + } + + if let Some(rp_ids) = params.min_pin_length_rp_ids.as_ref() { + if rp_ids.len() > state::PersistentState::MAX_MIN_PIN_LENGTH_RP_IDS { + return Err(Error::PinPolicyViolation); + } + let mut owned = heapless::Vec::new(); + for id in rp_ids { + owned + .push(heapless::String::try_from(*id).map_err(|_| Error::PinPolicyViolation)?) + .map_err(|_| Error::PinPolicyViolation)?; + } + self.state + .persistent + .set_min_pin_length_rp_ids(&mut self.trussed, owned)?; + } + + Ok(()) + } + fn parse_pin_protocol(&self, version: impl TryInto) -> Result { if let Ok(version) = version.try_into() { for pin_protocol in self.pin_protocols() { @@ -1366,7 +1403,8 @@ impl crate::Authenticator { // pin.len(), pin_length, &pin); // chop off null bytes let pin_length = pin.iter().position(|&b| b == b'\0').unwrap_or(pin.len()); - if !(4..64).contains(&pin_length) { + let min_pin_length = self.state.persistent.min_pin_length() as usize; + if pin_length < min_pin_length || pin_length >= 64 { return Err(Error::PinPolicyViolation); } @@ -1471,6 +1509,13 @@ impl crate::Authenticator { permissions: Permissions, rp_id: &str, ) -> Result { + // 0. CTAP 2.1 §6.5.5.7 / §6.4.0x0C: while `forcePINChange` is set the + // authenticator MUST refuse every PIN-protected operation until the + // platform calls `clientPin.changePIN`. + if self.state.persistent.force_pin_change() { + return Err(Error::PinPolicyViolation); + } + // 1. pinAuth zero length -> wait for user touch, then // return PinNotSet if not set, PinInvalid if set // diff --git a/src/state.rs b/src/state.rs index 9e85855..5de332c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -272,12 +272,34 @@ pub struct PersistentState { // TODO: Add per-key counters for resident keys. // counter: Option, timestamp: u32, + + /// Configured minimum PIN length (CTAP 2.1 `setMinPINLength`, §6.11.4 + /// subcmd 0x03). `0` means "no override; use the spec default of 4". + #[serde(default)] + min_pin_length: u8, + + /// RP IDs that should automatically receive the `minPinLength` extension + /// output without explicit request (CTAP 2.1 `setMinPINLength`). + #[serde(default)] + min_pin_length_rp_ids: heapless::Vec, 4>, + + /// `forcePINChange` (CTAP 2.1 §6.4 0x0C). When `true`, the authenticator + /// rejects every operation that requires `clientPin` until the platform + /// successfully calls `clientPin.changePIN`. + #[serde(default)] + force_pin_change: bool, } impl PersistentState { const RESET_RETRIES: u8 = 8; const FILENAME: &'static Path = path!("persistent-state.cbor"); + /// Default minimum PIN length (CTAP 2.1 §6.11.4: spec floor is 4). + pub const DEFAULT_MIN_PIN_LENGTH: u8 = 4; + /// Maximum number of RP IDs that can be auto-receivers of the + /// `minPinLength` extension. + pub const MAX_MIN_PIN_LENGTH_RP_IDS: usize = 4; + pub fn load(trussed: &mut T) -> Result { // TODO: add "exists_file" method instead? let result = @@ -441,9 +463,56 @@ impl PersistentState { pin_hash: [u8; 16], ) -> Result<()> { self.pin_hash = Some(pin_hash); + // Successfully (re)setting the PIN clears any pending forcePINChange + // request — the platform has just complied (CTAP 2.1 §6.5.5.7). + self.force_pin_change = false; + self.save(trussed)?; + Ok(()) + } + + /// Configured minimum PIN length, never less than the CTAP 2.1 floor. + pub fn min_pin_length(&self) -> u8 { + core::cmp::max(self.min_pin_length, Self::DEFAULT_MIN_PIN_LENGTH) + } + + pub fn set_min_pin_length( + &mut self, + trussed: &mut T, + new_value: u8, + ) -> Result<()> { + // Spec: setMinPINLength may only raise the value, never lower it. + if new_value <= self.min_pin_length() { + return Err(Error::PinPolicyViolation); + } + self.min_pin_length = new_value; + // Spec §6.11.4 step 7: if the existing PIN is shorter than the new + // floor, force the platform to change it. We can't measure the + // existing PIN length here (only its hash is stored), so we set the + // flag unconditionally on any tightening. + if self.pin_hash.is_some() { + self.force_pin_change = true; + } + self.save(trussed)?; + Ok(()) + } + + pub fn min_pin_length_rp_ids(&self) -> &[heapless::String<256>] { + &self.min_pin_length_rp_ids + } + + pub fn set_min_pin_length_rp_ids( + &mut self, + trussed: &mut T, + rp_ids: heapless::Vec, { Self::MAX_MIN_PIN_LENGTH_RP_IDS }>, + ) -> Result<()> { + self.min_pin_length_rp_ids = rp_ids; self.save(trussed)?; Ok(()) } + + pub fn force_pin_change(&self) -> bool { + self.force_pin_change + } } impl RuntimeState { From 6b97e76048cfb22aa953b37aeaa15fdabf553887 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Fri, 22 May 2026 11:50:40 +0200 Subject: [PATCH 5/8] fixup! ctap2.1: implement setMinPINLength + minPinLength extension + forcePINChange Use From instead of as usize for u8 conversion --- src/ctap2.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index f0723b2..9c0c64c 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -127,7 +127,7 @@ impl Authenticator for crate::Authenti response.remaining_discoverable_credentials = remaining_discoverable_credentials.map(|count| count as usize); response.max_cred_blob_length = Some(MAX_CRED_BLOB_LENGTH); - response.min_pin_length = Some(self.state.persistent.min_pin_length() as usize); + response.min_pin_length = Some(self.state.persistent.min_pin_length().into()); response.force_pin_change = Some(self.state.persistent.force_pin_change()); response.max_rpids_for_set_min_pin_length = Some(state::PersistentState::MAX_MIN_PIN_LENGTH_RP_IDS); @@ -1403,7 +1403,7 @@ impl crate::Authenticator { // pin.len(), pin_length, &pin); // chop off null bytes let pin_length = pin.iter().position(|&b| b == b'\0').unwrap_or(pin.len()); - let min_pin_length = self.state.persistent.min_pin_length() as usize; + let min_pin_length = self.state.persistent.min_pin_length().into(); if pin_length < min_pin_length || pin_length >= 64 { return Err(Error::PinPolicyViolation); } From ce55f3f0393a9b0ec68fb5ea43638ac263122b7b Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Fri, 22 May 2026 17:23:11 +0200 Subject: [PATCH 6/8] fixup! ctap2.1: implement setMinPINLength + minPinLength extension + forcePINChange --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 833e187..a80a693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update to `ctap-types` v0.6.0-rc.1. - Set `algorithms`, `firmware_version` and `remaining_discoverable_credentials` in `get_info` and add `firmware_version` to `Config`. -- Implement the `credBlob` extension. -- Implement the `authenticatorConfig` command without subcommands. +- Implement these new extensions: + - `credBlob` + - `minPinLength` +- Implement the `authenticatorConfig` command with these subcommands: + - `setMinPINLength` - Load full credential from filesstem for getAssertion if an allow list is used with a discoverable credential. ## [v0.3.0](https://github.com/trussed-dev/fido-authenticator/releases/tag/v0.3.0) (2026-03-25) From c8ec2f3ef11cdb45aeb5dd8acb1c2905df6f7f11 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Fri, 22 May 2026 17:26:14 +0200 Subject: [PATCH 7/8] fixup! ctap2.1: implement setMinPINLength + minPinLength extension + forcePINChange --- src/ctap2.rs | 11 +++++------ src/state.rs | 7 ++----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index 9c0c64c..2a754bd 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -3,9 +3,9 @@ use credential_management::CredentialManagement; use ctap_types::{ ctap2::{ - self, client_pin::Permissions, AttestationFormatsPreference, AttestationStatement, - AttestationStatementFormat, Authenticator, NoneAttestationStatement, - PackedAttestationStatement, VendorOperation, + self, authenticator_config::MAX_MIN_PIN_LENGTH_RP_IDS, client_pin::Permissions, + AttestationFormatsPreference, AttestationStatement, AttestationStatementFormat, + Authenticator, NoneAttestationStatement, PackedAttestationStatement, VendorOperation, }, heapless::{String, Vec}, heapless_bytes::Bytes, @@ -129,8 +129,7 @@ impl Authenticator for crate::Authenti response.max_cred_blob_length = Some(MAX_CRED_BLOB_LENGTH); response.min_pin_length = Some(self.state.persistent.min_pin_length().into()); response.force_pin_change = Some(self.state.persistent.force_pin_change()); - response.max_rpids_for_set_min_pin_length = - Some(state::PersistentState::MAX_MIN_PIN_LENGTH_RP_IDS); + response.max_rpids_for_set_min_pin_length = Some(MAX_MIN_PIN_LENGTH_RP_IDS); response.attestation_formats = Some(attestation_formats); response } @@ -1146,7 +1145,7 @@ impl crate::Authenticator { } if let Some(rp_ids) = params.min_pin_length_rp_ids.as_ref() { - if rp_ids.len() > state::PersistentState::MAX_MIN_PIN_LENGTH_RP_IDS { + if rp_ids.len() > MAX_MIN_PIN_LENGTH_RP_IDS { return Err(Error::PinPolicyViolation); } let mut owned = heapless::Vec::new(); diff --git a/src/state.rs b/src/state.rs index 5de332c..b83cc68 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,7 +7,7 @@ pub mod migrate; use core::num::NonZeroU32; use ctap_types::{ - ctap2::AttestationFormatsPreference, + ctap2::{authenticator_config::MAX_MIN_PIN_LENGTH_RP_IDS, AttestationFormatsPreference}, // 2022-02-27: 10 credentials sizes::MAX_CREDENTIAL_COUNT_IN_LIST, // U8 currently Error, @@ -296,9 +296,6 @@ impl PersistentState { /// Default minimum PIN length (CTAP 2.1 §6.11.4: spec floor is 4). pub const DEFAULT_MIN_PIN_LENGTH: u8 = 4; - /// Maximum number of RP IDs that can be auto-receivers of the - /// `minPinLength` extension. - pub const MAX_MIN_PIN_LENGTH_RP_IDS: usize = 4; pub fn load(trussed: &mut T) -> Result { // TODO: add "exists_file" method instead? @@ -503,7 +500,7 @@ impl PersistentState { pub fn set_min_pin_length_rp_ids( &mut self, trussed: &mut T, - rp_ids: heapless::Vec, { Self::MAX_MIN_PIN_LENGTH_RP_IDS }>, + rp_ids: heapless::Vec, MAX_MIN_PIN_LENGTH_RP_IDS>, ) -> Result<()> { self.min_pin_length_rp_ids = rp_ids; self.save(trussed)?; From c5a668685d7dd4b00d101acb595c9ece2bb056ca Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Fri, 22 May 2026 17:26:42 +0200 Subject: [PATCH 8/8] fixup! ctap2.1: implement setMinPINLength + minPinLength extension + forcePINChange --- src/ctap2.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index 2a754bd..1e21cff 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -127,7 +127,7 @@ impl Authenticator for crate::Authenti response.remaining_discoverable_credentials = remaining_discoverable_credentials.map(|count| count as usize); response.max_cred_blob_length = Some(MAX_CRED_BLOB_LENGTH); - response.min_pin_length = Some(self.state.persistent.min_pin_length().into()); + response.min_pin_length = Some(self.state.persistent.min_pin_length()); response.force_pin_change = Some(self.state.persistent.force_pin_change()); response.max_rpids_for_set_min_pin_length = Some(MAX_MIN_PIN_LENGTH_RP_IDS); response.attestation_formats = Some(attestation_formats);