diff --git a/CHANGELOG.md b/CHANGELOG.md index 037dd42..a80a693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update to `ctap-types` v0.6.0-rc.1. - Set `algorithms`, `firmware_version` and `remaining_discoverable_credentials` in `get_info` and add `firmware_version` to `Config`. -- Implement the `credBlob` extension. +- Implement these new extensions: + - `credBlob` + - `minPinLength` +- Implement the `authenticatorConfig` command with these subcommands: + - `setMinPINLength` - Load full credential from filesstem for getAssertion if an allow list is used with a discoverable credential. ## [v0.3.0](https://github.com/trussed-dev/fido-authenticator/releases/tag/v0.3.0) (2026-03-25) diff --git a/src/ctap2.rs b/src/ctap2.rs index 183d9bd..1e21cff 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -3,9 +3,9 @@ use credential_management::CredentialManagement; use ctap_types::{ ctap2::{ - self, client_pin::Permissions, AttestationFormatsPreference, AttestationStatement, - AttestationStatementFormat, Authenticator, NoneAttestationStatement, - PackedAttestationStatement, VendorOperation, + self, authenticator_config::MAX_MIN_PIN_LENGTH_RP_IDS, client_pin::Permissions, + AttestationFormatsPreference, AttestationStatement, AttestationStatementFormat, + Authenticator, NoneAttestationStatement, PackedAttestationStatement, VendorOperation, }, heapless::{String, Vec}, heapless_bytes::Bytes, @@ -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(); @@ -80,6 +81,8 @@ 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); + options.set_min_pin_length = Some(true); let mut transports = Vec::new(); if self.config.nfc_transport { @@ -124,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 } @@ -572,6 +578,46 @@ impl Authenticator for crate::Authenti .user_present(&mut self.trussed, constants::FIDO2_UP_TIMEOUT) } + #[inline(never)] + fn authenticator_config( + &mut self, + request: &ctap2::authenticator_config::Request<'_>, + ) -> Result<()> { + use ctap2::authenticator_config::Subcommand; + + 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)?; + + 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), + } + } + #[inline(never)] fn client_pin( &mut self, @@ -1083,6 +1129,39 @@ impl Authenticator for crate::Authenti // impl Authenticator for crate::Authenticator impl crate::Authenticator { + fn config_set_min_pin_length( + &mut self, + request: &ctap2::authenticator_config::Request<'_>, + ) -> Result<()> { + let params = request + .sub_command_params + .as_ref() + .ok_or(Error::MissingParameter)?; + + if let Some(new_value) = params.new_min_pin_length { + self.state + .persistent + .set_min_pin_length(&mut self.trussed, new_value)?; + } + + if let Some(rp_ids) = params.min_pin_length_rp_ids.as_ref() { + if rp_ids.len() > 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() { @@ -1323,7 +1402,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); } @@ -1428,6 +1508,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..b83cc68 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,7 +7,7 @@ pub mod migrate; use core::num::NonZeroU32; use ctap_types::{ - ctap2::AttestationFormatsPreference, + ctap2::{authenticator_config::MAX_MIN_PIN_LENGTH_RP_IDS, AttestationFormatsPreference}, // 2022-02-27: 10 credentials sizes::MAX_CREDENTIAL_COUNT_IN_LIST, // U8 currently Error, @@ -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 {