Skip to content
Open
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
95 changes: 91 additions & 4 deletions src/ctap2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -61,6 +61,7 @@ impl<UP: UserPresence, T: TrussedRequirements> 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();
Expand All @@ -80,6 +81,8 @@ impl<UP: UserPresence, T: TrussedRequirements> 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 {
Expand Down Expand Up @@ -124,6 +127,9 @@ impl<UP: UserPresence, T: TrussedRequirements> 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
}
Expand Down Expand Up @@ -572,6 +578,46 @@ impl<UP: UserPresence, T: TrussedRequirements> 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,
Expand Down Expand Up @@ -1083,6 +1129,39 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti

// impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenticator<UP, T>
impl<UP: UserPresence, T: TrussedRequirements> crate::Authenticator<UP, T> {
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<u8>) -> Result<PinProtocolVersion> {
if let Ok(version) = version.try_into() {
for pin_protocol in self.pin_protocols() {
Expand Down Expand Up @@ -1323,7 +1402,8 @@ impl<UP: UserPresence, T: TrussedRequirements> crate::Authenticator<UP, T> {
// 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);
}

Expand Down Expand Up @@ -1428,6 +1508,13 @@ impl<UP: UserPresence, T: TrussedRequirements> crate::Authenticator<UP, T> {
permissions: Permissions,
rp_id: &str,
) -> Result<bool> {
// 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
//
Expand Down
68 changes: 67 additions & 1 deletion src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -272,12 +272,31 @@ pub struct PersistentState {
// TODO: Add per-key counters for resident keys.
// counter: Option<CounterId>,
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<heapless::String<256>, 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<T: FilesystemClient>(trussed: &mut T) -> Result<Self> {
// TODO: add "exists_file" method instead?
let result =
Expand Down Expand Up @@ -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<T: FilesystemClient>(
&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<T: FilesystemClient>(
&mut self,
trussed: &mut T,
rp_ids: heapless::Vec<heapless::String<256>, 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 {
Expand Down
Loading