Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@ 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 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.
- 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)

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
14 changes: 14 additions & 0 deletions src/ctap1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ impl<UP: UserPresence, T: TrussedRequirements> 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: &register::Request) -> Result<register::Response> {
// 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)?;
Expand Down Expand Up @@ -149,6 +158,11 @@ impl<UP: UserPresence, T: TrussedRequirements> Authenticator for crate::Authenti
}

fn authenticate(&mut self, auth: &authenticate::Request) -> Result<authenticate::Response> {
// 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 {
Expand Down
403 changes: 378 additions & 25 deletions src/ctap2.rs

Large diffs are not rendered by default.

131 changes: 130 additions & 1 deletion src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ pub mod migrate;
use core::num::NonZeroU32;

use ctap_types::{
ctap2::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,
Expand Down Expand Up @@ -268,10 +271,40 @@ 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<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<MAX_RP_ID_LENGTH>, 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
/// 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 {
Expand Down Expand Up @@ -327,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)
}

Expand Down Expand Up @@ -435,15 +473,106 @@ 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<T: FilesystemClient>(
&mut self,
trussed: &mut T,
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.6 /
// §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, 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.
let cur = self.min_pin_length();
if new_value < cur {
return Err(Error::PinPolicyViolation);
}

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<MAX_RP_ID_LENGTH>] {
&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<MAX_RP_ID_LENGTH>, 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
}

/// Set the persistent `forcePINChange` flag. Idempotent — no save if the
/// flag is already at the requested value.
pub fn set_force_pin_change<T: FilesystemClient>(
&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(())
}

pub fn always_uv(&self) -> bool {
self.always_uv
}

pub fn toggle_always_uv<T: FilesystemClient>(&mut self, trussed: &mut T) -> Result<()> {
self.always_uv = !self.always_uv;
self.save(trussed)
}
}

impl RuntimeState {
Expand Down
Loading
Loading