From fe3744990a7d8d16ad6d738834360edbde5ab2a5 Mon Sep 17 00:00:00 2001 From: Emanuele Cesena Date: Fri, 8 May 2026 23:32:05 +0200 Subject: [PATCH] ctap2.1: implement credBlob extension (RK only, max 32 bytes) --- CHANGELOG.md | 1 + src/credential.rs | 19 +++++++++++++++++++ src/ctap2.rs | 43 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f63db46..ab15e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update to `ctap-types` v0.6.0-rc.1. - Set `algorithms`, `firmware_version` and `remaining_discoverable_credentials` in `get_info` and add `firmware_version` to `Config`. +- Implement the `credBlob` extension. ## [v0.3.0](https://github.com/trussed-dev/fido-authenticator/releases/tag/v0.3.0) (2026-03-25) diff --git a/src/credential.rs b/src/credential.rs index bb70f15..f248e11 100644 --- a/src/credential.rs +++ b/src/credential.rs @@ -2,6 +2,7 @@ use core::cmp::Ordering; +use ctap_types::sizes::MAX_CRED_BLOB_LENGTH; use serde::Serialize; use serde_bytes::ByteArray; use trussed_core::{ @@ -197,6 +198,13 @@ impl Credential { Self::Stripped(credential) => credential.third_party_payment, } } + + pub fn cred_blob(&self) -> Option<&Bytes> { + match self { + Self::Full(credential) => credential.data.cred_blob.as_ref(), + Self::Stripped(_) => None, + } + } } fn deserialize_bytes( @@ -518,6 +526,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 +625,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 +642,7 @@ impl FullCredential { cred_protect, large_blob_key, third_party_payment, + cred_blob, use_short_id: Some(true), }; @@ -814,6 +829,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 +861,7 @@ mod test { use_short_id: None, large_blob_key: None, third_party_payment: None, + cred_blob: None, } } @@ -932,6 +949,7 @@ mod test { use_short_id: Some(true), large_blob_key: Some(random_byte_array()), third_party_payment: Some(false), + cred_blob: None, } } @@ -1333,6 +1351,7 @@ mod test { use_short_id: Some(true), large_blob_key: None, third_party_payment: None, + cred_blob: None, }, ); } diff --git a/src/ctap2.rs b/src/ctap2.rs index ad80acf..bbc74af 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -9,7 +9,7 @@ use ctap_types::{ }, heapless::{String, Vec}, heapless_bytes::Bytes, - sizes, + sizes::{self, MAX_CRED_BLOB_LENGTH}, webauthn::{ FilteredPublicKeyCredentialParameters, KnownPublicKeyCredentialParameters, PublicKeyCredentialUserEntity, ED_DSA, ES256, @@ -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 = self.config.firmware_version; response.remaining_discoverable_credentials = remaining_discoverable_credentials.map(|count| count as usize); + response.max_cred_blob_length = Some(MAX_CRED_BLOB_LENGTH); response.attestation_formats = Some(attestation_formats); response } @@ -253,6 +255,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; @@ -277,6 +281,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() <= 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); @@ -348,6 +366,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, ); @@ -408,7 +427,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 @@ -428,10 +450,19 @@ impl Authenticator for crate::Authenti }, extensions: { - 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 + { 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 @@ -1546,6 +1577,12 @@ 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)) }