From dd6b9ae437d0c0365261708c8e41132d75dd32a9 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Fri, 8 May 2026 23:35:42 +0200 Subject: [PATCH 1/6] ctap2.1: add authenticatorConfig (0x0D) command and authnrCfg option --- CHANGELOG.md | 3 ++- Cargo.toml | 2 +- fuzz/Cargo.toml | 2 +- src/ctap2.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 037dd42..be41a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- Update to `ctap-types` v0.6.0-rc.1. +- Update to `ctap-types` v0.6.0-rc.2. - Set `algorithms`, `firmware_version` and `remaining_discoverable_credentials` in `get_info` and add `firmware_version` to `Config`. - Implement the `credBlob` extension. +- Implement the `config` 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) diff --git a/Cargo.toml b/Cargo.toml index 0c3e1fb..4979073 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ description = "FIDO authenticator Trussed app" apdu-app = { version = "0.2", optional = true } cbor-smol = "0.5" cosey = "0.4" -ctap-types = { version = "=0.6.0-rc.1", features = ["get-info-full", "large-blobs", "third-party-payment"] } +ctap-types = { version = "=0.6.0-rc.2", features = ["get-info-full", "large-blobs", "third-party-payment"] } ctaphid-app = { version = "0.2", optional = true } delog = "0.1" heapless = "0.9" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 86b47e9..7c822a0 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" cargo-fuzz = true [dependencies] -ctap-types = { version = "=0.6.0-rc.1", features = ["arbitrary"] } +ctap-types = { version = "=0.6.0-rc.2", features = ["arbitrary"] } libfuzzer-sys = "0.4" trussed = { version = "0.1", features = ["certificate-client", "crypto-client", "filesystem-client", "management-client", "aes256-cbc", "ed255", "p256", "sha256"] } trussed-staging = { version = "0.4.0", features = ["chunked", "hkdf", "virt", "fs-info"] } diff --git a/src/ctap2.rs b/src/ctap2.rs index 183d9bd..25e4036 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,45 @@ impl Authenticator for crate::Authenti .user_present(&mut self.trussed, constants::FIDO2_UP_TIMEOUT) } + #[inline(never)] + fn config(&mut self, request: &ctap2::config::Request<'_>) -> Result<()> { + use ctap2::config::Subcommand; + + 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)?; + + // 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 170017305d2cfbd94d9d72febf7c52f2a972e39c Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Sat, 9 May 2026 07:15:30 +0200 Subject: [PATCH 2/6] ctap2.1: implement setMinPINLength + minPinLength extension + forcePINChange --- CHANGELOG.md | 7 ++++-- src/ctap2.rs | 61 ++++++++++++++++++++++++++++++++++++++-------- src/state.rs | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be41a0b..13b87b7 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.2. - Set `algorithms`, `firmware_version` and `remaining_discoverable_credentials` in `get_info` and add `firmware_version` to `Config`. -- Implement the `credBlob` extension. -- Implement the `config` command without subcommands. +- Implement these new extensions: + - `credBlob` + - `minPinLength` +- Implement the `config` 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) diff --git a/src/ctap2.rs b/src/ctap2.rs index 25e4036..46b958b 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, client_pin::Permissions, config::MAX_MIN_PIN_LENGTH_RP_IDS, + AttestationFormatsPreference, AttestationStatement, AttestationStatementFormat, + Authenticator, NoneAttestationStatement, PackedAttestationStatement, VendorOperation, }, heapless::{String, Vec}, heapless_bytes::Bytes, @@ -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,9 @@ 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()); + 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); response } @@ -595,15 +600,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 @@ -1123,6 +1126,36 @@ impl Authenticator for crate::Authenti // impl Authenticator for crate::Authenticator impl crate::Authenticator { + fn config_set_min_pin_length(&mut self, request: &ctap2::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() > 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() { @@ -1363,7 +1396,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().into(); + if pin_length < min_pin_length || pin_length >= 64 { return Err(Error::PinPolicyViolation); } @@ -1468,6 +1502,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..73bb0c3 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::{config::MAX_MIN_PIN_LENGTH_RP_IDS, AttestationFormatsPreference}, // 2022-02-27: 10 credentials sizes::MAX_CREDENTIAL_COUNT_IN_LIST, // U8 currently Error, @@ -272,12 +272,31 @@ 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; + pub fn load(trussed: &mut T) -> Result { // TODO: add "exists_file" method instead? let result = @@ -441,9 +460,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, 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 3daba7d8a32f6eb3129fc0b550089bb245e2eb52 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Sat, 23 May 2026 13:52:23 -0400 Subject: [PATCH 3/6] =?UTF-8?q?ctap2.1:=20setMinPINLength=20(=C2=A76.11.4)?= =?UTF-8?q?=20+=20minPinLength=20extension=20at=20MakeCredential=20(=C2=A7?= =?UTF-8?q?10.1.2.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ctap2.rs | 234 +++++++++++++++--- src/state.rs | 64 +++-- tests/basic.rs | 552 +++++++++++++++++++++++++++++++++++++++++- tests/webauthn/mod.rs | 113 +++++++++ 4 files changed, 903 insertions(+), 60 deletions(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index 46b958b..a7818d1 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -3,7 +3,9 @@ use credential_management::CredentialManagement; use ctap_types::{ ctap2::{ - self, client_pin::Permissions, config::MAX_MIN_PIN_LENGTH_RP_IDS, + self, + client_pin::Permissions, + config::{MAX_MIN_PIN_LENGTH_RP_IDS, MAX_RP_ID_LENGTH}, AttestationFormatsPreference, AttestationStatement, AttestationStatementFormat, Authenticator, NoneAttestationStatement, PackedAttestationStatement, VendorOperation, }, @@ -263,6 +265,13 @@ impl Authenticator for crate::Authenti let mut third_party_payment_requested = false; let mut cred_blob_to_store: Option> = None; let mut cred_blob_requested = false; + // CTAP 2.1 §10.1.2.1 minPinLength extension: return the current + // `minPINLength` to RPs the platform has allowlisted via + // `authenticatorConfig.setMinPINLength`. When requested but the RP is + // out of scope, the spec says "return without the extension output" — + // we leave `min_pin_length_to_emit = None` and skip the extension + // block entirely (no EXTENSION_DATA flag, no map entry). + let mut min_pin_length_to_emit: Option = None; if let Some(extensions) = ¶meters.extensions { hmac_secret_requested = extensions.hmac_secret; @@ -301,6 +310,19 @@ impl Authenticator for crate::Authenti cred_blob_to_store = Some(Bytes::try_from(&**blob).expect("len bounded above")); } } + + if extensions.min_pin_length == Some(true) { + let rp_id: &str = parameters.rp.id.as_ref(); + if self + .state + .persistent + .min_pin_length_rp_ids() + .iter() + .any(|allowed| allowed.as_str() == rp_id) + { + min_pin_length_to_emit = Some(self.state.persistent.min_pin_length()); + } + } } // debug_now!("hmac-secret = {:?}, credProtect = {:?}", hmac_secret_requested, cred_protect_requested); @@ -436,6 +458,7 @@ impl Authenticator for crate::Authenti if hmac_secret_requested.is_some() || cred_protect_requested.is_some() || cred_blob_requested + || min_pin_length_to_emit.is_some() { flags |= Flags::EXTENSION_DATA; } @@ -459,6 +482,7 @@ impl Authenticator for crate::Authenti if hmac_secret_requested.is_some() || cred_protect_requested.is_some() || cred_blob_requested + || min_pin_length_to_emit.is_some() { let mut extensions = ctap2::make_credential::ExtensionsOutput::default(); extensions.cred_protect = parameters.extensions.as_ref().unwrap().cred_protect; @@ -469,6 +493,7 @@ impl Authenticator for crate::Authenti // otherwise (CTAP 2.1 §11.1). extensions.cred_blob = Some(cred_blob_to_store.is_some()); } + extensions.min_pin_length = min_pin_length_to_emit; Some(extensions) } else { None @@ -578,40 +603,97 @@ impl Authenticator for crate::Authenti .user_present(&mut self.trussed, constants::FIDO2_UP_TIMEOUT) } + // https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#authenticatorConfig #[inline(never)] fn config(&mut self, request: &ctap2::config::Request<'_>) -> Result<()> { use ctap2::config::Subcommand; - 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)?; + // CTAP 2.1 §6.11 — authenticatorConfig algorithm. - // 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)?; + // 1. If subCommand is not present in the input map, return + // CTAP2_ERR_MISSING_PARAMETER. + // (ctap-types' DeserializeIndexed enforces presence at the wire + // layer — `sub_command` is non-optional on `Request`, so absence + // surfaces as `SerdeMissingField` → `MissingParameter` before + // we get here.) + + // 2. If the authenticator does not support the subcommand being + // invoked, per subCommand's value, return CTAP1_ERR_INVALID_PARAMETER. + // ToggleAlwaysUv lands with the alwaysUv audit2 commit; + // EnableLongTouchForReset lands with the long-touch reset commit. + // EnterpriseAttestation / VendorPrototype are not supported. + match request.sub_command { + Subcommand::SetMinPINLength => {} + _ => return Err(Error::InvalidParameter), } - 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)?; + // 3. If the following statements are all true: + // - subCommand value is toggleAlwaysUv (0x02). + // - The authenticator is not protected by some form of user verification. + // - The alwaysUv option ID is present and true. + // then go to Step 5. + // Note: This allows for initial configuration of authenticators + // that have the Always UV feature enabled by default. + // (Not reachable here: toggleAlwaysUv is filtered out by step 2 + // until the alwaysUv audit2 commit lands.) + + // 4. If the authenticator is protected by some form of user + // verification or the alwaysUv option ID is present and true: + // We have no built-in UV here, and alwaysUv lands in a follow-up + // audit2 commit, so "protected by some form of UV" reduces to + // clientPin being set. In factory-default state (no PIN) the + // block is skipped per the note after step 6: "authenticatorConfig + // can be invoked without user verification if user verification + // is not configured, and the Always UV feature is disabled." + if self.state.persistent.pin_is_set() { + // 4.1. If pinUvAuthParam is absent from the input map, then + // end the operation by returning CTAP2_ERR_PUAT_REQUIRED. + let pin_auth = request.pin_auth.ok_or(Error::PinRequired)?; + + // 4.2. If pinUvAuthProtocol is absent from the input map, + // then end the operation by returning + // CTAP2_ERR_MISSING_PARAMETER. + let pin_protocol = request.pin_protocol.ok_or(Error::MissingParameter)?; + // 4.3. If pinUvAuthProtocol is not supported, return + // CTAP1_ERR_INVALID_PARAMETER. + let pin_protocol = self.parse_pin_protocol(pin_protocol)?; + + // 4.4. Call verify(pinUvAuthToken, + // 32×0xff || 0x0d || uint8(subCommand) || subCommandParams, + // pinUvAuthParam). + // If the verification fails, return CTAP2_ERR_PIN_AUTH_INVALID. + // Buffer sizing: 32 bytes of 0xff padding + 1 byte cmd (0x0d) + // + 1 byte subCommand + worst-case CBOR of SubcommandParameters + // (`MAX_SUBCOMMAND_PARAMS_CBOR_LEN`, ctap-types). Oversized + // params surface as `InvalidLength` (CTAP1 0x03). + let mut data: Bytes<{ 32 + 2 + ctap2::config::MAX_SUBCOMMAND_PARAMS_CBOR_LEN }> = + 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::InvalidLength)?; + } + let mut pin_protocol_impl = self.pin_protocol(pin_protocol); + let pin_token = pin_protocol_impl.verify_pin_token(&data, pin_auth)?; + + // 4.5. Check whether the pinUvAuthToken has the acfg + // permission. If not, return CTAP2_ERR_PIN_AUTH_INVALID. + pin_token.require_permissions(Permissions::AUTHENTICATOR_CONFIGURATION)?; + } + + // 5. Invoke subCommand (see below subsections for each defined + // subcommand), passing it the subCommandParams map. + // 6. Return the resulting status code as produced by subCommand, + // as defined in each subcommand subsection below. 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::EnableLongTouchForReset - | Subcommand::VendorPrototype => Err(Error::InvalidSubcommand), - // `Subcommand` is `#[non_exhaustive]`; refuse anything we did not - // explicitly enumerate above. - _ => Err(Error::InvalidSubcommand), + // Step 2 filtered every other variant. `Subcommand` is + // `#[non_exhaustive]` so the catch-all is still required. + _ => Err(Error::InvalidParameter), } } @@ -852,13 +934,16 @@ impl Authenticator for crate::Authenti return Err(Error::InvalidParameter); } - // 4. Check that all requested permissions are supported + // 4. Check that all requested permissions are supported. We + // support `authenticatorConfiguration` (CTAP 2.1 §6.11) — it + // was previously listed as unauthorized, which made + // `setMinPINLength` impossible to invoke since no platform + // could obtain a token with that permission. let mut unauthorized_permissions = Permissions::empty(); unauthorized_permissions.insert(Permissions::BIO_ENROLLMENT); if !self.config.supports_large_blobs() { unauthorized_permissions.insert(Permissions::LARGE_BLOB_WRITE); } - unauthorized_permissions.insert(Permissions::AUTHENTICATOR_CONFIGURATION); if permissions.intersects(unauthorized_permissions) { return Err(Error::UnauthorizedPermission); } @@ -1126,33 +1211,96 @@ impl Authenticator for crate::Authenti // impl Authenticator for crate::Authenticator impl crate::Authenticator { + // https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#setMinPINLength fn config_set_min_pin_length(&mut self, request: &ctap2::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 { + // 2.1. If newMinPINLength is absent, then let newMinPINLength be present + // with the value of current minimum PIN length. + let new_min_pin_length = params + .new_min_pin_length + .unwrap_or(self.state.persistent.min_pin_length()); + + // 2.2. If minPinLengthRPIDs is present and the authenticator does not + // support the minPinLength extension, return CTAP1_ERR_INVALID_PARAMETER. + // NOTHING TO DO HERE + + // 2.3. If newMinPINLength is less than the current minimum PIN length, + // return CTAP2_ERR_PIN_POLICY_VIOLATION. + if new_min_pin_length < self.state.persistent.min_pin_length() { + return Err(Error::PinPolicyViolation); + } + + // 2.4. If the value of forceChangePin is true, then: + if params.force_change_pin == Some(true) { + // 2.4.1. If the value of clientPIN is false, then return CTAP2_ERR_PIN_NOT_SET. + if !self.state.persistent.pin_is_set() { + return Err(Error::PinNotSet); + } + // 2.4.2. Let the value of the forcePINChange authenticatorGetInfo response member be true. self.state .persistent - .set_min_pin_length(&mut self.trussed, new_value)?; + .set_force_pin_change(&mut self.trussed, true)?; } - if let Some(rp_ids) = params.min_pin_length_rp_ids.as_ref() { - if rp_ids.len() > MAX_MIN_PIN_LENGTH_RP_IDS { - return Err(Error::PinPolicyViolation); - } - let mut owned = heapless::Vec::new(); + // 2.5. If the value of PINCodePointLength is less than newMinPINLength + // and the value of clientPIN is true then let the value of the + // forcePINChange member of the authenticatorGetInfo response be true. + if self.state.persistent.pin_code_point_length() < new_min_pin_length + && self.state.persistent.pin_is_set() + { + self.state + .persistent + .set_force_pin_change(&mut self.trussed, true)?; + } + + // 2.6. Authenticator stores newMinPINLength as minPINLength. + self.state + .persistent + .set_min_pin_length(&mut self.trussed, new_min_pin_length)?; + + // 2.7. If minPinLengthRPIDs is present and contains at least one string, then: + if let Some(rp_ids) = params + .min_pin_length_rp_ids + .as_ref() + .filter(|v| !v.is_empty()) + { + // If the authenticator does not have a pre-configured list of + // RP IDs authorized to receive the current minimum PIN length + // value, the authenticator stores the minPinLengthRPIDs + // parameter's list as the entire list of RP IDs authorized to + // receive the current minimum PIN length value. + // + // Otherwise, if the authenticator has a pre-configured list of + // RP IDs authorized to receive the current minimum PIN length + // value, it adds the minPinLengthRPIDs parameter's list to the + // immutable pre-configured list. Any previously added RP IDs + // are overwritten. + // + // Note: How the authenticator "adds" the minPinLengthRPIDs + // parameter's list to the pre-configured list is an + // implementation detail. + // + // If the authenticator cannot store or add the minPinLengthRPIDs, + // it returns CTAP2_ERR_KEY_STORE_FULL. + let mut owned: heapless::Vec< + heapless::String, + MAX_MIN_PIN_LENGTH_RP_IDS, + > = heapless::Vec::new(); for id in rp_ids { - owned - .push(heapless::String::try_from(*id).map_err(|_| Error::PinPolicyViolation)?) - .map_err(|_| Error::PinPolicyViolation)?; + let stored = heapless::String::try_from(*id).map_err(|_| Error::KeyStoreFull)?; + owned.push(stored).map_err(|_| Error::KeyStoreFull)?; } self.state .persistent - .set_min_pin_length_rp_ids(&mut self.trussed, owned)?; + .set_min_pin_length_rp_ids(&mut self.trussed, owned) + .map_err(|_| Error::KeyStoreFull)?; } + // 2.8. Authenticator returns CTAP2_OK. Ok(()) } @@ -1367,9 +1515,17 @@ impl crate::Authenticator { fn hash_store_pin(&mut self, pin: &Message) -> Result<()> { let pin_hash_32 = syscall!(self.trussed.hash_sha256(pin)).hash; let pin_hash: [u8; 16] = pin_hash_32[..16].try_into().unwrap(); + // CTAP 2.1 §6.5.5.5: persist PINCodePointLength alongside the hash so + // §6.11.4 step 2.5 can compare it against newMinPINLength later. We + // count code points best-effort here (full UTF-8 validation is the + // §6.5.5 PIN-audit commit's job); on non-UTF-8 input we fall back to + // byte count, a safe upper bound for the step-2.5 check. + let pin_code_point_length = core::str::from_utf8(pin) + .map(|s| s.chars().count()) + .unwrap_or(pin.len()) as u8; self.state .persistent - .set_pin_hash(&mut self.trussed, pin_hash) + .set_pin_hash(&mut self.trussed, pin_hash, pin_code_point_length) .unwrap(); Ok(()) diff --git a/src/state.rs b/src/state.rs index 73bb0c3..55981b5 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,7 +7,10 @@ pub mod migrate; use core::num::NonZeroU32; use ctap_types::{ - ctap2::{config::MAX_MIN_PIN_LENGTH_RP_IDS, AttestationFormatsPreference}, + ctap2::{ + config::{DEFAULT_MIN_PIN_LENGTH, MAX_MIN_PIN_LENGTH_RP_IDS, MAX_RP_ID_LENGTH}, + AttestationFormatsPreference, + }, // 2022-02-27: 10 credentials sizes::MAX_CREDENTIAL_COUNT_IN_LIST, // U8 currently Error, @@ -268,6 +271,13 @@ pub struct PersistentState { consecutive_pin_mismatches: u8, #[serde(with = "serde_bytes")] pin_hash: Option<[u8; 16]>, + /// Code-point length of the PIN whose hash sits in `pin_hash` + /// (CTAP 2.1 §6.5.5.5 / §6.11.4 step 2.5 — "PINCodePointLength"). + /// Captured at setPIN/changePIN time; `0` when no PIN is set or when + /// the field was added in a migration (treated as "unknown", forcing + /// a PIN change on the next `setMinPINLength` with a non-zero floor). + #[serde(default)] + pin_code_point_length: u8, // Ideally, we'd dogfood a "Monotonic Counter" from trussed. // TODO: Add per-key counters for resident keys. // counter: Option, @@ -281,7 +291,8 @@ pub struct PersistentState { /// 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>, + min_pin_length_rp_ids: + heapless::Vec, MAX_MIN_PIN_LENGTH_RP_IDS>, /// `forcePINChange` (CTAP 2.1 §6.4 0x0C). When `true`, the authenticator /// rejects every operation that requires `clientPin` until the platform @@ -294,9 +305,6 @@ 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; - pub fn load(trussed: &mut T) -> Result { // TODO: add "exists_file" method instead? let result = @@ -454,12 +462,22 @@ impl PersistentState { self.pin_hash } + /// PINCodePointLength of the currently-stored PIN (CTAP 2.1 §6.5.5.5), + /// captured at setPIN/changePIN time. Returns `0` when no PIN is set + /// — step 2.5 of §6.11.4 still consults it, and `0 < any non-zero + /// newMinPINLength` correctly forces a change. + pub fn pin_code_point_length(&self) -> u8 { + self.pin_code_point_length + } + pub fn set_pin_hash( &mut self, trussed: &mut T, pin_hash: [u8; 16], + pin_code_point_length: u8, ) -> Result<()> { self.pin_hash = Some(pin_hash); + self.pin_code_point_length = pin_code_point_length; // 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; @@ -469,7 +487,7 @@ impl PersistentState { /// 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) + core::cmp::max(self.min_pin_length, DEFAULT_MIN_PIN_LENGTH) } pub fn set_min_pin_length( @@ -478,29 +496,28 @@ impl PersistentState { new_value: u8, ) -> Result<()> { // Spec: setMinPINLength may only raise the value, never lower it. - if new_value <= self.min_pin_length() { + let cur = self.min_pin_length(); + if new_value < cur { 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; + + if new_value == cur { + return Ok(()); } + + self.min_pin_length = new_value; self.save(trussed)?; Ok(()) } - pub fn min_pin_length_rp_ids(&self) -> &[heapless::String<256>] { + pub fn min_pin_length_rp_ids(&self) -> &[heapless::String] { &self.min_pin_length_rp_ids } pub fn set_min_pin_length_rp_ids( &mut self, trussed: &mut T, - rp_ids: heapless::Vec, 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)?; @@ -510,6 +527,21 @@ impl PersistentState { pub fn force_pin_change(&self) -> bool { self.force_pin_change } + + /// Set the persistent `forcePINChange` flag. Idempotent — no save if the + /// flag is already at the requested value. + pub fn set_force_pin_change( + &mut self, + trussed: &mut T, + value: bool, + ) -> Result<()> { + if self.force_pin_change == value { + return Ok(()); + } + self.force_pin_change = value; + self.save(trussed)?; + Ok(()) + } } impl RuntimeState { diff --git a/tests/basic.rs b/tests/basic.rs index c711d15..34c6e27 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -16,11 +16,11 @@ use rand::RngCore as _; use fs::list_fs; use virt::{Ctap2, Ctap2Error, Options}; use webauthn::{ - exhaustive_struct, AttStmtFormat, ClientPin, CredentialManagement, CredentialManagementParams, - Exhaustive, GetAssertion, GetAssertionExtensionsInput, GetAssertionOptions, GetInfo, - GetNextAssertion, HmacSecretInput, KeyAgreementKey, MakeCredential, - MakeCredentialExtensionsInput, MakeCredentialOptions, PinToken, PubKeyCredDescriptor, - PubKeyCredParam, PublicKey, Rp, SharedSecret, Test, User, + exhaustive_struct, AttStmtFormat, AuthenticatorConfig, AuthenticatorConfigParams, ClientPin, + CredentialManagement, CredentialManagementParams, Exhaustive, GetAssertion, + GetAssertionExtensionsInput, GetAssertionOptions, GetInfo, GetNextAssertion, HmacSecretInput, + KeyAgreementKey, MakeCredential, MakeCredentialExtensionsInput, MakeCredentialOptions, + PinToken, PubKeyCredDescriptor, PubKeyCredParam, PublicKey, Rp, SharedSecret, Test, User, }; #[test] @@ -755,6 +755,7 @@ impl From for MakeCredentialExtensionsI } else { None }, + min_pin_length: None, } } } @@ -1167,3 +1168,544 @@ impl Exhaustive for TestListCredentials { fn test_list_credentials() { TestListCredentials::run_all(); } + +// ============================================================================ +// setMinPINLength (CTAP 2.1 §6.11.4) +// ============================================================================ + +/// Pin-token permission `authenticatorConfiguration` (CTAP 2.1 §6.5.5.7.4). +const PERM_AUTHENTICATOR_CONFIGURATION: u8 = 0x20; + +/// Build + send a setMinPINLength request with the given params, signed by +/// `pin_token`. Returns the wire-level outcome. +fn set_min_pin_length( + device: &Ctap2, + pin_token: &PinToken, + params: AuthenticatorConfigParams, +) -> Result<(), Ctap2Error> { + let mut request = AuthenticatorConfig::new(0x03); // SetMinPINLength + request.subcommand_params = Some(params); + request.pin_protocol = Some(2); + request.pin_auth = Some(pin_token.authenticate(&request.pin_uv_auth_data())); + device.exec(request).map(|_| ()) +} + +/// CTAP 2.1 §6.11.4 setMinPINLength algorithm: "If newMinPINLength is less +/// than the current minimum PIN length, return CTAP2_ERR_PIN_POLICY_VIOLATION." +/// The previous implementation rejected with the right error code but also +/// rejected the equal-value case. +#[test] +fn test_set_min_pin_length_below_current_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + + // DEFAULT_MIN_PIN_LENGTH is 4. Below the floor → PinPolicyViolation. + let params = AuthenticatorConfigParams { + new_min_pin_length: Some(3), + ..Default::default() + }; + let result = set_min_pin_length(&device, &pin_token, params); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// CTAP 2.1 §6.11.4 step 7d (inverse): `newMinPINLength == curMinPINLength` +/// is allowed — return Ok without changing state. Previously rejected with +/// `PinPolicyViolation`. +#[test] +fn test_set_min_pin_length_equal_is_noop() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + + // Equal to the current effective minimum (4 on a fresh device) → Ok. + let params = AuthenticatorConfigParams { + new_min_pin_length: Some(4), + ..Default::default() + }; + let result = set_min_pin_length(&device, &pin_token, params); + assert!(result.is_ok(), "got {:?}", result); + + // Getinfo.minPinLength should still report 4. + let reply = device.exec(GetInfo).unwrap(); + let options = reply.options.unwrap(); + // CTAP 2.1: minPinLength may not appear if get-info-full is off; we + // build with get-info-full so it is present. The actual field lives + // at index 0x0D in the GetInfo response, not in `options`. We don't + // currently parse it, so a missing-error here is treated as benign: + // the no-op succeeded if `set_min_pin_length` returned Ok above. + let _ = options; + }) +} + +/// Tightening from default (4) to 6 succeeds, and a follow-up equal request +/// also succeeds as a no-op. A subsequent lower-than-current request still +/// gets rejected. +#[test] +fn test_set_min_pin_length_tighten_then_noop_then_lower() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"12345678"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + let params = AuthenticatorConfigParams { + new_min_pin_length: Some(6), + ..Default::default() + }; + set_min_pin_length(&device, &pin_token, params).unwrap(); + + // Repeat — still equal, still ok. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + let params = AuthenticatorConfigParams { + new_min_pin_length: Some(6), + ..Default::default() + }; + set_min_pin_length(&device, &pin_token, params).unwrap(); + + // Now go below — should reject. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + let params = AuthenticatorConfigParams { + new_min_pin_length: Some(5), + ..Default::default() + }; + let result = set_min_pin_length(&device, &pin_token, params); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// CTAP 2.1 §6.11.4: `forceChangePin = true` sets the persistent +/// `forcePINChange` flag, which is then advertised in `authenticatorGetInfo` +/// (member 0x0C). The platform must call `changePIN` before any further +/// PIN-protected operation. +#[test] +fn test_set_min_pin_length_force_change_pin_sets_flag() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + + // forcePINChange should be false before the request. + let reply = device.exec(GetInfo).unwrap(); + assert_eq!(reply.force_pin_change, Some(false)); + + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + let params = AuthenticatorConfigParams { + force_change_pin: Some(true), + ..Default::default() + }; + set_min_pin_length(&device, &pin_token, params).unwrap(); + + // forcePINChange should be true after the request. + let reply = device.exec(GetInfo).unwrap(); + assert_eq!(reply.force_pin_change, Some(true)); + }) +} + +/// CTAP 2.1 §6.11 step 4 + §6.11.4: in factory-default state (no PIN, no +/// built-in UV) `authenticatorConfig` MAY be invoked without +/// `pinUvAuthParam`. So `setMinPINLength(newMinPINLength = 6)` with no +/// pin_auth must succeed, and the value must take effect (the next attempt +/// to drop it below 6 must be rejected with PIN_POLICY_VIOLATION). +#[test] +fn test_set_min_pin_length_factory_default_no_auth_succeeds() { + virt::run_ctap2(|device| { + let mut request = AuthenticatorConfig::new(0x03); // SetMinPINLength + request.subcommand_params = Some(AuthenticatorConfigParams { + new_min_pin_length: Some(6), + ..Default::default() + }); + // No pin_protocol, no pin_auth. + let result = device.exec(request); + assert!(result.is_ok(), "got {:?}", result.err()); + + // Verify the value stuck: try to lower to 5 (also unauthenticated, + // also in factory-default state) → PIN_POLICY_VIOLATION. + let mut request = AuthenticatorConfig::new(0x03); + request.subcommand_params = Some(AuthenticatorConfigParams { + new_min_pin_length: Some(5), + ..Default::default() + }); + assert_eq!(device.exec(request).err(), Some(Ctap2Error(0x37))); + }) +} + +/// CTAP 2.1 §6.11.4 step 2.4.a: "If the value of forceChangePin is true, +/// then: if the value of clientPIN is false, return CTAP2_ERR_PIN_NOT_SET." +/// In factory-default state the §6.11 step-4 gate is open (no pin_auth +/// required), so the step-2.4.a branch is reachable — exercise it. +#[test] +fn test_set_min_pin_length_force_change_pin_without_pin_set_rejected() { + virt::run_ctap2(|device| { + let mut request = AuthenticatorConfig::new(0x03); // SetMinPINLength + request.subcommand_params = Some(AuthenticatorConfigParams { + force_change_pin: Some(true), + ..Default::default() + }); + // No pin_protocol, no pin_auth — but no PIN is set either, so the + // gate is bypassed and we reach the spec's PIN_NOT_SET branch. + let result = device.exec(request); + assert_eq!(result.err(), Some(Ctap2Error(0x35))); // CTAP2_ERR_PIN_NOT_SET + }) +} + +/// CTAP 2.1 §6.11.4 step 2 ordering: step 2.3 (`newMinPINLength` < +/// current → PIN_POLICY_VIOLATION) is evaluated before step 2.4.a +/// (forceChangePin && !clientPIN → PIN_NOT_SET). Send both invalidating +/// inputs simultaneously and confirm PIN_POLICY_VIOLATION fires first. +#[test] +fn test_set_min_pin_length_policy_violation_takes_precedence_over_pin_not_set() { + virt::run_ctap2(|device| { + let mut request = AuthenticatorConfig::new(0x03); + request.subcommand_params = Some(AuthenticatorConfigParams { + new_min_pin_length: Some(3), // below floor of 4 + force_change_pin: Some(true), // would also trip PIN_NOT_SET + ..Default::default() + }); + let result = device.exec(request); + assert_eq!(result.err(), Some(Ctap2Error(0x37))); // PIN_POLICY_VIOLATION + }) +} + +/// CTAP 2.1 §6.11.4 step 2.4.a + step 2.6 ordering: if forceChangePin=true +/// fails with PIN_NOT_SET, the request MUST NOT leave a partially applied +/// newMinPINLength behind (storage at step 2.6 is unreachable after the +/// return at step 2.4.a). Send `newMinPINLength=6 + force_change_pin=true` +/// in factory default → PIN_NOT_SET, then verify a follow-up +/// `newMinPINLength = 5` without forceChangePin is still accepted (i.e. +/// the first call did not silently store 6). +#[test] +fn test_set_min_pin_length_force_change_pin_failure_does_not_apply_new_min() { + virt::run_ctap2(|device| { + let mut req1 = AuthenticatorConfig::new(0x03); + req1.subcommand_params = Some(AuthenticatorConfigParams { + new_min_pin_length: Some(6), + force_change_pin: Some(true), + ..Default::default() + }); + assert_eq!(device.exec(req1).err(), Some(Ctap2Error(0x35))); + + // If the failed call had partially applied newMinPINLength=6, then + // a subsequent attempt to lower to 5 would be rejected. Verify the + // pre-call state (min = 4 = floor) is intact: 5 must succeed. + let mut req2 = AuthenticatorConfig::new(0x03); + req2.subcommand_params = Some(AuthenticatorConfigParams { + new_min_pin_length: Some(5), + ..Default::default() + }); + let result = device.exec(req2); + assert!(result.is_ok(), "got {:?}", result.err()); + }) +} + +/// CTAP 2.1 §6.11 step 4: once a PIN is set, the authenticator IS +/// "protected by some form of user verification" and `pinUvAuthParam` +/// becomes mandatory. Send `setMinPINLength` without `pin_auth` → +/// CTAP2_ERR_PUAT_REQUIRED (0x36). +#[test] +fn test_set_min_pin_length_without_pin_auth_rejected_when_pin_set() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + + let mut request = AuthenticatorConfig::new(0x03); + request.subcommand_params = Some(AuthenticatorConfigParams { + new_min_pin_length: Some(6), + ..Default::default() + }); + // PIN is set → gate is closed → no pin_auth → 0x36. + let result = device.exec(request); + assert_eq!(result.err(), Some(Ctap2Error(0x36))); + }) +} + +/// User-requested test: a `setMinPINLength` request derived from an INCORRECT +/// PIN must fail — the platform never obtains a valid `pin_uv_auth_token`, +/// so the `pin_auth` HMAC won't verify on the device side. +/// +/// The failure surfaces at `getPinUvAuthTokenUsingPinWithPermissions`, before +/// the `setMinPINLength` request is even built. The authenticator returns +/// CTAP2_ERR_PIN_INVALID (0x31) and decrements the retry counter +/// (CTAP 2.1 §6.5.5.7). +#[test] +fn test_set_min_pin_length_with_incorrect_pin_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let real_pin = b"123456"; + let wrong_pin = b"000000"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, real_pin).unwrap(); + + // Obtaining the token with the wrong PIN must fail with PIN_INVALID. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let result = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + wrong_pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ); + assert_eq!(result.err(), Some(Ctap2Error(0x31))); + // Retries should have decreased. + assert_eq!(get_pin_retries(&device), 7); + }) +} + +// ---------------------------------------------------------------------------- +// minPinLength extension (CTAP 2.1 §10.1.2.1) — end-to-end at MakeCredential +// ---------------------------------------------------------------------------- +// +// These tests use the *factory-default* flow (no PIN set) so we can exercise +// the extension's RP-allowlist path without `force_pin_change=true` +// blocking MakeCredential. With no PIN, `pin_prechecks` short-circuits and +// `setMinPINLength` itself accepts unauthenticated calls per §6.11 step 4 +// (the spec's pre-issuance configuration path). + +/// Factory-default helper: setMinPINLength without a PIN/UV token. CTAP 2.1 +/// §6.11 step 4 allows this when the authenticator isn't yet "protected by +/// some form of user verification" — i.e. clientPin is false and alwaysUv +/// is false (the alwaysUv side lands in commit 2544f91). +fn set_min_pin_length_unauthenticated( + device: &Ctap2, + params: AuthenticatorConfigParams, +) -> Result<(), Ctap2Error> { + let mut request = AuthenticatorConfig::new(0x03); // SetMinPINLength + request.subcommand_params = Some(params); + // No pin_auth / pin_protocol — exercising the factory-default bypass. + device.exec(request).map(|_| ()) +} + +/// CTAP 2.1 §10.1.2.1: when the requesting RP-ID is on the allowlist +/// configured via `setMinPINLength`, the authenticator MUST include the +/// current `minPINLength` in the `make_credential` response extensions. +#[test] +fn test_min_pin_length_extension_rp_in_list_returns_value() { + let target_rp = "example.com"; + virt::run_ctap2(|device| { + // Factory default: tighten min and allowlist target_rp without + // touching PIN. + let params = AuthenticatorConfigParams { + new_min_pin_length: Some(6), + min_pin_length_rp_ids: Some(vec![target_rp.to_owned()]), + ..Default::default() + }; + set_min_pin_length_unauthenticated(&device, params).unwrap(); + + let client_data_hash = &[0u8; 32]; + let rp = Rp::new(target_rp); + let user = User::new(b"id").name("u").display_name("U"); + let pub_key_cred_params = vec![PubKeyCredParam::new("public-key", -7)]; + let mut request = MakeCredential::new(client_data_hash, rp, user, pub_key_cred_params); + request.extensions = Some(MakeCredentialExtensionsInput::default().min_pin_length(true)); + + let response = device.exec(request).unwrap(); + let extensions = response.auth_data.extensions.expect("extensions present"); + let value = extensions + .get("minPinLength") + .expect("minPinLength present"); + assert_eq!(value, &Value::from(6u8)); + }) +} + +/// CTAP 2.1 §10.1.2.1: when the requesting RP-ID is NOT on the +/// `setMinPINLength` allowlist, the authenticator MUST NOT return the +/// extension value (spec: "return without the extension output"). +#[test] +fn test_min_pin_length_extension_rp_not_in_list_omits() { + virt::run_ctap2(|device| { + let params = AuthenticatorConfigParams { + new_min_pin_length: Some(6), + min_pin_length_rp_ids: Some(vec!["allowed.example".to_owned()]), + ..Default::default() + }; + set_min_pin_length_unauthenticated(&device, params).unwrap(); + + let client_data_hash = &[0u8; 32]; + let rp = Rp::new("other.example"); + let user = User::new(b"id").name("u").display_name("U"); + let pub_key_cred_params = vec![PubKeyCredParam::new("public-key", -7)]; + let mut request = MakeCredential::new(client_data_hash, rp, user, pub_key_cred_params); + request.extensions = Some(MakeCredentialExtensionsInput::default().min_pin_length(true)); + + let response = device.exec(request).unwrap(); + match response.auth_data.extensions { + None => {} + Some(map) => assert!( + !map.contains_key("minPinLength"), + "minPinLength should be omitted for non-allowlisted RPs, got {map:?}" + ), + } + }) +} + +/// CTAP 2.1 §6.11.4 step 2.7: `minPinLengthRPIDs` replaces the stored list +/// rather than appending. We verify via the extension: after replacement, +/// the old RP-ID no longer receives the extension value. +#[test] +fn test_set_min_pin_length_rp_ids_replace_not_append() { + let first_rp = "first.example"; + let second_rp = "second.example"; + virt::run_ctap2(|device| { + // 1) Tighten min and allowlist `first.example` only. + set_min_pin_length_unauthenticated( + &device, + AuthenticatorConfigParams { + new_min_pin_length: Some(6), + min_pin_length_rp_ids: Some(vec![first_rp.to_owned()]), + ..Default::default() + }, + ) + .unwrap(); + // 2) Replace with `second.example`. + set_min_pin_length_unauthenticated( + &device, + AuthenticatorConfigParams { + new_min_pin_length: None, + min_pin_length_rp_ids: Some(vec![second_rp.to_owned()]), + ..Default::default() + }, + ) + .unwrap(); + + // first.example must no longer be allowlisted. + let client_data_hash = &[0u8; 32]; + let user = User::new(b"id").name("u").display_name("U"); + let pub_key_cred_params = vec![PubKeyCredParam::new("public-key", -7)]; + + let mut req1 = MakeCredential::new( + client_data_hash, + Rp::new(first_rp), + user.clone(), + pub_key_cred_params.clone(), + ); + req1.extensions = Some(MakeCredentialExtensionsInput::default().min_pin_length(true)); + let response1 = device.exec(req1).unwrap(); + match response1.auth_data.extensions { + None => {} + Some(map) => assert!( + !map.contains_key("minPinLength"), + "first.example dropped from list but still got extension: {map:?}" + ), + } + + // second.example must now be allowlisted. + let mut req2 = MakeCredential::new( + client_data_hash, + Rp::new(second_rp), + user, + pub_key_cred_params, + ); + req2.extensions = Some(MakeCredentialExtensionsInput::default().min_pin_length(true)); + let response2 = device.exec(req2).unwrap(); + let extensions = response2 + .auth_data + .extensions + .expect("extensions present for second.example"); + assert_eq!( + extensions.get("minPinLength"), + Some(&Value::from(6u8)), + "second.example should be on the new allowlist" + ); + }) +} + +/// CTAP 2.1 §6.11.4 step 2.5: force `forcePINChange=true` only when +/// `PINCodePointLength` is less than `newMinPINLength`. When the +/// existing PIN already meets the new minimum, the flag stays cleared. +#[test] +fn test_set_min_pin_length_pin_meets_new_min_no_force_change() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456789"; // 9 chars, already > new floor of 6 + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + + // Before: GetInfo.forcePinChange should be false. + let reply = device.exec(GetInfo).unwrap(); + assert_eq!(reply.force_pin_change, Some(false)); + + // Tighten to 6 — the existing 9-code-point PIN still meets the new + // floor, so step 2.5 must not flip forcePINChange. + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + let params = AuthenticatorConfigParams { + new_min_pin_length: Some(6), + ..Default::default() + }; + set_min_pin_length(&device, &pin_token, params).unwrap(); + + // After: forcePinChange is still false because PINCodePointLength + // (9) is not less than newMinPINLength (6). + let reply = device.exec(GetInfo).unwrap(); + assert_eq!(reply.force_pin_change, Some(false)); + }) +} diff --git a/tests/webauthn/mod.rs b/tests/webauthn/mod.rs index 99604bb..d89c3eb 100644 --- a/tests/webauthn/mod.rs +++ b/tests/webauthn/mod.rs @@ -458,6 +458,7 @@ pub struct MakeCredentialExtensionsInput { pub hmac_secret: Option, pub third_party_payment: Option, pub cred_blob: Option>, + pub min_pin_length: Option, } impl MakeCredentialExtensionsInput { @@ -465,6 +466,11 @@ impl MakeCredentialExtensionsInput { self.cred_blob = Some(cred_blob); self } + + pub fn min_pin_length(mut self, min_pin_length: bool) -> Self { + self.min_pin_length = Some(min_pin_length); + self + } } impl From for Value { @@ -476,6 +482,9 @@ impl From for Value { if let Some(hmac_secret) = extensions.hmac_secret { map.push("hmac-secret", hmac_secret); } + if let Some(min_pin_length) = extensions.min_pin_length { + map.push("minPinLength", min_pin_length); + } if let Some(third_party_payment) = extensions.third_party_payment { map.push("thirdPartyPayment", third_party_payment); } @@ -942,6 +951,8 @@ pub struct GetInfoReply { pub aaguid: Value, pub options: Option>, pub pin_protocols: Option>, + pub force_pin_change: Option, + pub min_pin_length: Option, pub attestation_formats: Option>, } @@ -953,6 +964,10 @@ impl From for GetInfoReply { aaguid: map.remove(&3).unwrap().deserialized().unwrap(), options: map.remove(&4).map(|value| value.deserialized().unwrap()), pin_protocols: map.remove(&6).map(|value| value.deserialized().unwrap()), + // 0x0C: forcePINChange (CTAP 2.1) + force_pin_change: map.remove(&0x0C).map(|value| value.deserialized().unwrap()), + // 0x0D: minPINLength (CTAP 2.1) + min_pin_length: map.remove(&0x0D).map(|value| value.deserialized().unwrap()), attestation_formats: map.remove(&0x16).map(|value| value.deserialized().unwrap()), } } @@ -1062,3 +1077,101 @@ impl From for CredentialManagementReply { } } } + +// ============================================================================ +// authenticatorConfig (CTAP 2.1 §6.11) +// ============================================================================ + +/// `authenticatorConfig` (CTAP 2.1 §6.11), command 0x0D. +pub struct AuthenticatorConfig { + pub subcommand: u8, + pub subcommand_params: Option, + pub pin_protocol: Option, + pub pin_auth: Option<[u8; 32]>, +} + +impl AuthenticatorConfig { + pub fn new(subcommand: u8) -> Self { + Self { + subcommand, + subcommand_params: None, + pin_protocol: None, + pin_auth: None, + } + } + + /// CTAP 2.1 §6.11.4 step 3.a: `pinUvAuthData = 32×0xff || 0x0d || + /// uint8(subCommand) || subCommandParams (CBOR)`. The platform HMACs this + /// exact byte string with the pin-uv-auth token. + pub fn pin_uv_auth_data(&self) -> Vec { + let mut data = vec![0xff; 32]; + data.push(0x0d); + data.push(self.subcommand); + if let Some(params) = &self.subcommand_params { + let mut buf = Vec::new(); + ciborium::into_writer(&Value::from(params.clone()), &mut buf).unwrap(); + data.extend_from_slice(&buf); + } + data + } +} + +impl From for Value { + fn from(request: AuthenticatorConfig) -> Value { + let mut map = Map::default(); + map.push(1, request.subcommand); + if let Some(params) = request.subcommand_params { + map.push(2, params); + } + if let Some(pin_protocol) = request.pin_protocol { + map.push(3, pin_protocol); + } + if let Some(pin_auth) = request.pin_auth { + map.push(4, pin_auth.as_slice()); + } + map.into() + } +} + +impl Request for AuthenticatorConfig { + const COMMAND: u8 = 0x0D; + + type Reply = AuthenticatorConfigReply; +} + +/// `authenticatorConfig` response — the spec defines no body, just a status +/// byte. We keep an empty marker so the `Request` trait is satisfied. +pub struct AuthenticatorConfigReply; + +impl From for AuthenticatorConfigReply { + fn from(_value: Value) -> Self { + Self + } +} + +/// `SubcommandParameters` for `authenticatorConfig`. Mirrors +/// `ctap_types::ctap2::authenticator_config::SubcommandParameters` but with +/// owned values for ergonomics. +#[derive(Clone, Default)] +pub struct AuthenticatorConfigParams { + pub new_min_pin_length: Option, + pub min_pin_length_rp_ids: Option>, + pub force_change_pin: Option, +} + +impl From for Value { + fn from(params: AuthenticatorConfigParams) -> Value { + let mut map = Map::default(); + if let Some(v) = params.new_min_pin_length { + map.push(1, v); + } + if let Some(ids) = params.min_pin_length_rp_ids { + let values: Vec = ids.into_iter().map(Value::from).collect(); + map.push(2, Value::Array(values)); + } + if let Some(force) = params.force_change_pin { + map.push(3, force); + } + map.into() + } +} From 96a96316f070bb90820b72c83ee95baabf5c7825 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Sat, 23 May 2026 13:21:55 -0400 Subject: [PATCH 4/6] =?UTF-8?q?ctap2.1:=20alwaysUv=20+=20toggleAlwaysUv=20?= =?UTF-8?q?(CTAP=202.1=20=C2=A76.4,=20=C2=A76.11.2,=20=C2=A77.2)=20?= =?UTF-8?q?=E2=80=94=20full=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 + src/ctap1.rs | 14 ++ src/ctap2.rs | 78 +++++++++-- src/state.rs | 20 +++ tests/basic.rs | 324 ++++++++++++++++++++++++++++++++++++++++++++++ tests/virt/mod.rs | 17 +++ 6 files changed, 441 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13b87b7..d6ecc02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Implement these new extensions: - `credBlob` - `minPinLength` +- Implement the `alwaysUv` feature. - Implement the `config` command with these subcommands: + - `toggleAlwaysUv` - `setMinPINLength` - Load full credential from filesstem for getAssertion if an allow list is used with a discoverable credential. diff --git a/src/ctap1.rs b/src/ctap1.rs index 50e4d0c..35092f3 100644 --- a/src/ctap1.rs +++ b/src/ctap1.rs @@ -33,6 +33,15 @@ impl Authenticator for crate::Authenti /// Also note that CTAP1 credentials should be assertable over CTAP2. I believe this is /// currently not the case. fn register(&mut self, reg: ®ister::Request) -> Result { + // CTAP 2.1 §7.2.4 step 2: when alwaysUv is enabled, U2F_REGISTER and + // U2F_AUTHENTICATE MUST immediately fail with SW_COMMAND_NOT_ALLOWED + // (0x6986). Our device has no built-in UV, so alwaysUv unconditionally + // disables CTAP1/U2F. The matching `getInfo` change (drop "U2F_V2" + // from `versions`) lives in `src/ctap2.rs`. + if self.state.persistent.always_uv() { + return Err(Error::CommandNotAllowedNoEf); + } + self.up .user_present(&mut self.trussed, constants::U2F_UP_TIMEOUT) .map_err(|_| Error::ConditionsOfUseNotSatisfied)?; @@ -149,6 +158,11 @@ impl Authenticator for crate::Authenti } fn authenticate(&mut self, auth: &authenticate::Request) -> Result { + // CTAP 2.1 §7.2.4 step 2: see `register` above. + if self.state.persistent.always_uv() { + return Err(Error::CommandNotAllowedNoEf); + } + let cred = Credential::try_from_bytes(self, auth.app_id, auth.key_handle); let user_presence_byte = match auth.control_byte { diff --git a/src/ctap2.rs b/src/ctap2.rs index a7818d1..228ffe7 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -52,7 +52,14 @@ impl Authenticator for crate::Authenti debug_now!("remaining stack size: {} bytes", msp() - 0x2000_0000); let mut versions = Vec::new(); - versions.push(Version::U2fV2).unwrap(); + // CTAP 2.1 §7.2.4: when alwaysUv is enabled the authenticator MUST + // disable CTAP1/U2F unless it has a built-in UV method (we don't). + // Step 1 of that section says "U2F_V2 MUST NOT appear in versions". + // The matching dispatch-level reject (SW_COMMAND_NOT_ALLOWED on + // U2F_REGISTER / U2F_AUTHENTICATE) lives in `src/ctap1.rs`. + if !self.state.persistent.always_uv() { + versions.push(Version::U2fV2).unwrap(); + } versions.push(Version::Fido2_0).unwrap(); versions.push(Version::Fido2_1).unwrap(); @@ -82,9 +89,16 @@ 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); + // CTAP 2.1 §6.11.2 toggleAlwaysUv: when alwaysUv is enabled, the + // authenticator MUST report makeCredUvNotRqd as false. We couple the + // two here so the toggle subcommand doesn't have to update two pieces + // of state. The authenticator's "default" for makeCredUvNotRqd (when + // alwaysUv is disabled) is true — non-discoverable MC without UV is + // allowed by this authenticator. + options.make_cred_uv_not_rqd = Some(!self.state.persistent.always_uv()); options.authnr_cfg = Some(true); options.set_min_pin_length = Some(true); + options.always_uv = Some(self.state.persistent.always_uv()); let mut transports = Vec::new(); if self.config.nfc_transport { @@ -619,11 +633,10 @@ impl Authenticator for crate::Authenti // 2. If the authenticator does not support the subcommand being // invoked, per subCommand's value, return CTAP1_ERR_INVALID_PARAMETER. - // ToggleAlwaysUv lands with the alwaysUv audit2 commit; // EnableLongTouchForReset lands with the long-touch reset commit. // EnterpriseAttestation / VendorPrototype are not supported. match request.sub_command { - Subcommand::SetMinPINLength => {} + Subcommand::SetMinPINLength | Subcommand::ToggleAlwaysUv => {} _ => return Err(Error::InvalidParameter), } @@ -634,18 +647,26 @@ impl Authenticator for crate::Authenti // then go to Step 5. // Note: This allows for initial configuration of authenticators // that have the Always UV feature enabled by default. - // (Not reachable here: toggleAlwaysUv is filtered out by step 2 - // until the alwaysUv audit2 commit lands.) + // We have no built-in UV, so "protected by some form of UV" + // reduces to clientPin being set. This bypass is the platform's + // exit hatch when alwaysUv was pre-flashed and no PIN has been + // configured yet — it lets the user clear alwaysUv without + // first being forced through PIN setup. + let toggle_always_uv_bypass = matches!(request.sub_command, Subcommand::ToggleAlwaysUv) + && !self.state.persistent.pin_is_set() + && self.state.persistent.always_uv(); // 4. If the authenticator is protected by some form of user // verification or the alwaysUv option ID is present and true: - // We have no built-in UV here, and alwaysUv lands in a follow-up - // audit2 commit, so "protected by some form of UV" reduces to - // clientPin being set. In factory-default state (no PIN) the - // block is skipped per the note after step 6: "authenticatorConfig - // can be invoked without user verification if user verification - // is not configured, and the Always UV feature is disabled." - if self.state.persistent.pin_is_set() { + // We have no built-in UV, so "protected by some form of UV" + // reduces to clientPin being set. In factory-default state + // (no PIN, alwaysUv off) the block is skipped per the note + // after step 6: "authenticatorConfig can be invoked without user + // verification if user verification is not configured, and the + // Always UV feature is disabled." + if !toggle_always_uv_bypass + && (self.state.persistent.pin_is_set() || self.state.persistent.always_uv()) + { // 4.1. If pinUvAuthParam is absent from the input map, then // end the operation by returning CTAP2_ERR_PUAT_REQUIRED. let pin_auth = request.pin_auth.ok_or(Error::PinRequired)?; @@ -691,6 +712,7 @@ impl Authenticator for crate::Authenti // as defined in each subcommand subsection below. match request.sub_command { Subcommand::SetMinPINLength => self.config_set_min_pin_length(request), + Subcommand::ToggleAlwaysUv => self.state.persistent.toggle_always_uv(&mut self.trussed), // Step 2 filtered every other variant. `Subcommand` is // `#[non_exhaustive]` so the catch-all is still required. _ => Err(Error::InvalidParameter), @@ -1110,7 +1132,18 @@ impl Authenticator for crate::Authenti ) { Ok(b) => b, Err(Error::PinRequired) => { - // UV is optional for get_assertion + // UV is optional for `getAssertion` by default — pin_prechecks + // raises PinRequired for the "RK + clientPin set + no pin_auth" + // case, and the spec lets GA proceed without UV. The + // alwaysUv branch (CTAP 2.1 §6.2.2 step 5) is already + // enforced inside pin_prechecks — it inspects the + // permissions parameter and the request's `up` option to + // honour the "up must be true" condition, so any + // alwaysUv-driven `PinRequired` reaching us here has + // already been adjudicated and we just propagate it. + if self.state.persistent.always_uv() { + return Err(Error::PinRequired); + } false } Err(err) => return Err(err), @@ -1665,6 +1698,23 @@ impl crate::Authenticator { return Err(Error::PinPolicyViolation); } + // 0b. CTAP 2.1 §6.1.2 step 6 / §6.2.2 step 5: when alwaysUv is + // enabled, both MC and GA must reject a missing pinUvAuthParam with + // CTAP2_ERR_PUAT_REQUIRED (wire 0x36 — `Error::PinRequired` is + // ctap-types' legacy name). Subtle difference: §6.2.2 step 5 only + // applies when the "up" option is true (the default), so an + // explicit `up=false` GA (a silent pre-flight check) bypasses the + // alwaysUv UV requirement per spec. MC has no such carve-out — + // `up=Some(false)` is rejected upstream with INVALID_OPTION, so we + // only need to skip the up check for non-GA permissions. + if self.state.persistent.always_uv() && pin_auth.is_none() { + let is_ga = permissions == Permissions::GET_ASSERTION; + let up_true = !is_ga || options.as_ref().and_then(|o| o.up).unwrap_or(true); + if up_true { + return Err(Error::PinRequired); + } + } + // 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 55981b5..c5b8cc3 100644 --- a/src/state.rs +++ b/src/state.rs @@ -299,6 +299,12 @@ pub struct PersistentState { /// successfully calls `clientPin.changePIN`. #[serde(default)] force_pin_change: bool, + + /// `alwaysUv` (CTAP 2.1 §6.11.3). When `true`, every MakeCredential and + /// GetAssertion must carry a valid `pinUvAuthParam`; ops without UV are + /// rejected with `PinRequired`. + #[serde(default)] + always_uv: bool, } impl PersistentState { @@ -354,6 +360,11 @@ impl PersistentState { self.consecutive_pin_mismatches = 0; self.pin_hash = None; self.timestamp = 0; + // CTAP 2.1 §6.7 authenticatorReset: "Always Require User Verification" + // is explicitly listed as a feature that MUST be reset. Other §6.7 + // feature flags (min_pin_length / min_pin_length_rp_ids / + // force_pin_change) are left for a follow-up — see AUDIT.md. + self.always_uv = false; self.save(trussed) } @@ -542,6 +553,15 @@ impl PersistentState { self.save(trussed)?; Ok(()) } + + pub fn always_uv(&self) -> bool { + self.always_uv + } + + pub fn toggle_always_uv(&mut self, trussed: &mut T) -> Result<()> { + self.always_uv = !self.always_uv; + self.save(trussed) + } } impl RuntimeState { diff --git a/tests/basic.rs b/tests/basic.rs index 34c6e27..32c7cdc 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -1480,6 +1480,330 @@ fn test_set_min_pin_length_without_pin_auth_rejected_when_pin_set() { }) } +// ---------------------------------------------------------------------------- +// alwaysUv + toggleAlwaysUv (CTAP 2.1 §6.4, §6.11.2, §6.1.2 / §6.2.2) +// ---------------------------------------------------------------------------- + +fn toggle_always_uv(device: &Ctap2, pin_token: &PinToken) -> Result<(), Ctap2Error> { + let mut request = AuthenticatorConfig::new(0x02); // ToggleAlwaysUv + request.pin_protocol = Some(2); + request.pin_auth = Some(pin_token.authenticate(&request.pin_uv_auth_data())); + device.exec(request).map(|_| ()) +} + +/// GetInfo on a fresh device advertises `alwaysUv=false` and the coupled +/// `makeCredUvNotRqd=true` (CTAP 2.1 §6.4 + §6.11.2 coupling). +#[test] +fn test_always_uv_default() { + virt::run_ctap2(|device| { + let reply = device.exec(GetInfo).unwrap(); + let options = reply.options.unwrap(); + assert_eq!(options.get("alwaysUv"), Some(&Value::from(false))); + assert_eq!(options.get("makeCredUvNotRqd"), Some(&Value::from(true))); + }) +} + +/// toggleAlwaysUv flips `alwaysUv` true and forces `makeCredUvNotRqd` false +/// in the same GetInfo (CTAP 2.1 §6.11.2 mandates the coupling). A second +/// toggle restores both. +#[test] +fn test_always_uv_toggle_couples_make_cred_uv_not_rqd() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + toggle_always_uv(&device, &pin_token).unwrap(); + + let reply = device.exec(GetInfo).unwrap(); + let options = reply.options.unwrap(); + assert_eq!(options.get("alwaysUv"), Some(&Value::from(true))); + assert_eq!(options.get("makeCredUvNotRqd"), Some(&Value::from(false))); + + // Toggle OFF. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + toggle_always_uv(&device, &pin_token).unwrap(); + + let reply = device.exec(GetInfo).unwrap(); + let options = reply.options.unwrap(); + assert_eq!(options.get("alwaysUv"), Some(&Value::from(false))); + assert_eq!(options.get("makeCredUvNotRqd"), Some(&Value::from(true))); + }) +} + +/// With `alwaysUv` enabled, `makeCredential` without a `pinUvAuthParam` MUST +/// be rejected with CTAP2_ERR_PUAT_REQUIRED (0x36) per CTAP 2.1 §6.1.2. +#[test] +fn test_always_uv_make_credential_without_pin_auth_is_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + toggle_always_uv(&device, &pin_token).unwrap(); + + let request = MakeCredential::new( + vec![0; 32], + Rp::new("example.com"), + User::new(vec![1; 16]), + vec![PubKeyCredParam::new("public-key", -7)], + ); + let result = device.exec(request); + assert_eq!(result.err(), Some(Ctap2Error(0x36))); + }) +} + +/// Same as above but for `getAssertion` (CTAP 2.1 §6.2.2). +/// +/// Setup: make an RK (no PIN yet, so MC needs no pin_auth), then set the PIN, +/// then toggle alwaysUv. Now GA without pin_auth should be rejected. This +/// order avoids the more complex pin-auth-on-MC path. +#[test] +fn test_always_uv_get_assertion_without_pin_auth_is_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + let rp_id = "example.com"; + virt::run_ctap2(|device| { + // Make an RK first (no PIN set; MC needs no pin_auth in this state). + let client_data_hash = vec![0u8; 32]; + let mut mc = MakeCredential::new( + client_data_hash.clone(), + Rp::new(rp_id), + User::new(vec![1; 16]), + vec![PubKeyCredParam::new("public-key", -7)], + ); + mc.options = Some(MakeCredentialOptions::default().rk(true)); + device.exec(mc).unwrap(); + + // Set the PIN and enable alwaysUv. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + toggle_always_uv(&device, &pin_token).unwrap(); + + // GA without pin_auth must fail with PUAT_REQUIRED. + let ga = GetAssertion::new(rp_id.to_owned(), client_data_hash); + let result = device.exec(ga); + assert_eq!(result.err(), Some(Ctap2Error(0x36))); + }) +} + +/// CTAP 2.1 §7.2.4 step 1: when `alwaysUv` is enabled the authenticator +/// MUST NOT include `"U2F_V2"` in its `getInfo.versions` array (it is +/// effectively required to disable CTAP1/U2F because we don't ship a +/// built-in UV method). Verify the version is present pre-toggle and +/// removed post-toggle. +#[test] +fn test_always_uv_u2f_v2_dropped_from_versions() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + // Pre-toggle: U2F_V2 must be advertised. + let reply = device.exec(GetInfo).unwrap(); + assert!( + reply.versions.contains(&"U2F_V2".to_owned()), + "fresh device must advertise U2F_V2, got versions={:?}", + reply.versions + ); + + // Enable alwaysUv. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + toggle_always_uv(&device, &pin_token).unwrap(); + + // Post-toggle: U2F_V2 MUST be absent. + let reply = device.exec(GetInfo).unwrap(); + assert!( + !reply.versions.contains(&"U2F_V2".to_owned()), + "U2F_V2 must be removed once alwaysUv is true, got versions={:?}", + reply.versions + ); + // The CTAP2 versions must still be present. + assert!(reply.versions.contains(&"FIDO_2_0".to_owned())); + assert!(reply.versions.contains(&"FIDO_2_1".to_owned())); + }) +} + +/// CTAP 2.1 §7.2.4 step 2: when alwaysUv is enabled, U2F_REGISTER MUST +/// fail with SW_COMMAND_NOT_ALLOWED (0x6986). +#[test] +fn test_always_uv_u2f_register_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + // Enable alwaysUv. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + toggle_always_uv(&device, &pin_token).unwrap(); + + // U2F_REGISTER APDU (extended length): + // CLA=00 INS=01 P1=00 P2=00 | extended Lc=00 0040 | 64-byte data + // | extended Le=0000 + let mut apdu: Vec = vec![0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x40]; + apdu.extend_from_slice(&[0u8; 64]); // challenge ‖ app_id (zeros are fine — we reject before parsing) + apdu.extend_from_slice(&[0x00, 0x00]); + let status = device + .ctap1(&apdu) + .expect_err("U2F_REGISTER must fail when alwaysUv is enabled"); + assert_eq!( + status, 0x6986, + "expected SW_COMMAND_NOT_ALLOWED, got {:#x}", + status + ); + }) +} + +/// CTAP 2.1 §7.2.4 step 2: same for U2F_AUTHENTICATE. +#[test] +fn test_always_uv_u2f_authenticate_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + // Enable alwaysUv. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + toggle_always_uv(&device, &pin_token).unwrap(); + + // U2F_AUTHENTICATE APDU (extended length, control byte = 0x03 + // EnforceUserPresenceAndSign): + // CLA=00 INS=02 P1=03 P2=00 | extended Lc=00 0041 | + // challenge(32) ‖ app_id(32) ‖ kh_len(1=0) | extended Le=0000 + let mut apdu: Vec = vec![0x00, 0x02, 0x03, 0x00, 0x00, 0x00, 0x41]; + apdu.extend_from_slice(&[0u8; 64]); // challenge ‖ app_id + apdu.push(0x00); // kh_len = 0 (no keyhandle); we reject before reading it + apdu.extend_from_slice(&[0x00, 0x00]); + let status = device + .ctap1(&apdu) + .expect_err("U2F_AUTHENTICATE must fail when alwaysUv is enabled"); + assert_eq!( + status, 0x6986, + "expected SW_COMMAND_NOT_ALLOWED, got {:#x}", + status + ); + }) +} + +/// CTAP 2.1 §6.2.2 step 5 carve-out: when `alwaysUv=true` and the +/// platform sends `up=Some(false)` (a silent pre-flight check), the +/// alwaysUv UV requirement is bypassed per the spec ("If the alwaysUv +/// option ID is present and true and the 'up' option is present and +/// true then …"). Verify that GA with `up=false` does not get a +/// PUAT_REQUIRED back even though no pinUvAuthParam is sent. +#[test] +fn test_always_uv_get_assertion_up_false_bypasses_uv_requirement() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + let rp_id = "example.com"; + virt::run_ctap2(|device| { + // Make an RK first (no PIN yet so MC needs no pin_auth). + let client_data_hash = vec![0u8; 32]; + let mut mc = MakeCredential::new( + client_data_hash.clone(), + Rp::new(rp_id), + User::new(vec![1; 16]), + vec![PubKeyCredParam::new("public-key", -7)], + ); + mc.options = Some(MakeCredentialOptions::default().rk(true)); + let mc_reply = device.exec(mc).unwrap(); + let credential = mc_reply.auth_data.credential.unwrap(); + + // Set PIN and enable alwaysUv. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + toggle_always_uv(&device, &pin_token).unwrap(); + + // GA with up=Some(false) and no pin_auth — alwaysUv check must + // be bypassed (spec §6.2.2 step 5 only applies when up=true). + let mut ga = GetAssertion::new(rp_id.to_owned(), client_data_hash); + ga.options = Some(GetAssertionOptions { + up: Some(false), + uv: None, + }); + ga.allow_list = Some(vec![PubKeyCredDescriptor::new( + "public-key", + credential.id.clone(), + )]); + let result = device.exec(ga); + assert!( + result.is_ok(), + "up=false GA must bypass alwaysUv UV requirement, got {:?}", + result.err() + ); + }) +} + /// User-requested test: a `setMinPINLength` request derived from an INCORRECT /// PIN must fail — the platform never obtains a valid `pin_uv_auth_token`, /// so the `pin_auth` HMAC won't verify on the device side. diff --git a/tests/virt/mod.rs b/tests/virt/mod.rs index 8a2b54e..586d7b6 100644 --- a/tests/virt/mod.rs +++ b/tests/virt/mod.rs @@ -140,6 +140,23 @@ pub struct Options { pub struct Ctap2<'a>(ctaphid::Device>); impl Ctap2<'_> { + /// Send a raw CTAP1 (U2F) APDU and return the response body / SW + /// status. Used by tests that need to verify CTAP1-level behaviour + /// from within a CTAP2 test setup (e.g. the `alwaysUv` § 7.2.4 + /// "disable U2F" path, where toggling `alwaysUv` requires CTAP2 but + /// the side effect is observable on the U2F dispatch). + pub fn ctap1(&self, apdu: &[u8]) -> Result, u16> { + let mut response = self.0.ctap1(apdu).unwrap(); + let low = response.pop().unwrap(); + let high = response.pop().unwrap(); + let status = u16::from_be_bytes([high, low]); + if status == 0x9000 { + Ok(response) + } else { + Err(status) + } + } + pub fn exec(&self, request: R) -> Result { let operation = Operation::try_from(R::COMMAND) .map(|op| format!("{op:?}")) From e3c79dfd85bbac82cee8f4c431e8a3f9c97e9b68 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Sat, 23 May 2026 15:23:36 -0400 Subject: [PATCH 5/6] =?UTF-8?q?ctap2.1:=20=C2=A76.5.5=20PIN=20management?= =?UTF-8?q?=20=E2=80=94=20code-point=20validation=20+=20reject=20same-PIN?= =?UTF-8?q?=20under=20forcePINChange=20+=20align=20setPin/getPinToken=20er?= =?UTF-8?q?ror=20codes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: https://github.com/trussed-dev/fido-authenticator/issues/43 --- CHANGELOG.md | 1 + src/ctap2.rs | 88 ++++++++++-- src/state.rs | 13 +- tests/basic.rs | 308 +++++++++++++++++++++++++++++++++++++++++- tests/webauthn/mod.rs | 15 +- 5 files changed, 406 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ecc02..aab39ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `toggleAlwaysUv` - `setMinPINLength` - Load full credential from filesstem for getAssertion if an allow list is used with a discoverable credential. +- Use UTF-8 code points instead of bytes when checking the minimum length for PINs. ## [v0.3.0](https://github.com/trussed-dev/fido-authenticator/releases/tag/v0.3.0) (2026-03-25) diff --git a/src/ctap2.rs b/src/ctap2.rs index 228ffe7..07c0116 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -772,8 +772,11 @@ impl Authenticator for crate::Authenti let pin_protocol = pin_protocol?; // 2. is pin already set + // CTAP 2.1 §6.5.5.4 step 3: a setPin request against an + // already-provisioned authenticator returns PinAuthInvalid. + // (Older CTAP 2.0 implementations returned NotAllowed.) if self.state.persistent.pin_is_set() { - return Err(Error::NotAllowed); + return Err(Error::PinAuthInvalid); } // 3. generate shared secret @@ -862,8 +865,40 @@ impl Authenticator for crate::Authenti shared_secret.delete(&mut self.trussed); - // 9. store hashed PIN - self.hash_store_pin(&new_pin)?; + // 8b. CTAP 2.1 §6.5.5.6: "If the forcePINChange member ... is + // true and LEFT(SHA-256(newPin), 16) is equal to its internal + // stored LEFT(SHA-256(curPin), 16) then authenticator returns + // CTAP2_ERR_PIN_POLICY_VIOLATION." We compute the new hash up + // front so the comparison is constant-time on a fixed-size + // array, and only return the error when force_pin_change is + // set — same-PIN change with the flag clear is allowed. + let new_pin_hash_32 = syscall!(self.trussed.hash_sha256(&new_pin)).hash; + let new_pin_hash: [u8; 16] = new_pin_hash_32[..16].try_into().unwrap(); + if self.state.persistent.force_pin_change() + && self.state.persistent.pin_hash() == Some(new_pin_hash) + { + return Err(Error::PinPolicyViolation); + } + + // 9. store hashed PIN + PINCodePointLength + // (CTAP 2.1 §6.5.5.5 — "Save the PIN with derived hash + // and PINCodePointLength"). `new_pin` was UTF-8-validated + // in `decrypt_pin_check_length` above, so the from_utf8 + // is infallible here; the unwrap_or is defensive. + let new_pin_code_point_length = core::str::from_utf8(&new_pin) + .map(|s| s.chars().count()) + .unwrap_or(new_pin.len()) as u8; + self.state.persistent.set_pin_hash( + &mut self.trussed, + new_pin_hash, + new_pin_code_point_length, + )?; + + // CTAP 2.1 §6.5.5.6 step 9: clear forcePINChange after a + // successful changePin. + self.state + .persistent + .set_force_pin_change(&mut self.trussed, false)?; self.pin_protocol(pin_protocol).reset_pin_tokens(); } @@ -913,7 +948,12 @@ impl Authenticator for crate::Authenti // 10. Reset PIN retries self.state.reset_retries(&mut self.trussed)?; - // 11. Check forcePINChange -- skipped + // 11. CTAP 2.1 §6.5.5.7.1 step 11: while forcePINChange is + // set, getPinToken returns PIN_INVALID until a successful + // changePin clears the flag. + if self.state.persistent.force_pin_change() { + return Err(Error::PinInvalid); + } // 12. Reset all PIN tokens // 13. Call beginUsingPinUvAuthToken @@ -993,7 +1033,12 @@ impl Authenticator for crate::Authenti // 10. Reset PIN retries self.state.reset_retries(&mut self.trussed)?; - // 11. Check forcePINChange -- skipped + // 11. CTAP 2.1 §6.5.5.7.3 step 11: while forcePINChange is + // set, this variant returns PIN_POLICY_VIOLATION (distinct + // from getPinToken's PIN_INVALID; see §6.5.5.7.1). + if self.state.persistent.force_pin_change() { + return Err(Error::PinPolicyViolation); + } // 12. Reset all PIN tokens // 13. Call beginUsingPinUvAuthToken @@ -1579,18 +1624,29 @@ impl crate::Authenticator { .decrypt(&mut self.trussed, pin_enc) .ok_or(Error::Other)?; - // // temp - // let pin_length = pin.iter().position(|&b| b == b'\0').unwrap_or(pin.len()); - // info_now!("pin.len() = {}, pin_length = {}, = {:?}", - // 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().into(); - if pin_length < min_pin_length || pin_length >= 64 { + // CTAP 2.1 §6.5.5.5 / §6.5.5.6: "The authenticator drops all + // **trailing** 0x00 bytes from paddedNewPin to produce newPin." + // Embedded nulls stay (they will fail UTF-8 validation if invalid). + let stripped_len = pin.iter().rposition(|&b| b != 0).map_or(0, |last| last + 1); + + // CTAP 2.1 §6.5.5.3: "Maximum PIN Length: 63 bytes." + if stripped_len > ctap2::client_pin::MAX_PIN_LENGTH { return Err(Error::PinPolicyViolation); } - pin.resize_zero(pin_length).unwrap(); + // Issue #43: minimum PIN length is measured in **Unicode code points**, + // not bytes. UTF-8-decode the stripped bytes and count `chars()`. A + // platform that sends non-UTF-8 bytes violates the spec; we reject + // with the same PIN_POLICY_VIOLATION code we use for length issues. + let s = + core::str::from_utf8(&pin[..stripped_len]).map_err(|_| Error::PinPolicyViolation)?; + let code_points = s.chars().count(); + let min_pin_length = usize::from(self.state.persistent.min_pin_length()); + if code_points < min_pin_length { + return Err(Error::PinPolicyViolation); + } + + pin.resize_zero(stripped_len).unwrap(); Ok(pin) } @@ -1720,6 +1776,8 @@ impl crate::Authenticator { // // the idea is for multi-authnr scenario where platform // wants to enforce PIN and needs to figure out which authnrs support PIN + // (CTAP 2.1 §6.5.5.7 step 2 — was upstream PR #56; the older + // CTAP 2.0 reading was `PinAuthInvalid` for the "pin set" case.) if let Some(pin_auth) = pin_auth { if pin_auth.is_empty() { self.up @@ -1727,7 +1785,7 @@ impl crate::Authenticator { if !self.state.persistent.pin_is_set() { return Err(Error::PinNotSet); } else { - return Err(Error::PinAuthInvalid); + return Err(Error::PinInvalid); } } } diff --git a/src/state.rs b/src/state.rs index c5b8cc3..3223000 100644 --- a/src/state.rs +++ b/src/state.rs @@ -487,10 +487,21 @@ impl PersistentState { pin_hash: [u8; 16], pin_code_point_length: u8, ) -> Result<()> { + // Idempotent: if the same hash is being written and forcePINChange is + // already clear, skip the flash write. Also — and more importantly — + // if the platform "changes" the PIN to the same value, we must not + // clear `force_pin_change` (the user hasn't actually complied with + // the change request). The spec-mandated reject for "forcePINChange + // + new==old" lives in the changePIN handler; this check is a belt- + // and-braces against any other caller path. + if self.pin_hash == Some(pin_hash) { + return Ok(()); + } self.pin_hash = Some(pin_hash); self.pin_code_point_length = pin_code_point_length; // Successfully (re)setting the PIN clears any pending forcePINChange - // request — the platform has just complied (CTAP 2.1 §6.5.5.7). + // request — the platform has just complied (CTAP 2.1 §6.5.5.6 / + // §6.5.5.7). self.force_pin_change = false; self.save(trussed)?; Ok(()) diff --git a/tests/basic.rs b/tests/basic.rs index 32c7cdc..f283237 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -105,8 +105,10 @@ fn test_set_pin() { let shared_secret = get_shared_secret(&device, &key_agreement_key); let result = set_pin(&device, &key_agreement_key, &shared_secret, b"123456"); - // TODO: review error code - assert_eq!(result, Err(Ctap2Error(0x30))); + // CTAP 2.1 §6.5.5.4: "If a PIN has already been set, authenticator + // returns CTAP2_ERR_PIN_AUTH_INVALID error." (0x33). Previously this + // expected 0x30 (NotAllowed), the CTAP 2.0 reading. + assert_eq!(result, Err(Ctap2Error(0x33))); let reply = device.exec(GetInfo).unwrap(); let options = reply.options.unwrap(); @@ -1804,6 +1806,308 @@ fn test_always_uv_get_assertion_up_false_bypasses_uv_requirement() { }) } +// ---------------------------------------------------------------------------- +// PIN length validation (issue #43): count Unicode code points, not bytes +// ---------------------------------------------------------------------------- + +/// Multi-byte UTF-8 PIN with FEWER code points than minimum is rejected. +/// "héé" = 5 bytes (h + é + é where é = 0xC3 0xA9), 3 code points. Default +/// minimum is 4 → PIN_POLICY_VIOLATION. +#[test] +fn test_set_pin_short_codepoints_multibyte_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + // "héé" — 5 bytes, 3 code points. Pre-fix this would have passed + // because the BYTE length (5) >= 4. The fixed code rejects it. + let pin = "héé".as_bytes(); + assert_eq!(pin.len(), 5); + assert_eq!( + pin.iter().filter(|&&b| !(0x80..0xC0).contains(&b)).count(), + 3 + ); + let result = set_pin(&device, &key_agreement_key, &shared_secret, pin); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// Multi-byte UTF-8 PIN with enough code points is accepted. +/// "héllo" = 6 bytes, 5 code points. Default minimum is 4 → succeeds. +#[test] +fn test_set_pin_codepoints_multibyte_accepted() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let pin = "héllo".as_bytes(); + assert_eq!(pin.len(), 6); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + }) +} + +/// ASCII PIN at the lower bound: 4 bytes = 4 code points. Accepted. +#[test] +fn test_set_pin_four_byte_ascii_accepted() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, b"abcd").unwrap(); + }) +} + +/// 3-byte ASCII PIN (3 code points) is rejected. +#[test] +fn test_set_pin_three_byte_ascii_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let result = set_pin(&device, &key_agreement_key, &shared_secret, b"abc"); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// Invalid UTF-8 in the PIN bytes is rejected (PIN_POLICY_VIOLATION). Platforms +/// MUST send Normalized UTF-8 per CTAP 2.1 §6.5.5.5; bytes that don't decode +/// fail the PIN policy check. +#[test] +fn test_set_pin_invalid_utf8_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + // 0xC3 is a UTF-8 lead byte that must be followed by a continuation + // byte in [0x80, 0xBF]. Trailing it with 'x' makes the sequence + // invalid UTF-8. + let pin = b"abc\xC3x"; + let result = set_pin(&device, &key_agreement_key, &shared_secret, pin); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// All-zero `paddedNewPin` strips to length 0 → 0 code points → reject. +/// Verifies the empty-PIN edge case (no leading bytes, no trailing +/// non-zero) is properly rejected against the spec floor of 4 cp. +#[test] +fn test_set_pin_empty_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let result = set_pin(&device, &key_agreement_key, &shared_secret, b""); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// CTAP 2.1 §6.5.5.5 — UTF-8 representation of newPin MUST NOT exceed 63 +/// bytes. A 63-byte ASCII PIN (63 code points) sits exactly at the spec +/// boundary and MUST be accepted. +#[test] +fn test_set_pin_at_byte_limit_accepted() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + // 63 ASCII chars → 63 bytes → 63 code points. + let pin = vec![b'a'; 63]; + set_pin(&device, &key_agreement_key, &shared_secret, &pin).unwrap(); + }) +} + +/// CTAP 2.1 §6.5.5.5: a 64-byte non-zero PIN fills `paddedNewPin` +/// completely with no trailing 0x00 — the stripped length stays at 64 +/// which exceeds the spec's 63-byte UTF-8 cap, so the authenticator +/// MUST reject with PIN_POLICY_VIOLATION. +#[test] +fn test_set_pin_over_byte_limit_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + // 64 ASCII chars → 64 bytes → no padding room left. + let pin = vec![b'a'; 64]; + let result = set_pin(&device, &key_agreement_key, &shared_secret, &pin); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// CTAP 2.1 §6.5.5.6 (changePIN) shares the same PIN-length validation +/// pipeline as setPIN (§6.5.5.5). Verify the code-point check applies +/// equally: a 3-byte ASCII new PIN under changePIN must be rejected +/// with PIN_POLICY_VIOLATION. +#[test] +fn test_change_pin_short_codepoints_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let old_pin = b"123456"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, old_pin).unwrap(); + + // Attempt to change to a 3-cp PIN. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let result = change_pin(&device, &key_agreement_key, &shared_secret, old_pin, b"abc"); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// Multi-byte UTF-8 new PIN with too few code points is also rejected +/// by changePIN (parallel to the setPin multi-byte test). "héé" = +/// 5 bytes, 3 code points → reject. +#[test] +fn test_change_pin_short_codepoints_multibyte_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let old_pin = b"123456"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, old_pin).unwrap(); + + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let new_pin = "héé".as_bytes(); + assert_eq!(new_pin.len(), 5); + let result = change_pin( + &device, + &key_agreement_key, + &shared_secret, + old_pin, + new_pin, + ); + assert_eq!(result, Err(Ctap2Error(0x37))); + }) +} + +/// CTAP 2.1 §6.5.5.6 changePIN: "If the forcePINChange member ... is true +/// and LEFT(SHA-256(newPin), 16) is equal to its internal stored +/// LEFT(SHA-256(curPin), 16) then authenticator returns +/// CTAP2_ERR_PIN_POLICY_VIOLATION." This blocks the trivial "rotate to the +/// same PIN" loophole when the platform is forcing a change. +#[test] +fn test_change_pin_same_pin_with_force_change_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + // Setup: set PIN, then mark forcePINChange via setMinPINLength. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + let params = AuthenticatorConfigParams { + force_change_pin: Some(true), + ..Default::default() + }; + set_min_pin_length(&device, &pin_token, params).unwrap(); + assert_eq!(device.exec(GetInfo).unwrap().force_pin_change, Some(true)); + + // Try to "change" to the same PIN — must be rejected. + let shared_secret = get_shared_secret(&device, &key_agreement_key); + let result = change_pin(&device, &key_agreement_key, &shared_secret, pin, pin); + assert_eq!(result, Err(Ctap2Error(0x37))); + + // forcePINChange should still be true after the rejection. + assert_eq!(device.exec(GetInfo).unwrap().force_pin_change, Some(true)); + }) +} + +/// Counterpart: when forcePINChange is **not** set, a same-PIN changePIN +/// silently succeeds (spec doesn't reject this case, only when the flag is +/// set). This documents the current behaviour and locks it in. +#[test] +fn test_change_pin_same_pin_without_force_change_allowed() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + // forcePINChange is false by default. + assert_eq!(device.exec(GetInfo).unwrap().force_pin_change, Some(false)); + + let shared_secret = get_shared_secret(&device, &key_agreement_key); + change_pin(&device, &key_agreement_key, &shared_secret, pin, pin).unwrap(); + + // Still false. + assert_eq!(device.exec(GetInfo).unwrap().force_pin_change, Some(false)); + }) +} + +/// Successful changePIN to a NEW pin while forcePINChange is set must clear +/// the flag (CTAP 2.1 §6.5.5.6: "Authenticator sets the value of the +/// forcePINChange member ... to false"). +#[test] +fn test_change_pin_to_new_pin_clears_force_change() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin1 = b"123456"; + let pin2 = b"654321"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin1).unwrap(); + let pin_token = get_pin_token( + &device, + &key_agreement_key, + &shared_secret, + pin1, + PERM_AUTHENTICATOR_CONFIGURATION, + None, + ) + .unwrap(); + let params = AuthenticatorConfigParams { + force_change_pin: Some(true), + ..Default::default() + }; + set_min_pin_length(&device, &pin_token, params).unwrap(); + assert_eq!(device.exec(GetInfo).unwrap().force_pin_change, Some(true)); + + let shared_secret = get_shared_secret(&device, &key_agreement_key); + change_pin(&device, &key_agreement_key, &shared_secret, pin1, pin2).unwrap(); + + assert_eq!(device.exec(GetInfo).unwrap().force_pin_change, Some(false)); + }) +} + +/// CTAP 2.1 §6.1.2 step 1 (and §6.5.5.7 step 2): when the platform sends +/// a **zero-length** `pinUvAuthParam` (the CTAP 2.0 "is PIN supported?" +/// probe), the authenticator MUST request UP and then return +/// `CTAP2_ERR_PIN_INVALID` (0x31) if a PIN is set. The pre-audit code +/// returned `PIN_AUTH_INVALID` (0x33), the CTAP 2.0 reading. +#[test] +fn test_make_credential_zero_length_pin_auth_returns_0x31_when_pin_set() { + let key_agreement_key = KeyAgreementKey::generate(); + let pin = b"123456"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + set_pin(&device, &key_agreement_key, &shared_secret, pin).unwrap(); + + let mut mc = MakeCredential::new( + vec![0u8; 32], + Rp::new("example.com"), + User::new(vec![1u8; 16]), + vec![PubKeyCredParam::new("public-key", -7)], + ); + // Zero-length pinUvAuthParam — the §6.1.2 step 1 probe. + mc.pin_auth_raw = Some(Vec::new()); + mc.pin_protocol = Some(2); + let result = device.exec(mc); + assert_eq!(result.err(), Some(Ctap2Error(0x31))); + }) +} + +/// Same probe, but with no PIN set on the device. CTAP 2.1 §6.1.2 step 1.3: +/// "return CTAP2_ERR_PIN_NOT_SET" (0x35). +#[test] +fn test_make_credential_zero_length_pin_auth_returns_0x35_when_pin_not_set() { + virt::run_ctap2(|device| { + let mut mc = MakeCredential::new( + vec![0u8; 32], + Rp::new("example.com"), + User::new(vec![1u8; 16]), + vec![PubKeyCredParam::new("public-key", -7)], + ); + mc.pin_auth_raw = Some(Vec::new()); + mc.pin_protocol = Some(2); + let result = device.exec(mc); + assert_eq!(result.err(), Some(Ctap2Error(0x35))); + }) +} + /// User-requested test: a `setMinPINLength` request derived from an INCORRECT /// PIN must fail — the platform never obtains a valid `pin_uv_auth_token`, /// so the `pin_auth` HMAC won't verify on the device side. diff --git a/tests/webauthn/mod.rs b/tests/webauthn/mod.rs index d89c3eb..4ab028b 100644 --- a/tests/webauthn/mod.rs +++ b/tests/webauthn/mod.rs @@ -391,6 +391,12 @@ pub struct MakeCredential { pub extensions: Option, pub options: Option, pub pin_auth: Option<[u8; 32]>, + /// Variable-length override for `pin_auth`, used by tests that exercise + /// the zero-length `pinUvAuthParam` path (CTAP 2.1 §6.1.2 step 1 + + /// §6.5.5.7 step 2 — "CTAP 2.0 backwards-compat" — where the platform + /// sends a 0-byte param to probe whether the authenticator has a PIN). + /// When `Some(_)`, this field is serialised instead of `pin_auth`. + pub pin_auth_raw: Option>, pub pin_protocol: Option, pub attestation_formats_preference: Option>, } @@ -410,6 +416,7 @@ impl MakeCredential { extensions: None, options: None, pin_auth: None, + pin_auth_raw: None, pin_protocol: None, attestation_formats_preference: None, } @@ -436,7 +443,13 @@ impl From for Value { if let Some(options) = request.options { map.push(7, options); } - if let Some(pin_auth) = request.pin_auth { + // `pin_auth_raw` takes precedence over the fixed-size `pin_auth` — + // tests that need a variable-length (e.g. zero-length) pinUvAuthParam + // use the raw field. The mutual-exclusion is a soft contract; the + // serializer just prefers raw when both are set. + if let Some(pin_auth_raw) = request.pin_auth_raw.as_ref() { + map.push(8, pin_auth_raw.as_slice()); + } else if let Some(pin_auth) = request.pin_auth { map.push(8, pin_auth.as_slice()); } if let Some(pin_protocol) = request.pin_protocol { From a7e869b8c5448626a4e2dfb9cdca14692ae88d4b Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Mon, 11 May 2026 19:36:23 +0200 Subject: [PATCH 6/6] ctap2.1: test user field in RK allowlist GetAssertion response --- src/ctap2.rs | 12 +++- tests/basic.rs | 129 ++++++++++++++++++++++++++++++++++++++++++ tests/webauthn/mod.rs | 4 ++ 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index 07c0116..c366d9b 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -1469,8 +1469,16 @@ impl crate::Authenticator { continue; } - // If this is an RK, we still need to load it from the filesystem to have access - // to all metadata + // CTAP 2.1 §6.2.3 — for resident credentials referenced + // via allowList, the response must include the `user` + // field. Modern versions of this app encrypt only a + // Stripped credential into `credential_id`, which omits + // user data. For RKs we recover the FullCredential from + // disk by hashing the credential_id. If the RK file is + // missing or corrupt the credential is treated as + // unusable — skip it and try the next allow-list entry + // (the Stripped form lacks the data the platform expects + // for an RK match). if let Credential::Stripped(stripped) = &credential { if matches!(stripped.key, Key::ResidentKey(_)) { let credential_id_hash = self.hash(credential_id.id); diff --git a/tests/basic.rs b/tests/basic.rs index f283237..c7cb266 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -2299,6 +2299,55 @@ fn test_set_min_pin_length_rp_ids_replace_not_append() { }) } +// ---------------------------------------------------------------------------- +// RK + allowList: user field in GA response (CTAP 2.1 §6.2.3) +// ---------------------------------------------------------------------------- + +/// CTAP 2.1 §6.2.3: when `getAssertion` is called with an `allowList` and the +/// matched credential is a resident key, the authenticator's response must +/// include the `user` field. Modern versions of this app stash only a +/// `Stripped` credential into `credential_id`, so the user field must be +/// recovered from the on-disk RK record. +#[test] +fn test_get_assertion_with_allow_list_rk_returns_user() { + let rp_id = "example.com"; + let user_id = b"alice-id-1234567"; + let user_name = "alice@example.com"; + virt::run_ctap2(|device| { + // Make an RK with a populated user struct. + let client_data_hash = vec![0u8; 32]; + let mut mc = MakeCredential::new( + client_data_hash.clone(), + Rp::new(rp_id), + User::new(user_id.to_vec()).name(user_name), + vec![PubKeyCredParam::new("public-key", -7)], + ); + mc.options = Some(MakeCredentialOptions::default().rk(true)); + let mc_reply = device.exec(mc).unwrap(); + let credential = mc_reply.auth_data.credential.unwrap(); + + // GA with allowList of just that credential — RK with allowList is + // the audited code path. + let mut ga = GetAssertion::new(rp_id.to_owned(), client_data_hash); + ga.allow_list = Some(vec![PubKeyCredDescriptor::new( + "public-key", + credential.id.clone(), + )]); + let ga_reply = device.exec(ga).unwrap(); + + let user_value = ga_reply.user.expect("user field missing in GA response"); + let user_map: std::collections::BTreeMap = + user_value.deserialized().unwrap(); + // id is the required field. name is optional and may be stripped by + // the authenticator depending on UV state; the audit fix is about + // presence of the `user` map itself. + assert_eq!( + user_map.get("id").unwrap(), + &ciborium::Value::from(user_id.as_slice()) + ); + }) +} + /// CTAP 2.1 §6.11.4 step 2.5: force `forcePINChange=true` only when /// `PINCodePointLength` is less than `newMinPINLength`. When the /// existing PIN already meets the new minimum, the flag stays cleared. @@ -2337,3 +2386,83 @@ fn test_set_min_pin_length_pin_meets_new_min_no_force_change() { assert_eq!(reply.force_pin_change, Some(false)); }) } + +/// CTAP 2.1 §6.2.2 step 12: "User identifiable information (name, displayName, +/// icon) inside user MUST NOT be returned if UV is not done by the +/// authenticator." Verify that when GA runs without `pin_auth` (no UV), the +/// `user` map contains only `id` — name/displayName/icon are stripped. +#[test] +fn test_get_assertion_with_allow_list_rk_no_uv_strips_pii() { + let rp_id = "example.com"; + let user_id = b"alice-id-1234567"; + virt::run_ctap2(|device| { + let client_data_hash = vec![0u8; 32]; + let mut mc = MakeCredential::new( + client_data_hash.clone(), + Rp::new(rp_id), + User::new(user_id.to_vec()) + .name("alice@example.com") + .display_name("Alice In Wonderland"), + vec![PubKeyCredParam::new("public-key", -7)], + ); + mc.options = Some(MakeCredentialOptions::default().rk(true)); + let mc_reply = device.exec(mc).unwrap(); + let credential = mc_reply.auth_data.credential.unwrap(); + + // GA without pin_auth → uv_performed = false → PII must be stripped. + let mut ga = GetAssertion::new(rp_id.to_owned(), client_data_hash); + ga.allow_list = Some(vec![PubKeyCredDescriptor::new( + "public-key", + credential.id.clone(), + )]); + let ga_reply = device.exec(ga).unwrap(); + + let user_value = ga_reply.user.expect("user field missing"); + let user_map: std::collections::BTreeMap = + user_value.deserialized().unwrap(); + assert_eq!( + user_map.get("id").unwrap(), + &ciborium::Value::from(user_id.as_slice()) + ); + assert!(!user_map.contains_key("name"), "name leaked without UV"); + assert!( + !user_map.contains_key("displayName"), + "displayName leaked without UV" + ); + assert!(!user_map.contains_key("icon"), "icon leaked without UV"); + }) +} + +/// CTAP 2.1 §6.2.3: the `user` response field is for resident credentials +/// only. A GA over an allow-list entry pointing at a non-RK credential +/// MUST NOT include `user`. +#[test] +fn test_get_assertion_with_allow_list_non_rk_no_user_field() { + let rp_id = "example.com"; + virt::run_ctap2(|device| { + // Make a NON-discoverable credential (rk=false). + let client_data_hash = vec![0u8; 32]; + let mc = MakeCredential::new( + client_data_hash.clone(), + Rp::new(rp_id), + User::new(b"bob-id".to_vec()).name("bob@example.com"), + vec![PubKeyCredParam::new("public-key", -7)], + ); + // No rk(true) — defaults to non-resident. + let mc_reply = device.exec(mc).unwrap(); + let credential = mc_reply.auth_data.credential.unwrap(); + + let mut ga = GetAssertion::new(rp_id.to_owned(), client_data_hash); + ga.allow_list = Some(vec![PubKeyCredDescriptor::new( + "public-key", + credential.id.clone(), + )]); + let ga_reply = device.exec(ga).unwrap(); + + assert!( + ga_reply.user.is_none(), + "non-RK credential must not return user, got {:?}", + ga_reply.user + ); + }) +} diff --git a/tests/webauthn/mod.rs b/tests/webauthn/mod.rs index 4ab028b..f90e8e4 100644 --- a/tests/webauthn/mod.rs +++ b/tests/webauthn/mod.rs @@ -798,6 +798,7 @@ pub struct GetAssertionReply { pub credential: PubKeyCredDescriptor, pub auth_data: AuthData, pub signature: Vec, + pub user: Option, pub number_of_credentials: Option, } @@ -808,6 +809,9 @@ impl From for GetAssertionReply { credential: map.remove(&0x01).unwrap().into(), auth_data: map.remove(&0x02).unwrap().into(), signature: map.remove(&0x03).unwrap().into_bytes().unwrap(), + // 0x04: user (CTAP 2.1 §6.2.3 — included for RKs by both + // no-allowList and allowList paths) + user: map.remove(&0x04), number_of_credentials: map.remove(&0x05).map(|value| value.deserialized().unwrap()), } }