From 804588adbc86078b492395362a9d385ba95dd306 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Sat, 30 May 2026 22:49:04 +0200 Subject: [PATCH 1/5] Introduce CredentialIdVersion enum Currently, the credential encoding and decoding is located in different parts of the codebase. This patch introduces the CredentialIdVersion to unify all relevant code in one place. It also introduces the KeyEncryptionKey and KeyWrappingKey helper types to ensure that these keys are only used for the designated steps and cannot be confused with any other key. --- src/credential.rs | 228 +++++++++++++++++++++-------- src/ctap1.rs | 25 +--- src/ctap2.rs | 36 ++--- src/ctap2/credential_management.rs | 3 +- src/state.rs | 43 ++++-- 5 files changed, 214 insertions(+), 121 deletions(-) diff --git a/src/credential.rs b/src/credential.rs index f248e11..c86ae9a 100644 --- a/src/credential.rs +++ b/src/credential.rs @@ -6,9 +6,9 @@ use ctap_types::sizes::MAX_CRED_BLOB_LENGTH; use serde::Serialize; use serde_bytes::ByteArray; use trussed_core::{ - mechanisms::{Chacha8Poly1305, Sha256}, + mechanisms::Sha256, syscall, try_syscall, - types::{EncryptedData, KeyId}, + types::{EncryptedData, KeyId, Location, Mechanism, StorageAttributes}, CryptoClient, FilesystemClient, }; @@ -36,29 +36,63 @@ pub enum CtapVersion { Fido21Pre, } -/// External ID of a credential, commonly known as "keyhandle". -#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] -pub struct CredentialId(pub Bytes); +/// Format version for the credential ID. +/// +/// The version is stored with the persistent state so that it is only changed if a reset is +/// performed and thus old credentials are invalidated. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub enum CredentialIdVersion { + /// Private keys for non-resident credentials are wrapped using Chacha8Poly1305, serialized + /// credential is encrypted using Chacha8Poly1305. + V1, +} -impl CredentialId { - fn new( +impl CredentialIdVersion { + fn mechanism(self) -> Mechanism { + match self { + Self::V1 => Mechanism::Chacha8Poly1305, + } + } + + fn generate_key(&self, trussed: &mut T) -> KeyId { + syscall!(trussed.generate_key( + self.mechanism(), + StorageAttributes::new().set_persistence(Location::Internal) + )) + .key + } + + pub fn generate_key_encryption_key( + &self, + trussed: &mut T, + ) -> KeyEncryptionKey { + KeyEncryptionKey(self.generate_key(trussed)) + } + + pub fn generate_key_wrapping_key(&self, trussed: &mut T) -> KeyWrappingKey { + KeyWrappingKey(self.generate_key(trussed)) + } + + pub fn id( + &self, trussed: &mut T, credential: &C, - key_encryption_key: KeyId, + key_encryption_key: KeyEncryptionKey, rp_id_hash: &[u8; 32], nonce: &[u8; 12], - ) -> Result { + ) -> Result { let mut serialized_credential = SerializedCredential::new(); cbor_smol::cbor_serialize_to(credential, &mut serialized_credential) .map_err(|_| Error::Other)?; let message = &serialized_credential; // info!("serialized cred = {:?}", message).ok(); let associated_data = &rp_id_hash[..]; - let encrypted_serialized_credential = syscall!(trussed.encrypt_chacha8poly1305( - key_encryption_key, + let encrypted_serialized_credential = syscall!(trussed.encrypt( + self.mechanism(), + key_encryption_key.0, message, associated_data, - Some(nonce) + Some(nonce.into()), )); let mut credential_id = Bytes::new(); cbor_smol::cbor_serialize_to( @@ -66,10 +100,80 @@ impl CredentialId { &mut credential_id, ) .map_err(|_| Error::RequestTooLarge)?; - Ok(Self(credential_id)) + Ok(CredentialId(credential_id)) + } + + pub fn credential( + &self, + trussed: &mut T, + key_encryption_key: KeyEncryptionKey, + rp_id_hash: &[u8; 32], + id: &[u8], + ) -> Result { + let encrypted_serialized = CredentialIdRef(id).deserialize()?; + + let serialized = try_syscall!(trussed.decrypt( + self.mechanism(), + key_encryption_key.0, + &encrypted_serialized.ciphertext, + &rp_id_hash[..], + &encrypted_serialized.nonce, + &encrypted_serialized.tag, + )) + .map_err(|_| Error::InvalidCredential)? + .plaintext + .ok_or(Error::InvalidCredential)?; + + // In older versions of this app, we serialized the full credential. Now we only serialize + // the stripped credential. For compatibility, we have to try both. + FullCredential::deserialize(&serialized) + .map(Credential::Full) + .or_else(|_| StrippedCredential::deserialize(&serialized).map(Credential::Stripped)) + .map_err(|_| Error::InvalidCredential) + } + + pub fn wrap_key( + &self, + trussed: &mut T, + key_wrapping_key: KeyWrappingKey, + key: KeyId, + ) -> Result { + let wrapped_key = + syscall!(trussed.wrap_key(self.mechanism(), key_wrapping_key.0, key, &[], None)) + .wrapped_key; + Ok(Key::WrappedKey( + Bytes::try_from(wrapped_key.as_slice()).map_err(|_| Error::Other)?, + )) + } + + pub fn unwrap_key( + &self, + trussed: &mut T, + key_wrapping_key: KeyWrappingKey, + key: &Bytes<128>, + ) -> Option { + syscall!(trussed.unwrap_key( + self.mechanism(), + key_wrapping_key.0, + key.increase_capacity(), + &[], + &[], + StorageAttributes::new().set_persistence(Location::Volatile), + )) + .key } } +#[derive(Clone, Copy, Debug)] +pub struct KeyEncryptionKey(pub KeyId); + +#[derive(Clone, Copy, Debug)] +pub struct KeyWrappingKey(pub KeyId); + +/// External ID of a credential, commonly known as "keyhandle". +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct CredentialId(pub Bytes); + struct CredentialIdRef<'a>(&'a [u8]); impl CredentialIdRef<'_> { @@ -111,7 +215,7 @@ pub enum Credential { } impl Credential { - pub fn try_from( + pub fn try_from( authnr: &mut Authenticator, rp_id_hash: &[u8; 32], descriptor: &PublicKeyCredentialDescriptorRef, @@ -119,49 +223,32 @@ impl Credential { Self::try_from_bytes(authnr, rp_id_hash, descriptor.id) } - pub fn try_from_bytes< - UP: UserPresence, - T: CryptoClient + Chacha8Poly1305 + FilesystemClient, - >( + pub fn try_from_bytes( authnr: &mut Authenticator, rp_id_hash: &[u8; 32], id: &[u8], ) -> Result { - let encrypted_serialized = CredentialIdRef(id).deserialize()?; - + let id_version = authnr.state.persistent.credential_id_version(); let kek = authnr .state .persistent .key_encryption_key(&mut authnr.trussed)?; - let serialized = try_syscall!(authnr.trussed.decrypt_chacha8poly1305( - kek, - &encrypted_serialized.ciphertext, - &rp_id_hash[..], - &encrypted_serialized.nonce, - &encrypted_serialized.tag, - )) - .map_err(|_| Error::InvalidCredential)? - .plaintext - .ok_or(Error::InvalidCredential)?; - - // In older versions of this app, we serialized the full credential. Now we only serialize - // the stripped credential. For compatibility, we have to try both. - FullCredential::deserialize(&serialized) - .map(Self::Full) - .or_else(|_| StrippedCredential::deserialize(&serialized).map(Self::Stripped)) - .map_err(|_| Error::InvalidCredential) + id_version.credential(&mut authnr.trussed, kek, rp_id_hash, id) } - pub fn id( + pub fn id( &self, trussed: &mut T, - key_encryption_key: KeyId, + id_version: CredentialIdVersion, + key_encryption_key: KeyEncryptionKey, rp_id_hash: &[u8; 32], ) -> Result { match self { - Self::Full(credential) => credential.id(trussed, key_encryption_key, Some(rp_id_hash)), - Self::Stripped(credential) => CredentialId::new( + Self::Full(credential) => { + credential.id(trussed, id_version, key_encryption_key, Some(rp_id_hash)) + } + Self::Stripped(credential) => id_version.id( trussed, credential, key_encryption_key, @@ -667,10 +754,11 @@ impl FullCredential { // the ID will stay below 255 bytes. // // Existing keyhandles can still be decoded - pub fn id( + pub fn id( &self, trussed: &mut T, - key_encryption_key: KeyId, + id_version: CredentialIdVersion, + key_encryption_key: KeyEncryptionKey, rp_id_hash: Option<&[u8; 32]>, ) -> Result { let rp_id_hash: [u8; 32] = if let Some(hash) = rp_id_hash { @@ -683,10 +771,10 @@ impl FullCredential { .map_err(|_| Error::Other)? }; if self.use_short_id.unwrap_or_default() { - StrippedCredential::from(self).id(trussed, key_encryption_key, &rp_id_hash) + StrippedCredential::from(self).id(trussed, id_version, key_encryption_key, &rp_id_hash) } else { let stripped_credential = self.strip(); - CredentialId::new( + id_version.id( trussed, &stripped_credential, key_encryption_key, @@ -763,13 +851,14 @@ impl StrippedCredential { } } - pub fn id( + pub fn id( &self, trussed: &mut T, - key_encryption_key: KeyId, + id_version: CredentialIdVersion, + key_encryption_key: KeyEncryptionKey, rp_id_hash: &[u8; 32], ) -> Result { - CredentialId::new(trussed, self, key_encryption_key, rp_id_hash, &self.nonce) + id_version.id(trussed, self, key_encryption_key, rp_id_hash, &self.nonce) } } @@ -994,12 +1083,13 @@ mod test { ) .unwrap() }; + let kek = KeyEncryptionKey(kek); platform.run_client(client_id.as_str(), |mut client| { let data = old_credential_data(); let rp_id_hash = syscall!(client.hash_sha256(data.rp.id().as_ref())).hash; let encrypted_serialized = CredentialIdRef(OLD_ID).deserialize().unwrap(); let serialized = syscall!(client.decrypt_chacha8poly1305( - kek, + kek.0, &encrypted_serialized.ciphertext, &rp_id_hash, &encrypted_serialized.nonce, @@ -1036,7 +1126,12 @@ mod test { let credential = Credential::Full(full); let id = credential - .id(&mut client, kek, rp_id_hash.as_ref().try_into().unwrap()) + .id( + &mut client, + CredentialIdVersion::V1, + kek, + rp_id_hash.as_ref().try_into().unwrap(), + ) .unwrap() .0; assert_eq!( @@ -1050,7 +1145,8 @@ mod test { #[test] fn credential_ids() { trussed::virt::with_client(StoreConfig::ram(), "fido", |mut client| { - let kek = syscall!(client.generate_chacha8poly1305_key(Location::Internal)).key; + let format = CredentialIdVersion::V1; + let kek = format.generate_key_encryption_key(&mut client); let nonce = ByteArray::new([0; 12]); let data = credential_data(); let mut full_credential = FullCredential { @@ -1068,10 +1164,10 @@ mod test { full_credential.data.use_short_id = Some(true); let stripped_credential = StrippedCredential::from(&full_credential); let full_id = full_credential - .id(&mut client, kek, Some(&rp_id_hash)) + .id(&mut client, format, kek, Some(&rp_id_hash)) .unwrap(); let short_id = stripped_credential - .id(&mut client, kek, &rp_id_hash) + .id(&mut client, format, kek, &rp_id_hash) .unwrap(); assert_eq!(full_id.0, short_id.0); @@ -1079,16 +1175,17 @@ mod test { full_credential.data.use_short_id = None; let stripped_credential = full_credential.strip(); let full_id = full_credential - .id(&mut client, kek, Some(&rp_id_hash)) + .id(&mut client, format, kek, Some(&rp_id_hash)) + .unwrap(); + let long_id = format + .id( + &mut client, + &stripped_credential, + kek, + &rp_id_hash, + &full_credential.nonce, + ) .unwrap(); - let long_id = CredentialId::new( - &mut client, - &stripped_credential, - kek, - &rp_id_hash, - &full_credential.nonce, - ) - .unwrap(); assert_eq!(full_id.0, long_id.0); assert!(short_id.0.len() < long_id.0.len()); @@ -1112,13 +1209,16 @@ mod test { third_party_payment: Some(true), }; trussed::virt::with_client(StoreConfig::ram(), "fido", |mut client| { - let kek = syscall!(client.generate_chacha8poly1305_key(Location::Internal)).key; + let id_version = CredentialIdVersion::V1; + let kek = id_version.generate_key_encryption_key(&mut client); let rp_id_hash = syscall!(client.hash_sha256(rp_id.as_ref())) .hash .as_slice() .try_into() .unwrap(); - let id = credential.id(&mut client, kek, &rp_id_hash).unwrap(); + let id = credential + .id(&mut client, id_version, kek, &rp_id_hash) + .unwrap(); assert_eq!(id.0.len(), 241); }); } diff --git a/src/ctap1.rs b/src/ctap1.rs index 7a0a478..24494d2 100644 --- a/src/ctap1.rs +++ b/src/ctap1.rs @@ -68,18 +68,13 @@ impl Authenticator for crate::Authenti .map_err(|_| Error::UnspecifiedCheckingError)?; // debug!("wrapping u2f private key"); - let wrapped_key = - syscall!(self - .trussed - .wrap_key_chacha8poly1305(wrapping_key, private_key, &[], None)) - .wrapped_key; - // debug!("wrapped_key = {:?}", &wrapped_key); + let credential_id_version = self.state.persistent.credential_id_version(); + let key = credential_id_version + .wrap_key(&mut self.trussed, wrapping_key, private_key) + .map_err(|_| Error::UnspecifiedCheckingError)?; syscall!(self.trussed.delete(private_key)); - let key = Key::WrappedKey( - Bytes::try_from(&*wrapped_key).map_err(|_| Error::UnspecifiedCheckingError)?, - ); let nonce = ByteArray::new(self.nonce()); let credential = StrippedCredential { @@ -108,7 +103,7 @@ impl Authenticator for crate::Authenti .key_encryption_key(&mut self.trussed) .map_err(|_| Error::NotEnoughMemory)?; let credential_id = credential - .id(&mut self.trussed, kek, reg.app_id) + .id(&mut self.trussed, credential_id_version, kek, reg.app_id) .map_err(|_| Error::NotEnoughMemory)?; let mut commitment = Commitment::new(); @@ -191,18 +186,14 @@ impl Authenticator for crate::Authenti let key = match cred.key() { Key::WrappedKey(bytes) => { + let credential_id_version = self.state.persistent.credential_id_version(); let wrapping_key = self .state .persistent .key_wrapping_key(&mut self.trussed) .map_err(|_| Error::IncorrectDataParameter)?; - let key_result = syscall!(self.trussed.unwrap_key_chacha8poly1305( - wrapping_key, - bytes, - &[], - Location::Volatile, - )) - .key; + let key_result = + credential_id_version.unwrap_key(&mut self.trussed, wrapping_key, bytes); match key_result { Some(key) => { info!("loaded u2f key!"); diff --git a/src/ctap2.rs b/src/ctap2.rs index 8fcc26e..9d7fff2 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -400,6 +400,7 @@ impl Authenticator for crate::Authenti }; // 12. if `rk` is set, store or overwrite key pair, if full error KeyStoreFull + let credential_id_version = self.state.persistent.credential_id_version(); // 12.a generate credential let key_parameter = match rk_requested { @@ -407,19 +408,7 @@ impl Authenticator for crate::Authenti false => { // WrappedKey version let wrapping_key = self.state.persistent.key_wrapping_key(&mut self.trussed)?; - let wrapped_key = syscall!(self.trussed.wrap_key_chacha8poly1305( - wrapping_key, - private_key, - &[], - None - )) - .wrapped_key; - - // 32B key, 12B nonce, 16B tag + some info on algorithm (P256/Ed25519) - // Turns out it's size 92 (enum serialization not optimized yet...) - // let mut wrapped_key = Bytes::<60>::new(); - // wrapped_key.extend_from_slice(&wrapped_key_msg).unwrap(); - Key::WrappedKey(Bytes::try_from(&*wrapped_key).map_err(|_| Error::Other)?) + credential_id_version.wrap_key(&mut self.trussed, wrapping_key, private_key)? } }; @@ -459,8 +448,12 @@ impl Authenticator for crate::Authenti ); // note that this does the "stripping" of OptionalUI etc. - let credential_id = - StrippedCredential::from(&credential).id(&mut self.trussed, kek, &rp_id_hash)?; + let credential_id = StrippedCredential::from(&credential).id( + &mut self.trussed, + credential_id_version, + kek, + &rp_id_hash, + )?; if rk_requested { // serialization with all metadata @@ -2049,6 +2042,7 @@ impl crate::Authenticator { credential: Credential, ) -> Result { let data = self.state.runtime.active_get_assertion.clone().unwrap(); + let credential_id_version = self.state.persistent.credential_id_version(); let rp_id_hash = &data.rp_id_hash; let (key, is_rk) = match credential.key().clone() { @@ -2056,13 +2050,8 @@ impl crate::Authenticator { Key::WrappedKey(bytes) => { let wrapping_key = self.state.persistent.key_wrapping_key(&mut self.trussed)?; // info_now!("unwrapping {:?} with wrapping key {:?}", &bytes, &wrapping_key); - let key_result = syscall!(self.trussed.unwrap_key_chacha8poly1305( - wrapping_key, - &bytes, - &[], - Location::Volatile, - )) - .key; + let key_result = + credential_id_version.unwrap_key(&mut self.trussed, wrapping_key, &bytes); // debug_now!("key result: {:?}", &key_result); info_now!("key result"); match key_result { @@ -2096,7 +2085,8 @@ impl crate::Authenticator { .state .persistent .key_encryption_key(&mut self.trussed)?; - let credential_id = credential.id(&mut self.trussed, kek, rp_id_hash)?; + let credential_id = + credential.id(&mut self.trussed, credential_id_version, kek, rp_id_hash)?; use ctap2::AuthenticatorDataFlags as Flags; diff --git a/src/ctap2/credential_management.rs b/src/ctap2/credential_management.rs index 1218766..bdef8b1 100644 --- a/src/ctap2/credential_management.rs +++ b/src/ctap2/credential_management.rs @@ -383,12 +383,13 @@ where // why these contortions to get kek. sheesh let authnr = &mut self.authnr; + let credential_id_version = authnr.state.persistent.credential_id_version(); let kek = authnr .state .persistent .key_encryption_key(&mut authnr.trussed)?; - let credential_id = credential.id(&mut self.trussed, kek, None)?; + let credential_id = credential.id(&mut self.trussed, credential_id_version, kek, None)?; use crate::credential::Key; let private_key = match credential.key { diff --git a/src/state.rs b/src/state.rs index 847e748..4536919 100644 --- a/src/state.rs +++ b/src/state.rs @@ -18,7 +18,7 @@ use ctap_types::{ }; use littlefs2_core::{path, Path}; use trussed_core::{ - mechanisms::{Chacha8Poly1305, P256}, + mechanisms::P256, syscall, try_syscall, types::{KeyId, Location, Mechanism, Message, PathBuf}, CertificateClient, CryptoClient, FilesystemClient, @@ -30,7 +30,7 @@ use heapless::{ }; use crate::{ - credential::FullCredential, + credential::{CredentialIdVersion, FullCredential, KeyEncryptionKey, KeyWrappingKey}, ctap2::{self, pin::PinProtocolState}, Result, }; @@ -269,6 +269,7 @@ pub struct PersistentState { // We could alternatively make all methods take a TrussedClient as parameter initialised: bool, + credential_id_version: Option, key_encryption_key: Option, key_wrapping_key: Option, consecutive_pin_mismatches: u8, @@ -317,6 +318,7 @@ impl PersistentState { initialised: false, key_encryption_key: None, key_wrapping_key: None, + credential_id_version: None, consecutive_pin_mismatches: 0, pin_hash: None, pin_code_point_length: 0, @@ -420,49 +422,58 @@ impl PersistentState { Ok(now) } - pub fn key_encryption_key( + pub fn credential_id_version(&self) -> CredentialIdVersion { + self.credential_id_version + .unwrap_or(CredentialIdVersion::V1) + } + + pub fn key_encryption_key( &mut self, trussed: &mut T, - ) -> Result { + ) -> Result { match self.key_encryption_key { - Some(key) => Ok(key), + Some(key) => Ok(KeyEncryptionKey(key)), None => self.rotate_key_encryption_key(trussed), } } - pub fn rotate_key_encryption_key( + pub fn rotate_key_encryption_key( &mut self, trussed: &mut T, - ) -> Result { + ) -> Result { if let Some(key) = self.key_encryption_key { syscall!(trussed.delete(key)); } - let key = syscall!(trussed.generate_chacha8poly1305_key(Location::Internal)).key; - self.key_encryption_key = Some(key); + let key = self + .credential_id_version() + .generate_key_encryption_key(trussed); + self.key_encryption_key = Some(key.0); self.save(trussed)?; Ok(key) } - pub fn key_wrapping_key( + pub fn key_wrapping_key( &mut self, trussed: &mut T, - ) -> Result { + ) -> Result { match self.key_wrapping_key { - Some(key) => Ok(key), + Some(key) => Ok(KeyWrappingKey(key)), None => self.rotate_key_wrapping_key(trussed), } } - pub fn rotate_key_wrapping_key( + pub fn rotate_key_wrapping_key( &mut self, trussed: &mut T, - ) -> Result { + ) -> Result { self.load_if_not_initialised(trussed); if let Some(key) = self.key_wrapping_key { syscall!(trussed.delete(key)); } - let key = syscall!(trussed.generate_chacha8poly1305_key(Location::Internal)).key; - self.key_wrapping_key = Some(key); + let key = self + .credential_id_version() + .generate_key_wrapping_key(trussed); + self.key_wrapping_key = Some(key.0); self.save(trussed)?; Ok(key) } From b7becd0020b4cf6f6c7f0c7474e58d33f12f62a0 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Sat, 30 May 2026 23:03:24 +0200 Subject: [PATCH 2/5] Make credential ID version configurable This patch adds the credential_id_version field to Config, making it possible to select the credential ID version for new credentials. To avoid invalidating existing credentials, this value is only used on the first boot or after a factory reset. --- CHANGELOG.md | 2 ++ fuzz/fuzz_targets/ctap.rs | 1 + src/ctap2.rs | 4 +++- src/dispatch.rs | 6 +++--- src/lib.rs | 18 ++++++++++++++++++ src/state.rs | 28 +++++++++++++++++++--------- tests/virt/mod.rs | 1 + 7 files changed, 47 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb783b4..5c46587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Set the initial signature counter to 1. - Correctly handle signature counter overflows by returning 0. - Increment the signature counter by a positive random number per assertion. +- Add the `Config::new` method to create an instance with the default values. +- Add support for multiple credential ID versions and add the `credential_id_version` field to `Config`. ## [v0.4.0-rc.1](https://github.com/trussed-dev/fido-authenticator/releases/tag/v0.4.0-rc.1) (2026-05-29) diff --git a/fuzz/fuzz_targets/ctap.rs b/fuzz/fuzz_targets/ctap.rs index 2b9b045..158ad9d 100644 --- a/fuzz/fuzz_targets/ctap.rs +++ b/fuzz/fuzz_targets/ctap.rs @@ -20,6 +20,7 @@ fuzz_target!(|requests: Vec>| { nfc_transport: false, ccid_transport: false, firmware_version: Some(0), + credential_id_version: None, }, ); diff --git a/src/ctap2.rs b/src/ctap2.rs index 9d7fff2..d67189f 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -648,7 +648,9 @@ impl Authenticator for crate::Authenti large_blobs::reset(&mut self.trussed); // b. delete persistent state - self.state.persistent.reset(&mut self.trussed)?; + self.state + .persistent + .reset(&mut self.trussed, &self.config)?; // c. Reset runtime state self.state.runtime.reset(&mut self.trussed); diff --git a/src/dispatch.rs b/src/dispatch.rs index 92e6b69..6c8cb8f 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -103,7 +103,7 @@ where authenticator .state .persistent - .load_if_not_initialised(&mut authenticator.trussed); + .load_if_not_initialised(&mut authenticator.trussed, &authenticator.config); // let command = apdu_dispatch::Command::try_from(data) // .map_err(|_| Status::IncorrectDataParameter)?; @@ -138,7 +138,7 @@ where authenticator .state .persistent - .load_if_not_initialised(&mut authenticator.trussed); + .load_if_not_initialised(&mut authenticator.trussed, &authenticator.config); debug!( "try_handle CTAP2: remaining stack: {} bytes", @@ -169,7 +169,7 @@ where authenticator .state .persistent - .load_if_not_initialised(&mut authenticator.trussed); + .load_if_not_initialised(&mut authenticator.trussed, &authenticator.config); debug!( "try_get CTAP2: remaining stack: {} bytes", diff --git a/src/lib.rs b/src/lib.rs index 03f400b..f16be97 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -134,9 +134,27 @@ pub struct Config { /// /// The runner is expected to plumb its own version constant in here. pub firmware_version: Option, + /// The credential ID format to use for new credentials. + /// + /// To avoid invalidating existing credentials, this value is only used if the state is clean, + /// i. e. on the first start or after a reset. Otherwise, `V1` is used. + pub credential_id_version: Option, } impl Config { + pub fn new(max_msg_size: usize) -> Self { + Self { + max_msg_size, + skip_up_timeout: None, + max_resident_credential_count: None, + large_blobs: None, + nfc_transport: false, + ccid_transport: false, + firmware_version: None, + credential_id_version: None, + } + } + pub fn supports_large_blobs(&self) -> bool { self.large_blobs.is_some() } diff --git a/src/state.rs b/src/state.rs index 4536919..b437c59 100644 --- a/src/state.rs +++ b/src/state.rs @@ -32,7 +32,7 @@ use heapless::{ use crate::{ credential::{CredentialIdVersion, FullCredential, KeyEncryptionKey, KeyWrappingKey}, ctap2::{self, pin::PinProtocolState}, - Result, + Config, Result, }; #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -313,12 +313,12 @@ impl PersistentState { const FILENAME: &'static Path = path!("persistent-state.cbor"); /// The default value for the state if it is initialized for the first time or reset. - fn reset_value() -> Self { + fn reset_value(config: &Config) -> Self { Self { initialised: false, key_encryption_key: None, key_wrapping_key: None, - credential_id_version: None, + credential_id_version: config.credential_id_version, consecutive_pin_mismatches: 0, pin_hash: None, pin_code_point_length: 0, @@ -369,19 +369,27 @@ impl PersistentState { Ok(()) } - pub fn reset(&mut self, trussed: &mut T) -> Result<()> { + pub fn reset( + &mut self, + trussed: &mut T, + config: &Config, + ) -> Result<()> { if let Some(key) = self.key_encryption_key { syscall!(trussed.delete(key)); } if let Some(key) = self.key_wrapping_key { syscall!(trussed.delete(key)); } - *self = Self::reset_value(); + *self = Self::reset_value(config); self.initialised = true; self.save(trussed) } - pub fn load_if_not_initialised(&mut self, trussed: &mut T) { + pub fn load_if_not_initialised( + &mut self, + trussed: &mut T, + config: &Config, + ) { if !self.initialised { match Self::load(trussed) { Ok(previous_self) => { @@ -389,8 +397,9 @@ impl PersistentState { *self = previous_self } Err(_err) => { + // if the state is missing or corrupted, start fresh with a clean state info!("error with previous state! {:?}", _err); - *self = Self::reset_value(); + *self = Self::reset_value(config); } } self.initialised = true; @@ -466,7 +475,6 @@ impl PersistentState { &mut self, trussed: &mut T, ) -> Result { - self.load_if_not_initialised(trussed); if let Some(key) = self.key_wrapping_key { syscall!(trussed.delete(key)); } @@ -705,6 +713,7 @@ impl RuntimeState { #[cfg(test)] mod tests { use super::*; + use crate::Config; use hex_literal::hex; use trussed::{ backend::BackendId, @@ -728,6 +737,7 @@ mod tests { #[test] fn test_signature_counter() { + let config = Config::new(0); virt::with_platform(StoreConfig::ram(), |platform| { platform.run_client_with_backends( "fido", @@ -738,7 +748,7 @@ mod tests { ], |mut client| { let mut state = PersistentState::default(); - state.load_if_not_initialised(&mut client); + state.load_if_not_initialised(&mut client, &config); let counter1 = state.signature_counter(&mut client).unwrap(); let counter2 = state.signature_counter(&mut client).unwrap(); diff --git a/tests/virt/mod.rs b/tests/virt/mod.rs index 71f54c5..0dc6841 100644 --- a/tests/virt/mod.rs +++ b/tests/virt/mod.rs @@ -76,6 +76,7 @@ where nfc_transport: options.nfc_transport, ccid_transport: options.ccid_transport, firmware_version: Some(0), + credential_id_version: None, }, ); From 595629c9a5acab7a2e1eb108f3af87dd4aa8d58c Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Sat, 30 May 2026 23:12:07 +0200 Subject: [PATCH 3/5] Add CredentialIdVersion::V2 using AES-256-GCM --- CHANGELOG.md | 1 + Cargo.toml | 8 +++++--- fuzz/Cargo.toml | 2 +- src/credential.rs | 5 +++++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c46587..58c6bde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Increment the signature counter by a positive random number per assertion. - Add the `Config::new` method to create an instance with the default values. - Add support for multiple credential ID versions and add the `credential_id_version` field to `Config`. +- Add `CredentialIdVersion::V2` using AES-256-GCM. ## [v0.4.0-rc.1](https://github.com/trussed-dev/fido-authenticator/releases/tag/v0.4.0-rc.1) (2026-05-29) diff --git a/Cargo.toml b/Cargo.toml index eb66182..c2ecf47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ serde_bytes = { version = "0.11.14", default-features = false } serde-indexed = "0.1" sha2 = { version = "0.10", default-features = false } trussed-chunked = { version = "0.3", optional = true } -trussed-core = { version = "0.2", features = ["aes256-cbc", "certificate-client", "chacha8-poly1305", "crypto-client", "ed255", "filesystem-client", "hmac-sha256", "management-client", "p256", "sha256", "ui-client"] } +trussed-core = { version = "0.2.2", features = ["aes256-cbc", "certificate-client", "chacha8-poly1305", "crypto-client", "ed255", "filesystem-client", "hmac-sha256", "management-client", "p256", "sha256", "ui-client"] } trussed-fs-info = "0.3" trussed-hkdf = "0.4" @@ -34,6 +34,8 @@ apdu-dispatch = ["dep:apdu-app"] ctaphid-dispatch = ["dep:ctaphid-app"] disable-reset-time-window = [] +credential-id-format-v2 = ["trussed-core/aes256-gcm"] + # enables support for a large-blob array longer than 1024 bytes chunked = ["dep:trussed-chunked"] @@ -67,7 +69,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 = "ad577412599156ac98f29ee969e76537e506f2bc", features = ["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" @@ -77,7 +79,7 @@ x509-parser = "0.16" features = ["chunked", "dispatch"] [patch.crates-io] -trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "0f8df68be879acdde1f8cf428c11e5d29692a47b" } +trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "ad577412599156ac98f29ee969e76537e506f2bc" } [profile.test] opt-level = 2 diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 2c86f52..9baff13 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -24,5 +24,5 @@ doc = false bench = false [patch.crates-io] -trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "0f8df68be879acdde1f8cf428c11e5d29692a47b" } +trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "ad577412599156ac98f29ee969e76537e506f2bc" } trussed-staging = { git = "https://github.com/trussed-dev/trussed-staging.git", tag = "v0.4.0" } diff --git a/src/credential.rs b/src/credential.rs index c86ae9a..ad43e82 100644 --- a/src/credential.rs +++ b/src/credential.rs @@ -45,12 +45,17 @@ pub enum CredentialIdVersion { /// Private keys for non-resident credentials are wrapped using Chacha8Poly1305, serialized /// credential is encrypted using Chacha8Poly1305. V1, + /// Like `V1`, but using AES-256-GCM instead of Chacha8Poly1305. + #[cfg(feature = "credential-id-format-v2")] + V2, } impl CredentialIdVersion { fn mechanism(self) -> Mechanism { match self { Self::V1 => Mechanism::Chacha8Poly1305, + #[cfg(feature = "credential-id-format-v2")] + Self::V2 => Mechanism::Aes256Gcm, } } From f82effbf79a65cb098d2bd171ba1ac7ea5ffbf61 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Sat, 30 May 2026 23:23:13 +0200 Subject: [PATCH 4/5] Make firmware version configurable depending on credential ID version --- CHANGELOG.md | 1 + fuzz/fuzz_targets/ctap.rs | 2 +- src/ctap2.rs | 5 ++++- src/lib.rs | 38 +++++++++++++++++++++++++++++++++++++- tests/virt/mod.rs | 2 +- 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c6bde..08b1130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add the `Config::new` method to create an instance with the default values. - Add support for multiple credential ID versions and add the `credential_id_version` field to `Config`. - Add `CredentialIdVersion::V2` using AES-256-GCM. +- Make firmware version configurable depending on the current credential ID format. ## [v0.4.0-rc.1](https://github.com/trussed-dev/fido-authenticator/releases/tag/v0.4.0-rc.1) (2026-05-29) diff --git a/fuzz/fuzz_targets/ctap.rs b/fuzz/fuzz_targets/ctap.rs index 158ad9d..127fc83 100644 --- a/fuzz/fuzz_targets/ctap.rs +++ b/fuzz/fuzz_targets/ctap.rs @@ -19,7 +19,7 @@ fuzz_target!(|requests: Vec>| { large_blobs: None, nfc_transport: false, ccid_transport: false, - firmware_version: Some(0), + firmware_version: Some(0.into()), credential_id_version: None, }, ); diff --git a/src/ctap2.rs b/src/ctap2.rs index d67189f..4bfb452 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -149,7 +149,10 @@ impl Authenticator for crate::Authenti 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 = self.config.firmware_version; + response.firmware_version = self + .config + .firmware_version + .map(|version| version.value(self.state.persistent.credential_id_version())); response.remaining_discoverable_credentials = remaining_discoverable_credentials.map(|count| count as usize); response.max_cred_blob_length = Some(MAX_CRED_BLOB_LENGTH); diff --git a/src/lib.rs b/src/lib.rs index f16be97..65ad612 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -133,7 +133,7 @@ pub struct Config { /// 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: Option, + pub firmware_version: Option, /// The credential ID format to use for new credentials. /// /// To avoid invalidating existing credentials, this value is only used if the state is clean, @@ -160,6 +160,42 @@ impl Config { } } +/// This struct makes it possible to define the firmware version based on the credential ID format +/// that is currently used by the authenticator. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct FirmwareVersion { + pub default: usize, + pub credential_id_v1: Option, + #[cfg(feature = "credential-id-format-v2")] + pub credential_id_v2: Option, +} + +impl FirmwareVersion { + pub fn new(default: usize) -> Self { + Self { + default, + credential_id_v1: None, + #[cfg(feature = "credential-id-format-v2")] + credential_id_v2: None, + } + } + + pub fn value(&self, credential_id_version: credential::CredentialIdVersion) -> usize { + let value = match credential_id_version { + credential::CredentialIdVersion::V1 => self.credential_id_v1, + #[cfg(feature = "credential-id-format-v2")] + credential::CredentialIdVersion::V2 => self.credential_id_v2, + }; + value.unwrap_or(self.default) + } +} + +impl From for FirmwareVersion { + fn from(default: usize) -> Self { + Self::new(default) + } +} + // impl Default for Config { // fn default() -> Self { // Self { diff --git a/tests/virt/mod.rs b/tests/virt/mod.rs index 0dc6841..48468ea 100644 --- a/tests/virt/mod.rs +++ b/tests/virt/mod.rs @@ -75,7 +75,7 @@ where large_blobs: None, nfc_transport: options.nfc_transport, ccid_transport: options.ccid_transport, - firmware_version: Some(0), + firmware_version: Some(0.into()), credential_id_version: None, }, ); From 4c38096af60752a7a9dfb73e0b9ffc162cff33b8 Mon Sep 17 00:00:00 2001 From: Robin Krahl Date: Sat, 30 May 2026 23:24:50 +0200 Subject: [PATCH 5/5] Release v0.4.0-rc.2 --- CHANGELOG.md | 4 ++++ Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b1130..f20e3c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- + +## [v0.4.0-rc.2](https://github.com/trussed-dev/fido-authenticator/releases/tag/v0.4.0-rc.2) (2026-05-31) + - Fix signature counter to improve spec compliance: - Set the initial signature counter to 1. - Correctly handle signature counter overflows by returning 0. diff --git a/Cargo.toml b/Cargo.toml index c2ecf47..0f5b166 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fido-authenticator" -version = "0.4.0-rc.1" +version = "0.4.0-rc.2" authors = ["The Trussed developers", "Nicolas Stalder ", "Nitrokey GmbH"] edition = "2021" license = "Apache-2.0 OR MIT"