From 1088ef7d820df880037df3411405c2d072585d00 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Fri, 8 May 2026 17:50:45 +0200 Subject: [PATCH 01/15] cargo: switch deps to ctap2.3 branches --- Cargo.toml | 7 ++++++- fuzz/Cargo.toml | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e0050bd..10e64b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,12 @@ x509-parser = "0.16" features = ["chunked", "dispatch"] [patch.crates-io] -trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "0f8df68be879acdde1f8cf428c11e5d29692a47b" } +# Track the ctap2.3 branches on the 0x0ece forks of ctap-types + trussed +# until the corresponding PRs (trussed-dev/ctap-types#75, +# trussed-dev/trussed#219) merge upstream. +ctap-types = { git = "https://github.com/0x0ece/ctap-types", rev = "88a5591d9205e54f8e5fae0f3978fc6e20845e79" } +trussed = { git = "https://github.com/0x0ece/trussed", rev = "b69aa1fdf817cc6f679fd695724615f5f91a0812" } +trussed-core = { git = "https://github.com/0x0ece/trussed", rev = "b69aa1fdf817cc6f679fd695724615f5f91a0812" } [profile.test] opt-level = 2 diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 9d832ed..453196e 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -24,5 +24,10 @@ doc = false bench = false [patch.crates-io] -trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "0f8df68be879acdde1f8cf428c11e5d29692a47b" } +# Track the ctap2.3 branches on the 0x0ece forks of ctap-types + trussed +# until the corresponding PRs (trussed-dev/ctap-types#75, +# trussed-dev/trussed#219) merge upstream. +ctap-types = { git = "https://github.com/0x0ece/ctap-types", rev = "88a5591d9205e54f8e5fae0f3978fc6e20845e79" } +trussed = { git = "https://github.com/0x0ece/trussed", rev = "b69aa1fdf817cc6f679fd695724615f5f91a0812" } +trussed-core = { git = "https://github.com/0x0ece/trussed", rev = "b69aa1fdf817cc6f679fd695724615f5f91a0812" } trussed-staging = { git = "https://github.com/trussed-dev/trussed-staging.git", tag = "v0.4.0" } From 9281e4d71ea988937a1cbdf04dce47d12bb9d51c Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Fri, 8 May 2026 18:01:07 +0200 Subject: [PATCH 02/15] ctap: getinfo advertises algorithms, firmwareVersion, remainingDiscoverableCredentials --- fuzz/fuzz_targets/ctap.rs | 1 + src/ctap2.rs | 20 +++++++++++++++++++- src/lib.rs | 4 ++++ tests/virt/mod.rs | 1 + 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/fuzz/fuzz_targets/ctap.rs b/fuzz/fuzz_targets/ctap.rs index 815c731..a9cf791 100644 --- a/fuzz/fuzz_targets/ctap.rs +++ b/fuzz/fuzz_targets/ctap.rs @@ -18,6 +18,7 @@ fuzz_target!(|requests: Vec>| { max_resident_credential_count: None, large_blobs: None, nfc_transport: false, + firmware_version: 0, }, ); diff --git a/src/ctap2.rs b/src/ctap2.rs index 1081079..0abc5dd 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -10,7 +10,10 @@ use ctap_types::{ heapless::{String, Vec}, heapless_bytes::Bytes, sizes, - webauthn::PublicKeyCredentialUserEntity, + webauthn::{ + FilteredPublicKeyCredentialParameters, KnownPublicKeyCredentialParameters, + PublicKeyCredentialUserEntity, ED_DSA, ES256, + }, ByteArray, Error, }; use littlefs2_core::{path, Path, PathBuf}; @@ -93,6 +96,17 @@ impl Authenticator for crate::Authenti let (_, aaguid) = self.state.identity.attestation(&mut self.trussed); + let mut algorithms = Vec::new(); + algorithms + .push(KnownPublicKeyCredentialParameters { alg: ES256 }) + .unwrap(); + algorithms + .push(KnownPublicKeyCredentialParameters { alg: ED_DSA }) + .unwrap(); + let algorithms = FilteredPublicKeyCredentialParameters(algorithms); + + let remaining_discoverable_credentials = self.estimate_remaining(); + let mut response = ctap2::get_info::Response::default(); response.versions = versions; response.extensions = Some(extensions); @@ -104,6 +118,10 @@ impl Authenticator for crate::Authenti response.pin_protocols = Some(pin_protocols); response.max_creds_in_list = Some(ctap_types::sizes::MAX_CREDENTIAL_COUNT_IN_LIST); response.max_cred_id_length = Some(ctap_types::sizes::MAX_CREDENTIAL_ID_LENGTH); + response.algorithms = Some(algorithms); + response.firmware_version = Some(self.config.firmware_version as usize); + response.remaining_discoverable_credentials = remaining_discoverable_credentials + .map(|count| count as usize); response.attestation_formats = Some(attestation_formats); response } diff --git a/src/lib.rs b/src/lib.rs index 2aba994..e9a7bce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -126,6 +126,10 @@ pub struct Config { pub large_blobs: Option, /// Whether the authenticator supports the NFC transport. pub nfc_transport: bool, + /// Firmware version reported by `authenticatorGetInfo` (CTAP 2.1 §6.4 0x0E). + /// + /// The runner is expected to plumb its own version constant in here. + pub firmware_version: u32, } impl Config { diff --git a/tests/virt/mod.rs b/tests/virt/mod.rs index fa070b0..f3628e4 100644 --- a/tests/virt/mod.rs +++ b/tests/virt/mod.rs @@ -74,6 +74,7 @@ where max_resident_credential_count: options.max_resident_credential_count, large_blobs: None, nfc_transport: false, + firmware_version: 0, }, ); From 33920c36b239e9ec377e2fcb120129df0ebb3fc9 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Fri, 8 May 2026 23:32:05 +0200 Subject: [PATCH 03/15] ctap2.1: implement credBlob extension (RK only, max 32 bytes) --- src/constants.rs | 5 +++++ src/credential.rs | 28 +++++++++++++++++++++++++++ src/ctap1.rs | 1 + src/ctap2.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/constants.rs b/src/constants.rs index 687fc1e..d39409c 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -9,3 +9,8 @@ pub const ATTESTATION_CERT_ID: CertId = CertId::from_special(0); pub const ATTESTATION_KEY_ID: KeyId = KeyId::from_special(0); pub const MAX_RESIDENT_CREDENTIALS_GUESSTIMATE: u32 = 100; + +/// Maximum number of bytes of `credBlob` data we accept per credential +/// (CTAP 2.1 §11.1; spec floor is 32). Reported in `authenticatorGetInfo` +/// as `maxCredBlobLength`. +pub const MAX_CRED_BLOB_LENGTH: usize = 32; diff --git a/src/credential.rs b/src/credential.rs index bb70f15..a3e05f1 100644 --- a/src/credential.rs +++ b/src/credential.rs @@ -197,6 +197,13 @@ impl Credential { Self::Stripped(credential) => credential.third_party_payment, } } + + pub fn cred_blob(&self) -> Option<&Bytes<{ crate::constants::MAX_CRED_BLOB_LENGTH }>> { + match self { + Self::Full(credential) => credential.data.cred_blob.as_ref(), + Self::Stripped(credential) => credential.cred_blob.as_ref(), + } + } } fn deserialize_bytes( @@ -518,6 +525,11 @@ pub struct CredentialData { #[serde(skip_serializing_if = "Option::is_none")] pub third_party_payment: Option, + + /// `credBlob` extension (CTAP 2.1 §11.1) — platform-supplied bytes + /// associated with this credential, up to `MAX_CRED_BLOB_LENGTH` bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub cred_blob: Option>, } // TODO: figure out sizes @@ -612,6 +624,7 @@ impl FullCredential { cred_protect: Option, large_blob_key: Option>, third_party_payment: Option, + cred_blob: Option>, nonce: [u8; 12], ) -> Self { info!("credential for algorithm {}", algorithm); @@ -628,6 +641,7 @@ impl FullCredential { cred_protect, large_blob_key, third_party_payment, + cred_blob, use_short_id: Some(true), }; @@ -735,6 +749,10 @@ pub struct StrippedCredential { pub large_blob_key: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub third_party_payment: Option, + /// Carries the `credBlob` for non-resident credentials inside the + /// credential ID itself — see [`CredentialData::cred_blob`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub cred_blob: Option>, } impl StrippedCredential { @@ -771,6 +789,7 @@ impl From<&FullCredential> for StrippedCredential { cred_protect: credential.data.cred_protect, large_blob_key: credential.data.large_blob_key, third_party_payment: credential.data.third_party_payment, + cred_blob: credential.data.cred_blob.clone(), } } } @@ -814,6 +833,7 @@ mod test { use_short_id: Some(true), large_blob_key: Some(ByteArray::new([0xff; 32])), third_party_payment: Some(true), + cred_blob: None, } } @@ -845,6 +865,7 @@ mod test { use_short_id: None, large_blob_key: None, third_party_payment: None, + cred_blob: None, } } @@ -932,6 +953,7 @@ mod test { use_short_id: Some(true), large_blob_key: Some(random_byte_array()), third_party_payment: Some(false), + cred_blob: None, } } @@ -1092,6 +1114,11 @@ mod test { cred_protect: Some(CredentialProtectionPolicy::Required), large_blob_key: Some(ByteArray::new([0xff; 32])), third_party_payment: Some(true), + // `cred_blob` is intentionally left out of the worst-case fixture: + // adding 32 bytes of blob alongside the other extensions blows past + // `MAX_CREDENTIAL_ID_LENGTH = 255`. Non-RK credentials therefore + // refuse to store `credBlob` (see `make_credential` in `ctap2.rs`). + cred_blob: None, }; trussed::virt::with_client(StoreConfig::ram(), "fido", |mut client| { let kek = syscall!(client.generate_chacha8poly1305_key(Location::Internal)).key; @@ -1333,6 +1360,7 @@ mod test { use_short_id: Some(true), large_blob_key: None, third_party_payment: None, + cred_blob: None, }, ); } diff --git a/src/ctap1.rs b/src/ctap1.rs index 50e4d0c..2b8e29f 100644 --- a/src/ctap1.rs +++ b/src/ctap1.rs @@ -88,6 +88,7 @@ impl Authenticator for crate::Authenti cred_protect: None, large_blob_key: None, third_party_payment: None, + cred_blob: None, }; // info!("made credential {:?}", &credential); diff --git a/src/ctap2.rs b/src/ctap2.rs index 0abc5dd..036235e 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -56,6 +56,7 @@ impl Authenticator for crate::Authenti let mut extensions = Vec::new(); extensions.push(Extension::CredProtect).unwrap(); + extensions.push(Extension::CredBlob).unwrap(); extensions.push(Extension::HmacSecret).unwrap(); if self.config.supports_large_blobs() { extensions.push(Extension::LargeBlobKey).unwrap(); @@ -122,6 +123,7 @@ impl Authenticator for crate::Authenti response.firmware_version = Some(self.config.firmware_version as usize); response.remaining_discoverable_credentials = remaining_discoverable_credentials .map(|count| count as usize); + response.max_cred_blob_length = Some(constants::MAX_CRED_BLOB_LENGTH); response.attestation_formats = Some(attestation_formats); response } @@ -251,6 +253,8 @@ impl Authenticator for crate::Authenti let mut cred_protect_requested = None; let mut large_blob_key_requested = false; let mut third_party_payment_requested = false; + let mut cred_blob_to_store: Option> = None; + let mut cred_blob_requested = false; if let Some(extensions) = ¶meters.extensions { hmac_secret_requested = extensions.hmac_secret; @@ -275,6 +279,20 @@ impl Authenticator for crate::Authenti } third_party_payment_requested = extensions.third_party_payment.unwrap_or_default(); + + if let Some(blob) = extensions.cred_blob { + cred_blob_requested = true; + // Spec (CTAP 2.1 §11.1): authenticator MAY refuse to store. We + // refuse when (a) the blob exceeds `MAX_CRED_BLOB_LENGTH`, or + // (b) the credential is non-discoverable — encoding `credBlob` + // into a non-RK credential ID would push it past + // `MAX_CREDENTIAL_ID_LENGTH = 255`. In either case, leave + // `cred_blob_to_store = None` and emit `credBlob: false` in the + // MC output extensions. + if rk_requested && blob.len() <= constants::MAX_CRED_BLOB_LENGTH { + cred_blob_to_store = Some(Bytes::try_from(&**blob).expect("len bounded above")); + } + } } // debug_now!("hmac-secret = {:?}, credProtect = {:?}", hmac_secret_requested, cred_protect_requested); @@ -346,6 +364,7 @@ impl Authenticator for crate::Authenti cred_protect_requested, large_blob_key, third_party_payment_requested.then_some(true), + cred_blob_to_store.clone(), nonce, ); @@ -406,7 +425,10 @@ impl Authenticator for crate::Authenti if true { flags |= Flags::ATTESTED_CREDENTIAL_DATA; } - if hmac_secret_requested.is_some() || cred_protect_requested.is_some() { + if hmac_secret_requested.is_some() + || cred_protect_requested.is_some() + || cred_blob_requested + { flags |= Flags::EXTENSION_DATA; } flags @@ -426,10 +448,19 @@ impl Authenticator for crate::Authenti }, extensions: { - if hmac_secret_requested.is_some() || cred_protect_requested.is_some() { - let mut extensions = ctap2::make_credential::Extensions::default(); + if hmac_secret_requested.is_some() + || cred_protect_requested.is_some() + || cred_blob_requested + { + let mut extensions = ctap2::make_credential::ExtensionsOutput::default(); extensions.cred_protect = parameters.extensions.as_ref().unwrap().cred_protect; extensions.hmac_secret = parameters.extensions.as_ref().unwrap().hmac_secret; + if cred_blob_requested { + // `Some(true)` if the platform-supplied blob fit in + // `MAX_CRED_BLOB_LENGTH` and was stored, `Some(false)` + // otherwise (CTAP 2.1 §11.1). + extensions.cred_blob = Some(cred_blob_to_store.is_some()); + } Some(extensions) } else { None @@ -1544,6 +1575,17 @@ impl crate::Authenticator { output.third_party_payment = Some(credential.third_party_payment().unwrap_or_default()); } + if extensions.cred_blob.unwrap_or(false) { + // Spec: if the extension was requested but no blob is associated + // with the credential, return an empty byte string (not absent). + output.cred_blob = Some( + credential + .cred_blob() + .cloned() + .unwrap_or_else(Bytes::new), + ); + } + Ok(output.is_set().then_some(output)) } From 296ed0f8a1856943b933de52e6fce8166f3995cf Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Fri, 8 May 2026 23:35:42 +0200 Subject: [PATCH 04/15] ctap2.1: add authenticatorConfig (0x0D) command and authnrCfg option --- src/ctap2.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/ctap2.rs b/src/ctap2.rs index 036235e..117b08d 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 { @@ -570,6 +571,54 @@ impl Authenticator for crate::Authenti .user_present(&mut self.trussed, constants::FIDO2_UP_TIMEOUT) } + #[inline(never)] + fn authenticator_config( + &mut self, + request: &ctap2::authenticator_config::Request<'_>, + ) -> Result<()> { + use ctap2::authenticator_config::Subcommand; + + // CTAP 2.1 §6.11.4 step 5: a PIN/UV-auth token is required. We have no + // built-in UV (no biometrics), so this also implies a client PIN must + // be set. + if !self.state.persistent.pin_is_set() { + return Err(Error::PinNotSet); + } + let pin_protocol = request.pin_protocol.ok_or(Error::MissingParameter)?; + let pin_protocol = self.parse_pin_protocol(pin_protocol)?; + let pin_auth = request.pin_auth.ok_or(Error::MissingParameter)?; + + // pinUvAuthData = 0xff * 32 || 0x0d || subCommand || subCommandParams (CBOR) + let mut data: Bytes<{ 32 + 2 + sizes::MAX_CREDENTIAL_ID_LENGTH }> = Bytes::new(); + data.resize(32, 0xff).map_err(|_| Error::Other)?; + data.push(0x0d).map_err(|_| Error::Other)?; + data.push(request.sub_command as u8) + .map_err(|_| Error::Other)?; + if let Some(params) = request.sub_command_params.as_ref() { + cbor_smol::cbor_serialize_to(params, &mut data).map_err(|_| Error::Other)?; + } + + let mut pin_protocol_impl = self.pin_protocol(pin_protocol); + let pin_token = pin_protocol_impl.verify_pin_token(&data, pin_auth)?; + pin_token.require_permissions(Permissions::AUTHENTICATOR_CONFIGURATION)?; + + // Subcommand handlers land in subsequent commits (C4: setMinPINLength, + // C5: toggleAlwaysUv, C11: enableLongTouchForReset). For now, refuse + // every subcommand cleanly so platforms can still feature-detect via + // the `authnrCfg` GetInfo flag without us pretending to support things + // we do not. + match request.sub_command { + Subcommand::EnableEnterpriseAttestation + | Subcommand::ToggleAlwaysUv + | Subcommand::SetMinPINLength + | Subcommand::EnableLongTouchForReset + | Subcommand::VendorPrototype => Err(Error::InvalidSubcommand), + // `Subcommand` is `#[non_exhaustive]`; refuse anything we did not + // explicitly enumerate above. + _ => Err(Error::InvalidSubcommand), + } + } + #[inline(never)] fn client_pin( &mut self, From 07ee66829e8937141d1719a550a98073bec8e10f Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Sat, 9 May 2026 07:11:14 +0200 Subject: [PATCH 05/15] state: add migrate_all umbrella + panic on corrupt PersistentState load --- src/state.rs | 58 +++++++++++++++++++++++++++++++------------- src/state/migrate.rs | 17 +++++++++++++ 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/src/state.rs b/src/state.rs index 9e85855..29bee01 100644 --- a/src/state.rs +++ b/src/state.rs @@ -266,7 +266,15 @@ pub struct PersistentState { key_encryption_key: Option, key_wrapping_key: Option, consecutive_pin_mismatches: u8, - #[serde(with = "serde_bytes")] + /// PIN hash. Always serialized as a CBOR byte string (major type 2) — + /// the shape used since `fido-authenticator 0.2.0`. Devices upgrading + /// from `0.1.1` carry blobs that encode the field as a 16-element CBOR + /// array (major type 4); cbor-smol's `deserialize_bytes` is permissive + /// enough to accept both shapes (it routes major type 4 to `visit_seq`), + /// so the legacy format deserializes cleanly via the `serde_bytes` + /// helper, and the next `save` normalises the on-disk format. There is + /// a regression test for the 0.1.1 shape in `state::tests::deser`. + #[serde(default, with = "serde_bytes")] pin_hash: Option<[u8; 16]>, // Ideally, we'd dogfood a "Monotonic Counter" from trussed. // TODO: Add per-key counters for resident keys. @@ -278,18 +286,24 @@ impl PersistentState { const RESET_RETRIES: u8 = 8; const FILENAME: &'static Path = path!("persistent-state.cbor"); - pub fn load(trussed: &mut T) -> Result { - // TODO: add "exists_file" method instead? - let result = - try_syscall!(trussed.read_file(Location::Internal, PathBuf::from(Self::FILENAME),)) - .map_err(|_| Error::Other); - - if result.is_err() { - info!("err loading: {:?}", result.err().unwrap()); - return Err(Error::Other); - } - - let data = result.unwrap().data; + /// Load persistent state from disk. + /// + /// Returns `Ok(None)` when no state file is present (fresh / wiped + /// device), `Ok(Some(state))` on a successful load, and `Err(_)` when a + /// state file is present but cannot be deserialized. The two paths must + /// stay distinct so [`Self::load_if_not_initialised`] can refuse to start + /// the authenticator on a corrupt blob instead of silently re-initialising + /// (which would destroy every credential). + pub fn load(trussed: &mut T) -> Result> { + // Trussed's read syscall conflates "file does not exist" and + // "read failure" into a single `FilesystemReadFailure`. Either way, + // this is a fresh authenticator from our perspective. + let data = match try_syscall!(trussed + .read_file(Location::Internal, PathBuf::from(Self::FILENAME))) + { + Ok(reply) => reply.data, + Err(_) => return Ok(None), + }; let state: Self = cbor_smol::cbor_deserialize(&data).map_err(|_err| { info!("err deser'ing: {_err:?}",); @@ -299,7 +313,7 @@ impl PersistentState { debug!("Loaded state: {state:#?}"); - Ok(state) + Ok(Some(state)) } pub fn save(&self, trussed: &mut T) -> Result<()> { @@ -333,12 +347,22 @@ impl PersistentState { pub fn load_if_not_initialised(&mut self, trussed: &mut T) { if !self.initialised { match Self::load(trussed) { - Ok(previous_self) => { + Ok(Some(previous_self)) => { info!("loaded previous state!"); - *self = previous_self + *self = previous_self; + } + Ok(None) => { + // No state on disk — fresh authenticator. Leave defaults. + info!("no previous state, starting fresh"); } Err(_err) => { - info!("error with previous state! {:?}", _err); + // State file is present but unparseable. Silently + // resetting here would silently destroy the user's PIN, + // KEK, and every credential. Refuse to start instead. + // The failure is at least visible in the RTT log; the + // user can recover via JLink/bootloader. + error_now!("PERSISTENT STATE LOAD FAILED: {:?}", _err); + panic!("fido-authenticator: corrupt persistent state"); } } self.initialised = true; diff --git a/src/state/migrate.rs b/src/state/migrate.rs index d33d0c9..1da9e35 100644 --- a/src/state/migrate.rs +++ b/src/state/migrate.rs @@ -7,6 +7,23 @@ fn ignore_does_not_exists(error: Error) -> Result<(), Error> { Err(error) } +/// Run every per-version migration this code knows about, in order. Idempotent +/// — safe to call on every boot, no-op on already-migrated state. +/// +/// `base_path` must be the base of the file directory of the fido app +/// (typically `fido/dat`). +/// +/// The runner is expected to call this **before** constructing the +/// `Authenticator`, so that any on-disk state has been brought to the latest +/// shape before [`crate::state::PersistentState::load`] tries to deserialize +/// it. +pub fn migrate_all(fs: &dyn DynFilesystem, base_path: &Path) -> Result<(), Error> { + migrate_no_rp_dir(fs, base_path)?; + // Future per-version migrations chain here. Each must be idempotent + // (run-twice = no-op). + Ok(()) +} + /// Migration function, to be used with trussed-staging's `migrate` management syscall /// /// `base_path` must be the base of the file directory of the fido app (often `/fido/dat`) From c621455be6d657189f71578168d801cf1a04e6d0 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Sat, 9 May 2026 07:15:30 +0200 Subject: [PATCH 06/15] ctap2.1: implement setMinPINLength + minPinLength extension + forcePINChange --- src/ctap2.rs | 62 ++++++++++++++++++++++++++++++++++++++++------ src/state.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index 117b08d..b431aab 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -61,6 +61,7 @@ impl Authenticator for crate::Authenti if self.config.supports_large_blobs() { extensions.push(Extension::LargeBlobKey).unwrap(); } + extensions.push(Extension::MinPinLength).unwrap(); extensions.push(Extension::ThirdPartyPayment).unwrap(); let mut pin_protocols = Vec::new(); @@ -81,6 +82,7 @@ impl Authenticator for crate::Authenti options.pin_uv_auth_token = Some(true); options.make_cred_uv_not_rqd = Some(true); options.authnr_cfg = Some(true); + options.set_min_pin_length = Some(true); let mut transports = Vec::new(); if self.config.nfc_transport { @@ -125,6 +127,10 @@ impl Authenticator for crate::Authenti response.remaining_discoverable_credentials = remaining_discoverable_credentials .map(|count| count as usize); response.max_cred_blob_length = Some(constants::MAX_CRED_BLOB_LENGTH); + response.min_pin_length = Some(self.state.persistent.min_pin_length() as usize); + response.force_pin_change = Some(self.state.persistent.force_pin_change()); + response.max_rpids_for_set_min_pin_length = + Some(state::PersistentState::MAX_MIN_PIN_LENGTH_RP_IDS); response.attestation_formats = Some(attestation_formats); response } @@ -602,15 +608,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 @@ -1130,6 +1134,42 @@ impl Authenticator for crate::Authenti // impl Authenticator for crate::Authenticator impl crate::Authenticator { + fn config_set_min_pin_length( + &mut self, + request: &ctap2::authenticator_config::Request<'_>, + ) -> Result<()> { + let params = request + .sub_command_params + .as_ref() + .ok_or(Error::MissingParameter)?; + + if let Some(new_value) = params.new_min_pin_length { + self.state + .persistent + .set_min_pin_length(&mut self.trussed, new_value)?; + } + + if let Some(rp_ids) = params.min_pin_length_rp_ids.as_ref() { + if rp_ids.len() > state::PersistentState::MAX_MIN_PIN_LENGTH_RP_IDS { + return Err(Error::PinPolicyViolation); + } + let mut owned = heapless::Vec::new(); + for id in rp_ids { + owned + .push( + heapless::String::try_from(*id) + .map_err(|_| Error::PinPolicyViolation)?, + ) + .map_err(|_| Error::PinPolicyViolation)?; + } + self.state + .persistent + .set_min_pin_length_rp_ids(&mut self.trussed, owned)?; + } + + Ok(()) + } + fn parse_pin_protocol(&self, version: impl TryInto) -> Result { if let Ok(version) = version.try_into() { for pin_protocol in self.pin_protocols() { @@ -1347,7 +1387,8 @@ impl crate::Authenticator { // pin.len(), pin_length, &pin); // chop off null bytes let pin_length = pin.iter().position(|&b| b == b'\0').unwrap_or(pin.len()); - if !(4..64).contains(&pin_length) { + let min_pin_length = self.state.persistent.min_pin_length() as usize; + if pin_length < min_pin_length || pin_length >= 64 { return Err(Error::PinPolicyViolation); } @@ -1452,6 +1493,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 29bee01..7fbb112 100644 --- a/src/state.rs +++ b/src/state.rs @@ -280,12 +280,34 @@ pub struct PersistentState { // TODO: Add per-key counters for resident keys. // counter: Option, timestamp: u32, + + /// Configured minimum PIN length (CTAP 2.1 `setMinPINLength`, §6.11.4 + /// subcmd 0x03). `0` means "no override; use the spec default of 4". + #[serde(default)] + min_pin_length: u8, + + /// RP IDs that should automatically receive the `minPinLength` extension + /// output without explicit request (CTAP 2.1 `setMinPINLength`). + #[serde(default)] + min_pin_length_rp_ids: heapless::Vec, 4>, + + /// `forcePINChange` (CTAP 2.1 §6.4 0x0C). When `true`, the authenticator + /// rejects every operation that requires `clientPin` until the platform + /// successfully calls `clientPin.changePIN`. + #[serde(default)] + force_pin_change: bool, } impl PersistentState { const RESET_RETRIES: u8 = 8; const FILENAME: &'static Path = path!("persistent-state.cbor"); + /// Default minimum PIN length (CTAP 2.1 §6.11.4: spec floor is 4). + pub const DEFAULT_MIN_PIN_LENGTH: u8 = 4; + /// Maximum number of RP IDs that can be auto-receivers of the + /// `minPinLength` extension. + pub const MAX_MIN_PIN_LENGTH_RP_IDS: usize = 4; + /// Load persistent state from disk. /// /// Returns `Ok(None)` when no state file is present (fresh / wiped @@ -465,9 +487,56 @@ impl PersistentState { pin_hash: [u8; 16], ) -> Result<()> { self.pin_hash = Some(pin_hash); + // Successfully (re)setting the PIN clears any pending forcePINChange + // request — the platform has just complied (CTAP 2.1 §6.5.5.7). + self.force_pin_change = false; + self.save(trussed)?; + Ok(()) + } + + /// Configured minimum PIN length, never less than the CTAP 2.1 floor. + pub fn min_pin_length(&self) -> u8 { + core::cmp::max(self.min_pin_length, Self::DEFAULT_MIN_PIN_LENGTH) + } + + pub fn set_min_pin_length( + &mut self, + trussed: &mut T, + new_value: u8, + ) -> Result<()> { + // Spec: setMinPINLength may only raise the value, never lower it. + if new_value <= self.min_pin_length() { + return Err(Error::PinPolicyViolation); + } + self.min_pin_length = new_value; + // Spec §6.11.4 step 7: if the existing PIN is shorter than the new + // floor, force the platform to change it. We can't measure the + // existing PIN length here (only its hash is stored), so we set the + // flag unconditionally on any tightening. + if self.pin_hash.is_some() { + self.force_pin_change = true; + } + self.save(trussed)?; + Ok(()) + } + + pub fn min_pin_length_rp_ids(&self) -> &[heapless::String<256>] { + &self.min_pin_length_rp_ids + } + + pub fn set_min_pin_length_rp_ids( + &mut self, + trussed: &mut T, + rp_ids: heapless::Vec, { Self::MAX_MIN_PIN_LENGTH_RP_IDS }>, + ) -> Result<()> { + self.min_pin_length_rp_ids = rp_ids; self.save(trussed)?; Ok(()) } + + pub fn force_pin_change(&self) -> bool { + self.force_pin_change + } } impl RuntimeState { From ca4531474fb6eede2f505331557b7587fbd87c86 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Sat, 9 May 2026 07:17:36 +0200 Subject: [PATCH 07/15] ctap2.1: implement alwaysUv + toggleAlwaysUv --- src/ctap2.rs | 18 ++++++++++++++---- src/state.rs | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index b431aab..b400125 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -83,6 +83,7 @@ impl Authenticator for crate::Authenti options.make_cred_uv_not_rqd = Some(true); 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 { @@ -610,11 +611,13 @@ impl Authenticator for crate::Authenti 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::ToggleAlwaysUv => self + .state + .persistent + .toggle_always_uv(&mut self.trussed), + // 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 @@ -1500,6 +1503,13 @@ impl crate::Authenticator { return Err(Error::PinPolicyViolation); } + // 0b. CTAP 2.1 §6.11.3 `alwaysUv`: when set, every MC / GA must carry a + // valid `pinUvAuthParam`. Reject early if the platform did not send + // one — `PinRequired` lets it know to invoke `clientPin` first. + if self.state.persistent.always_uv() && pin_auth.is_none() { + 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 7fbb112..4862fcf 100644 --- a/src/state.rs +++ b/src/state.rs @@ -296,6 +296,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 { @@ -537,6 +543,15 @@ impl PersistentState { pub fn force_pin_change(&self) -> bool { self.force_pin_change } + + 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 { From 60f2710d952b3e339302c005666bfd9d32a0a8c7 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Sat, 9 May 2026 07:21:29 +0200 Subject: [PATCH 08/15] ctap2.2: implement hmac-secret-mc extension at MakeCredential time --- src/ctap2.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/ctap2.rs b/src/ctap2.rs index b400125..52fe168 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -53,11 +53,13 @@ impl Authenticator for crate::Authenti versions.push(Version::U2fV2).unwrap(); versions.push(Version::Fido2_0).unwrap(); versions.push(Version::Fido2_1).unwrap(); + versions.push(Version::Fido2_2).unwrap(); let mut extensions = Vec::new(); extensions.push(Extension::CredProtect).unwrap(); extensions.push(Extension::CredBlob).unwrap(); extensions.push(Extension::HmacSecret).unwrap(); + extensions.push(Extension::HmacSecretMc).unwrap(); if self.config.supports_large_blobs() { extensions.push(Extension::LargeBlobKey).unwrap(); } @@ -303,6 +305,12 @@ impl Authenticator for crate::Authenti } } + let hmac_secret_mc_input = parameters + .extensions + .as_ref() + .and_then(|ext| ext.hmac_secret_mc.as_ref()) + .cloned(); + // debug_now!("hmac-secret = {:?}, credProtect = {:?}", hmac_secret_requested, cred_protect_requested); // 10. get UP, if denied error OperationDenied @@ -317,6 +325,62 @@ impl Authenticator for crate::Authenti let private_key = algorithm.generate_private_key(&mut self.trussed, location); let cose_public_key = algorithm.derive_public_key(&mut self.trussed, private_key); + // 11.b CTAP 2.2 hmac-secret-mc: evaluate hmac-secret at MakeCredential + // time so the platform can capture salts atomically with credential + // creation. Same wire format as GA's hmac-secret output. + let hmac_secret_mc_output: Option> = if let Some(hmac_secret) = + hmac_secret_mc_input.as_ref() + { + let pin_protocol = hmac_secret + .pin_protocol + .map(|i| self.parse_pin_protocol(i)) + .transpose()? + .unwrap_or(PinProtocolVersion::V1); + + let cred_random = syscall!(self.trussed.derive_key( + Mechanism::HmacSha256, + private_key, + Some(Bytes::from(&[uv_performed as u8])), + StorageAttributes::new().set_persistence(Location::Volatile), + )) + .key; + + let mut pin_protocol_impl = self.pin_protocol(pin_protocol); + let shared_secret = pin_protocol_impl.shared_secret(&hmac_secret.key_agreement)?; + pin_protocol_impl.verify_pin_auth( + &shared_secret, + &hmac_secret.salt_enc, + &hmac_secret.salt_auth, + )?; + + let salts = shared_secret + .decrypt(&mut self.trussed, &hmac_secret.salt_enc) + .ok_or(Error::InvalidOption)?; + if salts.len() != 32 && salts.len() != 64 { + debug_now!("invalid hmac-secret-mc salt length"); + return Err(Error::InvalidLength); + } + + let mut salt_output: Bytes<64> = Bytes::new(); + let output1 = + syscall!(self.trussed.sign_hmacsha256(cred_random, &salts[0..32])).signature; + salt_output.extend_from_slice(&output1).unwrap(); + if salts.len() == 64 { + let output2 = + syscall!(self.trussed.sign_hmacsha256(cred_random, &salts[32..64])).signature; + salt_output.extend_from_slice(&output2).unwrap(); + } + + syscall!(self.trussed.delete(cred_random)); + + let output_enc = shared_secret.encrypt(&mut self.trussed, &salt_output); + shared_secret.delete(&mut self.trussed); + + Some(Bytes::try_from(&*output_enc).map_err(|_| Error::Other)?) + } else { + None + }; + // 12. if `rk` is set, store or overwrite key pair, if full error KeyStoreFull // 12.a generate credential @@ -436,6 +500,7 @@ impl Authenticator for crate::Authenti if hmac_secret_requested.is_some() || cred_protect_requested.is_some() || cred_blob_requested + || hmac_secret_mc_output.is_some() { flags |= Flags::EXTENSION_DATA; } @@ -459,6 +524,7 @@ impl Authenticator for crate::Authenti if hmac_secret_requested.is_some() || cred_protect_requested.is_some() || cred_blob_requested + || hmac_secret_mc_output.is_some() { let mut extensions = ctap2::make_credential::ExtensionsOutput::default(); extensions.cred_protect = parameters.extensions.as_ref().unwrap().cred_protect; @@ -469,6 +535,9 @@ impl Authenticator for crate::Authenti // otherwise (CTAP 2.1 §11.1). extensions.cred_blob = Some(cred_blob_to_store.is_some()); } + if let Some(out) = hmac_secret_mc_output.clone() { + extensions.hmac_secret_mc = Some(out); + } Some(extensions) } else { None From 99c393b4c83ea74b127b9902aa868e569286a86f Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Sat, 9 May 2026 07:24:14 +0200 Subject: [PATCH 09/15] ctap2.3: advertise smart-card transport when CCID is enabled --- fuzz/fuzz_targets/ctap.rs | 1 + src/ctap2.rs | 3 +++ src/lib.rs | 4 ++++ tests/virt/mod.rs | 1 + 4 files changed, 9 insertions(+) diff --git a/fuzz/fuzz_targets/ctap.rs b/fuzz/fuzz_targets/ctap.rs index a9cf791..3db80fc 100644 --- a/fuzz/fuzz_targets/ctap.rs +++ b/fuzz/fuzz_targets/ctap.rs @@ -18,6 +18,7 @@ fuzz_target!(|requests: Vec>| { max_resident_credential_count: None, large_blobs: None, nfc_transport: false, + ccid_transport: false, firmware_version: 0, }, ); diff --git a/src/ctap2.rs b/src/ctap2.rs index 52fe168..28602bf 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -91,6 +91,9 @@ impl Authenticator for crate::Authenti if self.config.nfc_transport { transports.push(Transport::Nfc).unwrap(); } + if self.config.ccid_transport { + transports.push(Transport::SmartCard).unwrap(); + } transports.push(Transport::Usb).unwrap(); let mut attestation_formats = Vec::new(); diff --git a/src/lib.rs b/src/lib.rs index e9a7bce..c1b2042 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -126,6 +126,10 @@ pub struct Config { pub large_blobs: Option, /// Whether the authenticator supports the NFC transport. pub nfc_transport: bool, + /// Whether the authenticator exposes FIDO over a CCID smart-card interface + /// (CTAP 2.3 §3 FIDO Interfaces). When `true`, GetInfo advertises the + /// `"smart-card"` transport alongside `"usb"` / `"nfc"`. + pub ccid_transport: bool, /// Firmware version reported by `authenticatorGetInfo` (CTAP 2.1 §6.4 0x0E). /// /// The runner is expected to plumb its own version constant in here. diff --git a/tests/virt/mod.rs b/tests/virt/mod.rs index f3628e4..c0ff69c 100644 --- a/tests/virt/mod.rs +++ b/tests/virt/mod.rs @@ -74,6 +74,7 @@ where max_resident_credential_count: options.max_resident_credential_count, large_blobs: None, nfc_transport: false, + ccid_transport: false, firmware_version: 0, }, ); From 836e44e89292b8422de2cb07a5bfd1c165521a75 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Sat, 9 May 2026 07:27:34 +0200 Subject: [PATCH 10/15] ctap2.3: implement long-touch reset (Level::Strong UV, drop boot-window) --- src/ctap2.rs | 34 ++++++++++++++++++---------------- src/lib.rs | 28 ++++++++++++++++++++++++++++ src/state.rs | 15 +++++++++++++++ 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index 28602bf..9bdfc78 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -137,6 +137,9 @@ impl Authenticator for crate::Authenti response.force_pin_change = Some(self.state.persistent.force_pin_change()); response.max_rpids_for_set_min_pin_length = Some(state::PersistentState::MAX_MIN_PIN_LENGTH_RP_IDS); + // CTAP 2.3 §6.4 0x18: long-touch is the only reset gesture we support, + // and it is hard-wired on. Always advertise as "supported & enabled". + response.long_touch_for_reset = Some(true); response.attestation_formats = Some(attestation_formats); response } @@ -614,18 +617,13 @@ impl Authenticator for crate::Authenti #[inline(never)] fn reset(&mut self) -> Result<()> { - // 1. >10s after bootup -> NotAllowed - let uptime = syscall!(self.trussed.uptime()).uptime; - debug_now!("uptime: {:?}", uptime); - if uptime.as_secs() > 10 { - #[cfg(not(feature = "disable-reset-time-window"))] - return Err(Error::NotAllowed); - } - // 2. check for user presence - // denied -> OperationDenied - // timeout -> UserActionTimeout + // CTAP 2.3 §7.7: replace the legacy 10-second boot window with a + // continuous ≥5 s "long touch". The runner is responsible for timing + // the press and surfacing `consent::Level::Strong` here when the user + // holds the button long enough; anything weaker results in + // `OperationDenied`. self.up - .user_present(&mut self.trussed, constants::FIDO2_UP_TIMEOUT)?; + .user_present_strong(&mut self.trussed, constants::FIDO2_UP_TIMEOUT)?; // Delete resident keys syscall!(self.trussed.delete_all(Location::Internal)); @@ -687,11 +685,15 @@ impl Authenticator for crate::Authenti .state .persistent .toggle_always_uv(&mut self.trussed), - // C11 wires `EnableLongTouchForReset`. EnterpriseAttestation / - // VendorPrototype are deliberately not supported on this device. - Subcommand::EnableEnterpriseAttestation - | Subcommand::EnableLongTouchForReset - | Subcommand::VendorPrototype => Err(Error::InvalidSubcommand), + // CTAP 2.3 §6.11.5: long-touch is the only reset gesture we + // support, hard-wired on. The subcommand is therefore a no-op: + // already enabled, so we just acknowledge. + Subcommand::EnableLongTouchForReset => Ok(()), + // EnterpriseAttestation / VendorPrototype are deliberately not + // supported on this device. + Subcommand::EnableEnterpriseAttestation | Subcommand::VendorPrototype => { + Err(Error::InvalidSubcommand) + } // `Subcommand` is `#[non_exhaustive]`; refuse anything we did not // explicitly enumerate above. _ => Err(Error::InvalidSubcommand), diff --git a/src/lib.rs b/src/lib.rs index c1b2042..ecba5f3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -299,6 +299,18 @@ pub trait UserPresence: Copy { trussed: &mut T, timeout_milliseconds: u32, ) -> Result<()>; + + /// Strong user-presence check (CTAP 2.3 §7.7 long-touch reset). Default + /// falls back to a normal user-presence check; runners that can detect a + /// continuous ≥5 s touch should override this and ask trussed for + /// `consent::Level::Strong`. + fn user_present_strong( + self, + trussed: &mut T, + timeout_milliseconds: u32, + ) -> Result<()> { + self.user_present(trussed, timeout_milliseconds) + } } #[deprecated(note = "use `Silent` directly`")] @@ -336,6 +348,22 @@ impl UserPresence for Conforming { _ => Error::OperationDenied, }) } + + fn user_present_strong( + self, + trussed: &mut T, + timeout_milliseconds: u32, + ) -> Result<()> { + use trussed_core::types::consent::Level; + let result = syscall!(trussed + .confirm_user_present_with_level(Level::Strong, timeout_milliseconds)) + .result; + result.map_err(|err| match err { + trussed_core::types::consent::Error::TimedOut => Error::UserActionTimeout, + trussed_core::types::consent::Error::Interrupted => Error::KeepaliveCancel, + _ => Error::OperationDenied, + }) + } } impl Authenticator diff --git a/src/state.rs b/src/state.rs index 4862fcf..8a1ba09 100644 --- a/src/state.rs +++ b/src/state.rs @@ -617,6 +617,21 @@ impl RuntimeState { self.clear_credential_cache(); self.active_get_assertion = None; + // Clear any in-flight credMgmt enumeration cursors. Otherwise a + // `next_relying_party`/`next_credential` call straight after + // `authenticatorReset` succeeds with stale data instead of + // returning `NotAllowed` (caught by fido2-tests + // test_rpnext_without_rpbegin). + self.cached_rp = None; + self.cached_rk = None; + + // The per-power-cycle pinAuthFailedAttempts counter is runtime + // state and lives here. `authenticatorReset` removes the PIN + // entirely (caller resets the persistent retries counter via + // `PersistentState::reset`), so the per-power-cycle counter + // should drop with it. + self.consecutive_pin_mismatches = 0; + if let Some(pin_protocol) = self.pin_protocol.take() { pin_protocol.reset(trussed); } From 09858ffd1d3b747b5a0822b8f9a54b20f35f0080 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Sat, 9 May 2026 07:28:48 +0200 Subject: [PATCH 11/15] ctap2.3: getinfo advertises FIDO_2_3 --- src/ctap2.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ctap2.rs b/src/ctap2.rs index 9bdfc78..c71ac2e 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -54,6 +54,7 @@ impl Authenticator for crate::Authenti versions.push(Version::Fido2_0).unwrap(); versions.push(Version::Fido2_1).unwrap(); versions.push(Version::Fido2_2).unwrap(); + versions.push(Version::Fido2_3).unwrap(); let mut extensions = Vec::new(); extensions.push(Extension::CredProtect).unwrap(); From f452a6a2cab9b2c277966d6335f6164cfe1882bc Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Mon, 11 May 2026 19:35:49 +0200 Subject: [PATCH 12/15] =?UTF-8?q?ctap2.1:=20align=20setPin=20/=20getPinTok?= =?UTF-8?q?en=20PIN=20error=20codes=20with=20=C2=A76.5.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ctap2.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index c71ac2e..a7c11bc 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -754,8 +754,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 @@ -1590,6 +1593,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 @@ -1597,7 +1602,7 @@ impl crate::Authenticator { if !self.state.persistent.pin_is_set() { return Err(Error::PinNotSet); } else { - return Err(Error::PinAuthInvalid); + return Err(Error::PinInvalid); } } } From 6fe88179e60b1820e94be6c8af6b1856450b9b4b Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Mon, 11 May 2026 19:36:23 +0200 Subject: [PATCH 13/15] ctap2.1: include user field in RK allowlist GetAssertion response --- src/ctap2.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index a7c11bc..1dcf240 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -1328,7 +1328,8 @@ impl crate::Authenticator { // they probably meant to send None. if !allow_list.is_empty() { for credential_id in allow_list { - let credential = match Credential::try_from(self, rp_id_hash, credential_id) { + let mut credential = match Credential::try_from(self, rp_id_hash, credential_id) + { Ok(credential) => credential, _ => continue, }; @@ -1337,6 +1338,27 @@ impl crate::Authenticator { continue; } + // CTAP 2.1 §6.2.3 — for resident credentials, the daemon + // must include the `user` field in the response. Modern + // versions of this app encrypt only a Stripped credential + // into `credential_id`, which omits user data. For RKs we + // can recover the FullCredential from disk by hashing + // the credential_id; if the RK file is missing fall back + // to whatever try_from gave us. + if matches!(&credential, Credential::Stripped(s) + if matches!(s.key, Key::ResidentKey(_))) + { + let credential_id_hash = self.hash(credential_id.id); + let path = rk_path(rp_id_hash, &credential_id_hash); + if let Ok(reply) = + try_syscall!(self.trussed.read_file(Location::Internal, path)) + { + if let Ok(full) = FullCredential::deserialize(&reply.data) { + credential = Credential::Full(full); + } + } + } + return Ok(Some((credential, 1))); } From d42c1b841328c81f4d7ab52c608996fca2ae8801 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Fri, 15 May 2026 12:15:34 +0200 Subject: [PATCH 14/15] ctap2: refactor make_credential / get_assertion to take &mut Response --- src/ctap2.rs | 57 ++++++++++++++++++++++++------------------------- src/dispatch.rs | 21 +++++++----------- 2 files changed, 36 insertions(+), 42 deletions(-) diff --git a/src/ctap2.rs b/src/ctap2.rs index 1dcf240..eeb91de 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -176,14 +176,17 @@ impl Authenticator for crate::Authenti // 7. reset timer // 8. increment credential counter (not applicable) - self.assert_with_credential(None, Credential::Full(credential)) + let mut response = ctap2::get_assertion::Response::empty(); + self.assert_with_credential(None, &Credential::Full(credential), &mut response)?; + Ok(response) } #[inline(never)] - fn make_credential( + fn make_credential_into( &mut self, parameters: &ctap2::make_credential::Request, - ) -> Result { + response: &mut ctap2::make_credential::Response, + ) -> Result<()> { let rp_id_hash = self.hash(parameters.rp.id.as_ref()); // 1-4. @@ -604,16 +607,13 @@ impl Authenticator for crate::Authenti info_now!("deleted private credential key: {}", _success); } - let mut attestation_object = ctap2::make_credential::ResponseBuilder { - fmt: att_stmt_fmt - .map(From::from) - .unwrap_or(AttestationStatementFormat::None), - auth_data: serialized_auth_data, - } - .build(); - attestation_object.att_stmt = att_stmt; - attestation_object.large_blob_key = large_blob_key; - Ok(attestation_object) + response.fmt = att_stmt_fmt + .map(From::from) + .unwrap_or(AttestationStatementFormat::None); + response.auth_data = serialized_auth_data; + response.att_stmt = att_stmt; + response.large_blob_key = large_blob_key; + Ok(()) } #[inline(never)] @@ -1095,10 +1095,11 @@ impl Authenticator for crate::Authenti } #[inline(never)] - fn get_assertion( + fn get_assertion_into( &mut self, parameters: &ctap2::get_assertion::Request, - ) -> Result { + response: &mut ctap2::get_assertion::Response, + ) -> Result<()> { debug_now!("remaining stack size: {} bytes", msp() - 0x2000_0000); let rp_id_hash = self.hash(parameters.rp_id.as_ref()); @@ -1188,7 +1189,7 @@ impl Authenticator for crate::Authenti n => Some(n), }; - self.assert_with_credential(num_credentials, credential) + self.assert_with_credential(num_credentials, &credential, response) } #[inline(never)] @@ -1802,8 +1803,9 @@ impl crate::Authenticator { fn assert_with_credential( &mut self, num_credentials: Option, - credential: Credential, - ) -> Result { + credential: &Credential, + response: &mut ctap2::get_assertion::Response, + ) -> Result<()> { let data = self.state.runtime.active_get_assertion.clone().unwrap(); let rp_id_hash = &data.rp_id_hash; @@ -1840,7 +1842,7 @@ impl crate::Authenticator { } large_blob_key_requested = extensions.large_blob_key == Some(true); } - self.process_assertion_extensions(&data, extensions, &credential, key)? + self.process_assertion_extensions(&data, extensions, credential, key)? } else { None }; @@ -1945,18 +1947,15 @@ impl crate::Authenticator { syscall!(self.trussed.delete(key)); } - let mut response = ctap2::get_assertion::ResponseBuilder { - credential: credential_id.into(), - auth_data: serialized_auth_data, - signature, - } - .build(); + response.credential = credential_id.into(); + response.auth_data = serialized_auth_data; + response.signature = signature; response.number_of_credentials = num_credentials; response.att_stmt = att_stmt; // User with empty IDs are ignored for compatibility if is_rk { - if let Credential::Full(credential) = &credential { + if let Credential::Full(credential) = credential { if !credential.user.id().is_empty() { let mut user: PublicKeyCredentialUserEntity = credential.user.clone().into(); // User identifiable information (name, DisplayName, icon) MUST not @@ -1974,13 +1973,13 @@ impl crate::Authenticator { if large_blob_key_requested { debug!("Sending largeBlobKey in getAssertion"); response.large_blob_key = match credential { - Credential::Stripped(stripped) => stripped.large_blob_key, - Credential::Full(full) => full.data.large_blob_key, + Credential::Stripped(stripped) => stripped.large_blob_key.clone(), + Credential::Full(full) => full.data.large_blob_key.clone(), }; } } - Ok(response) + Ok(()) } #[inline(never)] diff --git a/src/dispatch.rs b/src/dispatch.rs index 92e6b69..d52d38f 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -145,13 +145,11 @@ where msp() - 0x2000_0000 ); - // let ctap_request = ctap2::Request::deserialize(data) - // .map_err(|error| error as u8)?; - // let ctap_response = ctap2::Authenticator::call_ctap2(authenticator, &ctap_request) - // .map_err(|error| error as u8)?; - - // Goal of these nested scopes is to keep stack small. - let ctap_response = try_get_ctap2_response(authenticator, data)?; + // ctap_response lives here (this is the only stack slot for the + // ~6 KB ctap2::Response with mldsa44). Inner layers fill it in + // place via &mut, avoiding by-value copies. + let mut ctap_response = ctap2::Response::Reset; + try_get_ctap2_response(authenticator, data, &mut ctap_response)?; ctap_response.serialize(response); Ok(()) } @@ -160,7 +158,8 @@ where fn try_get_ctap2_response( authenticator: &mut Authenticator, data: &[u8], -) -> Result + ctap_response: &mut ctap2::Response, +) -> Result<(), u8> where T: TrussedRequirements, UP: UserPresence, @@ -190,11 +189,7 @@ where debug!("2a SP: {:X}", msp()); use ctap2::Authenticator; authenticator - .call_ctap2(&ctap_request) - .inspect(|_response| { - info!("Sending CTAP2 response {:?}", response_operation(_response)); - trace!("CTAP2 response: {:?}", _response); - }) + .call_ctap2(&ctap_request, ctap_response) .map_err(|error| { info!("CTAP2 error: {:?}", error); error as u8 From 29e94b39ceed9c6bd9bb9d0446ac78df4eb700a9 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Fri, 15 May 2026 12:16:12 +0200 Subject: [PATCH 15/15] ctap2.3: implement ML-DSA-44 (COSE alg -50, behind mldsa44 feature) --- Cargo.toml | 18 ++- src/ctap1.rs | 6 + src/ctap2.rs | 195 +++++++++++++++++------------ src/ctap2/credential_management.rs | 7 ++ src/ctap2/pin.rs | 6 +- src/lib.rs | 38 +++++- src/state.rs | 6 +- 7 files changed, 184 insertions(+), 92 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 10e64b4..6a31c64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,14 @@ disable-reset-time-window = [] # enables support for a large-blob array longer than 1024 bytes chunked = ["dep:trussed-chunked"] +# enables ML-DSA-44 (FIPS 204, COSE alg -50). When off, the variant, the +# `pubKeyCredParams = -50` arm, the GetInfo algorithm entry, and the +# `Mldsa44` Trussed-core requirement are all elided. Off by default. +# Also bumps `MAX_PACKED_SIG_LENGTH`, x5c element size, and authData buffer +# in ctap-types so packed attestation can carry the 2420-byte ML-DSA-44 sig +# alongside the larger 1322-byte COSE_Key in authData. +mldsa44 = ["trussed-core/mldsa44", "ctap-types/mldsa44"] + log-all = [] log-none = [] log-trace = [] @@ -52,6 +60,14 @@ cbc = { version = "0.1.2", features = ["alloc"] } ciborium = "0.2.2" ciborium-io = "0.2.2" cipher = "0.4.4" +# The lib's `mldsa44` feature normally turns on both `trussed-core/mldsa44` +# and `ctap-types/mldsa44` together; they keep `Message`'s inner Bytes size +# (1024 → 2048) and `x5c`'s inner Bytes size in lockstep. The dev-dep +# `trussed` below pulls in `trussed-core/mldsa44` unconditionally for the +# test runner, so we need `ctap-types/mldsa44` here too — otherwise the +# `x5c.push(cert)` sites in `ctap2.rs` see `Bytes<2048>` going into a +# `Bytes<1024>` slot and `cargo test` fails to compile. +ctap-types = { version = "0.5", features = ["mldsa44"] } ctaphid = { version = "0.3.1", default-features = false } ctaphid-dispatch = "0.4" delog = { version = "0.1.6", features = ["std-log"] } @@ -67,7 +83,7 @@ rand = "0.8.4" rand_chacha = "0.3" sha2 = "0.10" serde_test = "1.0.176" -trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "0f8df68be879acdde1f8cf428c11e5d29692a47b", features = ["virt"] } +trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "0f8df68be879acdde1f8cf428c11e5d29692a47b", features = ["mldsa44", "virt"] } trussed-staging = { git = "https://github.com/trussed-dev/trussed-staging.git", tag = "v0.4.0", features = ["chunked", "hkdf", "virt", "fs-info"] } trussed-usbip = { git = "https://github.com/trussed-dev/pc-usbip-runner.git", rev = "017921df0930707c4af68882ccb1f8b3f1bbf7c5", default-features = false, features = ["ctaphid"] } usbd-ctaphid = "0.4" diff --git a/src/ctap1.rs b/src/ctap1.rs index 2b8e29f..fb776d1 100644 --- a/src/ctap1.rs +++ b/src/ctap1.rs @@ -140,6 +140,12 @@ impl Authenticator for crate::Authenti } }; + // U2F register's `attestation_certificate` is fixed at `Bytes<1024>`. + // Real attestation certs comfortably fit; we lift it from the + // trussed `Message`-typed read so it works regardless of how the + // mldsa44 feature sizes that Message buffer. + let cert = + ctap_types::Bytes::<1024>::try_from(&*cert).map_err(|_| Error::NotEnoughMemory)?; Ok(register::Response::new( 0x05, &cose_key, diff --git a/src/ctap2.rs b/src/ctap2.rs index eeb91de..f6d3de3 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -114,6 +114,10 @@ impl Authenticator for crate::Authenti algorithms .push(KnownPublicKeyCredentialParameters { alg: ED_DSA }) .unwrap(); + #[cfg(feature = "mldsa44")] + algorithms + .push(KnownPublicKeyCredentialParameters { alg: -50 }) + .ok(); let algorithms = FilteredPublicKeyCredentialParameters(algorithms); let remaining_discoverable_credentials = self.estimate_remaining(); @@ -131,8 +135,8 @@ impl Authenticator for crate::Authenti response.max_cred_id_length = Some(ctap_types::sizes::MAX_CREDENTIAL_ID_LENGTH); response.algorithms = Some(algorithms); response.firmware_version = Some(self.config.firmware_version as usize); - response.remaining_discoverable_credentials = remaining_discoverable_credentials - .map(|count| count as usize); + response.remaining_discoverable_credentials = + remaining_discoverable_credentials.map(|count| count as usize); response.max_cred_blob_length = Some(constants::MAX_CRED_BLOB_LENGTH); response.min_pin_length = Some(self.state.persistent.min_pin_length() as usize); response.force_pin_change = Some(self.state.persistent.force_pin_change()); @@ -233,17 +237,21 @@ impl Authenticator for crate::Authenti // 7. check pubKeyCredParams algorithm is valid + supported COSE identifier + // CTAP §6.1.2: walk pubKeyCredParams in order and pick the first + // supported algorithm. The guard on every arm matters — without + // it later entries silently overwrite earlier ones and we end + // up with last-match instead of first-match (caught by the + // ML-DSA-44 vs EdDSA preference test). let mut algorithm: Option = None; for param in parameters.pub_key_cred_params.0.iter() { + if algorithm.is_some() { + break; + } match param.alg { - -7 => { - if algorithm.is_none() { - algorithm = Some(SigningAlgorithm::P256); - } - } - -8 => { - algorithm = Some(SigningAlgorithm::Ed25519); - } + -7 => algorithm = Some(SigningAlgorithm::P256), + -8 => algorithm = Some(SigningAlgorithm::Ed25519), + #[cfg(feature = "mldsa44")] + -50 => algorithm = Some(SigningAlgorithm::MlDsa44), _ => {} } } @@ -556,7 +564,7 @@ impl Authenticator for crate::Authenti }; // debug_now!("authData = {:?}", &authenticator_data); - let serialized_auth_data = authenticator_data.serialize()?; + let mut serialized_auth_data = authenticator_data.serialize()?; // select attestation format or use packed attestation as default let att_stmt_fmt = parameters @@ -570,11 +578,15 @@ impl Authenticator for crate::Authenti Some(AttestationStatement::None(NoneAttestationStatement {})) } SupportedAttestationFormat::Packed => { - let mut commitment = Bytes::<1024>::new(); - commitment - .extend_from_slice(&serialized_auth_data) - .map_err(|_| Error::Other)?; - commitment + // Build the "commitment" (auth_data ‖ cdh) IN PLACE inside + // `serialized_auth_data` to avoid a separate 2.3 KB local. + // With `mldsa44`, `SerializedAuthenticatorData` has 2048 B + // capacity, comfortably fitting the ~1577 B auth_data + 32 B + // cdh = ~1609 B. After signing we truncate to restore the + // original auth_data length so the buffer can be moved into + // `response.auth_data`. + let auth_data_len = serialized_auth_data.len(); + serialized_auth_data .extend_from_slice(parameters.client_data_hash) .map_err(|_| Error::Other)?; @@ -582,8 +594,12 @@ impl Authenticator for crate::Authenti .as_ref() .map(|attestation| (attestation.0, SigningAlgorithm::P256)) .unwrap_or((private_key, algorithm)); - let signature = - attestation_algorithm.sign(&mut self.trussed, attestation_key, &commitment); + let signature = attestation_algorithm.sign( + &mut self.trussed, + attestation_key, + &serialized_auth_data, + ); + serialized_auth_data.truncate(auth_data_len); let packed = PackedAttestationStatement { alg: attestation_algorithm.into(), sig: Bytes::try_from(&*signature).map_err(|_| Error::Other)?, @@ -607,6 +623,11 @@ impl Authenticator for crate::Authenti info_now!("deleted private credential key: {}", _success); } + // Write fields directly into the caller-provided slot — avoids + // the 6 KB Response by-value return + move through the dispatch + // chain. `serialized_auth_data` still lives transiently on this + // function's stack (≈2 KB); future work could write it directly + // into `response.auth_data` via a mutable serialize sink. response.fmt = att_stmt_fmt .map(From::from) .unwrap_or(AttestationStatementFormat::None); @@ -682,10 +703,7 @@ impl Authenticator for crate::Authenti match request.sub_command { Subcommand::SetMinPINLength => self.config_set_min_pin_length(request), - Subcommand::ToggleAlwaysUv => self - .state - .persistent - .toggle_always_uv(&mut self.trussed), + Subcommand::ToggleAlwaysUv => self.state.persistent.toggle_always_uv(&mut self.trussed), // CTAP 2.3 §6.11.5: long-touch is the only reset gesture we // support, hard-wired on. The subcommand is therefore a no-op: // already enabled, so we just acknowledge. @@ -1238,10 +1256,7 @@ impl crate::Authenticator { let mut owned = heapless::Vec::new(); for id in rp_ids { owned - .push( - heapless::String::try_from(*id) - .map_err(|_| Error::PinPolicyViolation)?, - ) + .push(heapless::String::try_from(*id).map_err(|_| Error::PinPolicyViolation)?) .map_err(|_| Error::PinPolicyViolation)?; } self.state @@ -1788,12 +1803,7 @@ impl crate::Authenticator { if extensions.cred_blob.unwrap_or(false) { // Spec: if the extension was requested but no blob is associated // with the credential, return an empty byte string (not absent). - output.cred_blob = Some( - credential - .cred_blob() - .cloned() - .unwrap_or_else(Bytes::new), - ); + output.cred_blob = Some(credential.cred_blob().cloned().unwrap_or_else(Bytes::new)); } Ok(output.is_set().then_some(output)) @@ -1882,66 +1892,53 @@ impl crate::Authenticator { extensions: extensions_output, }; - let serialized_auth_data = authenticator_data.serialize()?; + let mut serialized_auth_data = authenticator_data.serialize()?; - let mut commitment = Bytes::<1024>::new(); - commitment - .extend_from_slice(&serialized_auth_data) - .map_err(|_| Error::Other)?; - commitment + // Build commitment in place: append client_data_hash to serialized_auth_data, + // sign over the concatenation, then truncate back. Mirrors the elision + // done in make_credential — avoids a separate Bytes<1024> commitment buffer. + let auth_data_len = serialized_auth_data.len(); + serialized_auth_data .extend_from_slice(&data.client_data_hash) .map_err(|_| Error::Other)?; let signing_algorithm = SigningAlgorithm::try_from(credential.algorithm()).map_err(|_| Error::Other)?; - let signature = - Bytes::try_from(&*signing_algorithm.sign(&mut self.trussed, key, &commitment)).unwrap(); + let signature = Bytes::try_from( + &*signing_algorithm.sign(&mut self.trussed, key, &serialized_auth_data), + ) + .unwrap(); - // select preferred format or skip attestation statement + // select preferred format or skip attestation statement. + // + // The Packed branch's `PackedAttestationStatement` carries a + // `Bytes` sig (2436 B) and an x5c + // `Bytes` cert (2052 B) — ~4.5 KB total + // with `mldsa44`. Outline the construction into a `#[inline(never)]` + // helper so those temporaries live in the helper's frame, not + // in `assert_with_credential`'s (which is preserved on the lower + // task's stack during the 72 KB libcrux_sign call above us). let att_stmt_fmt = data .attestation_formats_preference .as_ref() .and_then(SupportedAttestationFormat::select); - let att_stmt = if let Some(format) = att_stmt_fmt { - match format { - SupportedAttestationFormat::None => { - Some(AttestationStatement::None(NoneAttestationStatement {})) - } - SupportedAttestationFormat::Packed => { - let (attestation_maybe, _) = self.state.identity.attestation(&mut self.trussed); - let (signature, attestation_algorithm) = { - if let Some(attestation) = attestation_maybe.as_ref() { - let signing_algorithm = SigningAlgorithm::P256; - let signature = signing_algorithm.sign( - &mut self.trussed, - attestation.0, - &commitment, - ); - ( - Bytes::try_from(&*signature).map_err(|_| Error::Other)?, - signing_algorithm.into(), - ) - } else { - (signature.clone(), credential.algorithm()) - } - }; - let packed = PackedAttestationStatement { - alg: attestation_algorithm, - sig: signature, - x5c: attestation_maybe.as_ref().map(|attestation| { - // See: https://www.w3.org/TR/webauthn-2/#sctn-packed-attestation-cert-requirements - let cert = attestation.1.clone(); - let mut x5c = Vec::new(); - x5c.push(cert).ok(); - x5c - }), - }; - Some(AttestationStatement::Packed(packed)) - } + match att_stmt_fmt { + Some(SupportedAttestationFormat::None) => { + response.att_stmt = Some(AttestationStatement::None(NoneAttestationStatement {})); } - } else { - None - }; + Some(SupportedAttestationFormat::Packed) => { + self.build_packed_att_stmt( + &serialized_auth_data, + &signature, + credential.algorithm(), + response, + )?; + } + None => {} + } + + // Truncate back so the response carries only authData (without cdh). + serialized_auth_data.truncate(auth_data_len); if !is_rk { syscall!(self.trussed.delete(key)); @@ -1951,7 +1948,6 @@ impl crate::Authenticator { response.auth_data = serialized_auth_data; response.signature = signature; response.number_of_credentials = num_credentials; - response.att_stmt = att_stmt; // User with empty IDs are ignored for compatibility if is_rk { @@ -1982,6 +1978,43 @@ impl crate::Authenticator { Ok(()) } + /// Build a `Packed` attestation statement for `get_assertion` and + /// write it into `response.att_stmt`. Outlined so its ~4.5 KB worth + /// of temporaries (`Bytes` re-sign output plus + /// the x5c cert clone) live here instead of inflating the caller's + /// preserved stack while libcrux_sign runs above us. + #[inline(never)] + fn build_packed_att_stmt( + &mut self, + message: &[u8], + fallback_sig: &Bytes<{ ctap_types::sizes::MAX_PACKED_SIG_LENGTH }>, + fallback_alg: i32, + response: &mut ctap2::get_assertion::Response, + ) -> Result<()> { + let (attestation_maybe, _) = self.state.identity.attestation(&mut self.trussed); + let (sig, alg) = if let Some(attestation) = attestation_maybe.as_ref() { + let signing_algorithm = SigningAlgorithm::P256; + let att_sig = signing_algorithm.sign(&mut self.trussed, attestation.0, message); + ( + Bytes::try_from(&*att_sig).map_err(|_| Error::Other)?, + signing_algorithm.into(), + ) + } else { + (fallback_sig.clone(), fallback_alg) + }; + response.att_stmt = Some(AttestationStatement::Packed(PackedAttestationStatement { + alg, + sig, + x5c: attestation_maybe.as_ref().map(|attestation| { + let cert = attestation.1.clone(); + let mut x5c = Vec::new(); + x5c.push(cert).ok(); + x5c + }), + })); + Ok(()) + } + #[inline(never)] fn delete_resident_key_by_user_id( &mut self, diff --git a/src/ctap2/credential_management.rs b/src/ctap2/credential_management.rs index 1218766..2a36ca3 100644 --- a/src/ctap2/credential_management.rs +++ b/src/ctap2/credential_management.rs @@ -407,6 +407,13 @@ where SigningAlgorithm::Ed25519 => PublicKey::Ed25519Key( ctap_types::serde::cbor_deserialize(&cose_public_key).unwrap(), ), + // `cosey::PublicKey` doesn't have an ML-DSA variant (yet); the + // credential itself works for GA, but `credentialManagement` can't + // serialise its public key via this path. Skip rather than crash — + // the platform will see `Err(InvalidCredential)` and can fall back + // to GA + signature verification to obtain the key. + #[cfg(feature = "mldsa44")] + SigningAlgorithm::MlDsa44 => return Err(Error::InvalidCredential), }; let cred_protect = match credential.cred_protect { Some(x) => Some(x), diff --git a/src/ctap2/pin.rs b/src/ctap2/pin.rs index 096d6a3..e797de3 100644 --- a/src/ctap2/pin.rs +++ b/src/ctap2/pin.rs @@ -428,7 +428,7 @@ impl SharedSecret { } #[must_use] - pub fn encrypt(&self, trussed: &mut T, data: &[u8]) -> Bytes<1024> { + pub fn encrypt(&self, trussed: &mut T, data: &[u8]) -> Message { let key_id = self.aes_key_id(); let iv = self.generate_iv(trussed); let mut ciphertext = @@ -444,7 +444,7 @@ impl SharedSecret { } #[must_use] - fn wrap(&self, trussed: &mut T, key: KeyId) -> Bytes<1024> { + fn wrap(&self, trussed: &mut T, key: KeyId) -> Message { let wrapping_key = self.aes_key_id(); let iv = self.generate_iv(trussed); let mut wrapped_key = syscall!(trussed.wrap_key( @@ -465,7 +465,7 @@ impl SharedSecret { } #[must_use] - pub fn decrypt(&self, trussed: &mut T, data: &[u8]) -> Option> { + pub fn decrypt(&self, trussed: &mut T, data: &[u8]) -> Option { let key_id = self.aes_key_id(); let (iv, data) = match self { Self::V1 { .. } => (Default::default(), data), diff --git a/src/lib.rs b/src/lib.rs index ecba5f3..7ba7c5f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -71,6 +71,7 @@ pub trait TrussedRequirements: + mechanisms::Sha256 + mechanisms::HmacSha256 + mechanisms::Ed255 + + MldsaRequirement + FsInfoClient + HkdfClient + ExtensionRequirements @@ -89,6 +90,7 @@ impl TrussedRequirements for T where + mechanisms::Sha256 + mechanisms::HmacSha256 + mechanisms::Ed255 + + MldsaRequirement + FsInfoClient + HkdfClient + ExtensionRequirements @@ -107,6 +109,22 @@ pub trait ExtensionRequirements: trussed_chunked::ChunkedClient {} #[cfg(feature = "chunked")] impl ExtensionRequirements for T where T: trussed_chunked::ChunkedClient {} +/// Marker trait that, with the `mldsa44` feature enabled, requires the +/// Trussed client to expose the ML-DSA-44 mechanism. With the feature +/// disabled it is a no-op so non-PQ builds do not pay any size or stack +/// tax for ML-DSA. +#[cfg(not(feature = "mldsa44"))] +pub trait MldsaRequirement {} + +#[cfg(not(feature = "mldsa44"))] +impl MldsaRequirement for T {} + +#[cfg(feature = "mldsa44")] +pub trait MldsaRequirement: mechanisms::Mldsa44 {} + +#[cfg(feature = "mldsa44")] +impl MldsaRequirement for T where T: mechanisms::Mldsa44 {} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] /// Externally defined configuration. pub struct Config { @@ -205,7 +223,8 @@ pub(crate) fn msp() -> u32 { 0x2000_0000 } -/// Currently Ed25519 and P256. +/// Signing algorithms we know about. COSE alg ids: Ed25519 = -8, P-256 = -7, +/// ML-DSA-44 = -50 (FIPS 204 / WebAuthn L3, behind the `mldsa44` feature). #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(i32)] #[non_exhaustive] @@ -214,6 +233,9 @@ pub enum SigningAlgorithm { Ed25519 = -8, /// The NIST P-256 signature algorithm. P256 = -7, + /// FIPS 204 ML-DSA-44 (NIST level 2 post-quantum signature). + #[cfg(feature = "mldsa44")] + MlDsa44 = -50, } impl SigningAlgorithm { @@ -221,6 +243,8 @@ impl SigningAlgorithm { match self { Self::Ed25519 => Mechanism::Ed255, Self::P256 => Mechanism::P256, + #[cfg(feature = "mldsa44")] + Self::MlDsa44 => Mechanism::Mldsa44, } } @@ -228,6 +252,8 @@ impl SigningAlgorithm { match self { Self::Ed25519 => SignatureSerialization::Raw, Self::P256 => SignatureSerialization::Asn1Der, + #[cfg(feature = "mldsa44")] + Self::MlDsa44 => SignatureSerialization::Raw, } } @@ -276,6 +302,8 @@ impl From for i32 { match alg { SigningAlgorithm::P256 => -7, SigningAlgorithm::Ed25519 => -8, + #[cfg(feature = "mldsa44")] + SigningAlgorithm::MlDsa44 => -50, } } } @@ -287,6 +315,8 @@ impl TryFrom for SigningAlgorithm { Ok(match alg { -7 => SigningAlgorithm::P256, -8 => SigningAlgorithm::Ed25519, + #[cfg(feature = "mldsa44")] + -50 => SigningAlgorithm::MlDsa44, _ => return Err(Error::UnsupportedAlgorithm), }) } @@ -355,9 +385,9 @@ impl UserPresence for Conforming { timeout_milliseconds: u32, ) -> Result<()> { use trussed_core::types::consent::Level; - let result = syscall!(trussed - .confirm_user_present_with_level(Level::Strong, timeout_milliseconds)) - .result; + let result = + syscall!(trussed.confirm_user_present_with_level(Level::Strong, timeout_milliseconds)) + .result; result.map_err(|err| match err { trussed_core::types::consent::Error::TimedOut => Error::UserActionTimeout, trussed_core::types::consent::Error::Interrupted => Error::KeepaliveCancel, diff --git a/src/state.rs b/src/state.rs index 8a1ba09..710d3bd 100644 --- a/src/state.rs +++ b/src/state.rs @@ -326,9 +326,9 @@ impl PersistentState { // Trussed's read syscall conflates "file does not exist" and // "read failure" into a single `FilesystemReadFailure`. Either way, // this is a fresh authenticator from our perspective. - let data = match try_syscall!(trussed - .read_file(Location::Internal, PathBuf::from(Self::FILENAME))) - { + let data = match try_syscall!( + trussed.read_file(Location::Internal, PathBuf::from(Self::FILENAME)) + ) { Ok(reply) => reply.data, Err(_) => return Ok(None), };