From 5f8033c8fe8ffc8b44e5c32ab61e8f18d794d4d6 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 10 May 2026 21:11:10 +0100 Subject: [PATCH 1/4] feat(ctap2): add authenticatorLargeBlobs command (0x0C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the wire-level model and protocol method for CTAP 2.1 `authenticatorLargeBlobs` (command code 0x0C, spec §6.10). This is the device-side primitive the platform uses to fetch and update the authenticator's serialized largeBlobArray. Includes only the `get` request shape so far; `set` is reserved for a follow-up that will also handle the pinUvAuthParam binding required for writes. Refs: CTAP 2.2 §6.10. --- libwebauthn/src/proto/ctap2/cbor/request.rs | 11 +++ libwebauthn/src/proto/ctap2/mod.rs | 1 + libwebauthn/src/proto/ctap2/model.rs | 3 + .../src/proto/ctap2/model/large_blobs.rs | 71 +++++++++++++++++++ libwebauthn/src/proto/ctap2/protocol.rs | 34 ++++++++- 5 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 libwebauthn/src/proto/ctap2/model/large_blobs.rs diff --git a/libwebauthn/src/proto/ctap2/cbor/request.rs b/libwebauthn/src/proto/ctap2/cbor/request.rs index 03828a62..87c3257d 100644 --- a/libwebauthn/src/proto/ctap2/cbor/request.rs +++ b/libwebauthn/src/proto/ctap2/cbor/request.rs @@ -8,6 +8,7 @@ use crate::proto::ctap2::model::Ctap2MakeCredentialRequest; use crate::proto::ctap2::Ctap2AuthenticatorConfigRequest; use crate::proto::ctap2::Ctap2BioEnrollmentRequest; use crate::proto::ctap2::Ctap2CredentialManagementRequest; +use crate::proto::ctap2::Ctap2LargeBlobsRequest; use crate::webauthn::Error; #[derive(Debug, Clone, PartialEq)] @@ -106,3 +107,13 @@ impl TryFrom<&Ctap2CredentialManagementRequest> for CborRequest { }) } } + +impl TryFrom<&Ctap2LargeBlobsRequest> for CborRequest { + type Error = Error; + fn try_from(request: &Ctap2LargeBlobsRequest) -> Result { + Ok(CborRequest { + command: Ctap2CommandCode::AuthenticatorLargeBlobs, + encoded_data: cbor::to_vec(&request)?, + }) + } +} diff --git a/libwebauthn/src/proto/ctap2/mod.rs b/libwebauthn/src/proto/ctap2/mod.rs index a4e1f61e..058cedda 100644 --- a/libwebauthn/src/proto/ctap2/mod.rs +++ b/libwebauthn/src/proto/ctap2/mod.rs @@ -32,6 +32,7 @@ pub use model::{ pub use model::{ Ctap2GetAssertionRequest, Ctap2GetAssertionResponse, Ctap2GetAssertionResponseExtensions, }; +pub use model::{Ctap2LargeBlobsRequest, Ctap2LargeBlobsResponse}; pub use model::{ Ctap2MakeCredentialRequest, Ctap2MakeCredentialResponse, Ctap2MakeCredentialsResponseExtensions, }; diff --git a/libwebauthn/src/proto/ctap2/model.rs b/libwebauthn/src/proto/ctap2/model.rs index 980facf8..63ac46e1 100644 --- a/libwebauthn/src/proto/ctap2/model.rs +++ b/libwebauthn/src/proto/ctap2/model.rs @@ -44,6 +44,8 @@ pub use credential_management::{ Ctap2CredentialData, Ctap2CredentialManagementMetadata, Ctap2CredentialManagementRequest, Ctap2CredentialManagementResponse, Ctap2RPData, }; +mod large_blobs; +pub use large_blobs::{Ctap2LargeBlobsRequest, Ctap2LargeBlobsResponse}; #[derive(Debug, IntoPrimitive, TryFromPrimitive, Copy, Clone, PartialEq, Serialize_repr)] #[repr(u8)] @@ -58,6 +60,7 @@ pub enum Ctap2CommandCode { AuthenticatorCredentialManagement = 0x0A, AuthenticatorCredentialManagementPreview = 0x41, AuthenticatorSelection = 0x0B, + AuthenticatorLargeBlobs = 0x0C, AuthenticatorConfig = 0x0D, } diff --git a/libwebauthn/src/proto/ctap2/model/large_blobs.rs b/libwebauthn/src/proto/ctap2/model/large_blobs.rs new file mode 100644 index 00000000..41bb254e --- /dev/null +++ b/libwebauthn/src/proto/ctap2/model/large_blobs.rs @@ -0,0 +1,71 @@ +//! CTAP 2.1 `authenticatorLargeBlobs` command (`0x0C`). Wire-level model only; +//! see [`crate::ops::webauthn::large_blob`] for the high-level read pipeline. + +use serde_bytes::ByteBuf; +use serde_indexed::{DeserializeIndexed, SerializeIndexed}; + +/// Request parameters. `get` (read) and `set` (write) are mutually exclusive. +#[derive(Debug, Clone, SerializeIndexed)] +pub struct Ctap2LargeBlobsRequest { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(index = 0x01)] + pub get: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(index = 0x02)] + pub set: Option, + + #[serde(index = 0x03)] + pub offset: u32, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(index = 0x04)] + pub length: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(index = 0x05)] + pub pin_uv_auth_param: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(index = 0x06)] + pub pin_uv_auth_protocol: Option, +} + +impl Ctap2LargeBlobsRequest { + pub fn new_get(offset: u32, length: u32) -> Self { + Self { + get: Some(length), + set: None, + offset, + length: None, + pin_uv_auth_param: None, + pin_uv_auth_protocol: None, + } + } +} + +#[cfg_attr(test, derive(SerializeIndexed))] +#[derive(Debug, Default, Clone, DeserializeIndexed)] +pub struct Ctap2LargeBlobsResponse { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(index = 0x01)] + pub config: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::ctap2::cbor; + + #[test] + fn get_request_round_trips_through_cbor() { + let req = Ctap2LargeBlobsRequest::new_get(0, 1024); + let bytes = cbor::to_vec(&req).expect("serialize"); + assert_eq!(bytes[0], 0xa2, "expected CBOR map of two items"); + let value: cbor::Value = cbor::from_slice(&bytes).expect("deserialize"); + let cbor::Value::Map(map) = value else { + panic!("expected map"); + }; + assert_eq!(map.len(), 2); + } +} diff --git a/libwebauthn/src/proto/ctap2/protocol.rs b/libwebauthn/src/proto/ctap2/protocol.rs index 3376adb2..20dc2ec8 100644 --- a/libwebauthn/src/proto/ctap2/protocol.rs +++ b/libwebauthn/src/proto/ctap2/protocol.rs @@ -13,8 +13,8 @@ use super::model::Ctap2ClientPinResponse; use super::{ Ctap2AuthenticatorConfigRequest, Ctap2BioEnrollmentRequest, Ctap2ClientPinRequest, Ctap2CredentialManagementRequest, Ctap2CredentialManagementResponse, Ctap2GetAssertionRequest, - Ctap2GetAssertionResponse, Ctap2GetInfoResponse, Ctap2MakeCredentialRequest, - Ctap2MakeCredentialResponse, + Ctap2GetAssertionResponse, Ctap2GetInfoResponse, Ctap2LargeBlobsRequest, + Ctap2LargeBlobsResponse, Ctap2MakeCredentialRequest, Ctap2MakeCredentialResponse, }; const TIMEOUT_GET_INFO: Duration = Duration::from_millis(250); @@ -73,6 +73,11 @@ pub trait Ctap2 { request: &Ctap2CredentialManagementRequest, timeout: Duration, ) -> Result; + async fn ctap2_large_blobs( + &mut self, + request: &Ctap2LargeBlobsRequest, + timeout: Duration, + ) -> Result; } #[async_trait] @@ -272,4 +277,29 @@ where Ok(Ctap2CredentialManagementResponse::default()) } } + + #[instrument(skip_all)] + async fn ctap2_large_blobs( + &mut self, + request: &Ctap2LargeBlobsRequest, + timeout: Duration, + ) -> Result { + trace!(?request); + self.cbor_send(&request.try_into()?, timeout).await?; + let cbor_response = self.cbor_recv(timeout).await?; + match cbor_response.status_code { + CtapError::Ok => (), + error => return Err(Error::Ctap(error)), + }; + if let Some(data) = cbor_response.data { + let ctap_response = parse_cbor!(Ctap2LargeBlobsResponse, &data); + debug!("CTAP2 LargeBlobs successful"); + trace!(?ctap_response); + Ok(ctap_response) + } else { + // Write responses carry no body; same serde_indexed workaround as + // credential_management above. + Ok(Ctap2LargeBlobsResponse::default()) + } + } } From bab2d4348232318884f09363be00c0df2361895b Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 10 May 2026 21:11:52 +0100 Subject: [PATCH 2/4] feat(webauthn): LargeBlobStorage trait + in-memory + authenticator backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the public `LargeBlobStorage` async trait alongside two bundled implementations: - `MemoryLargeBlobStorage`: a HashMap-backed store, primarily for tests. - `AuthenticatorLargeBlobStorage<'_, C>`: drives the CTAP 2.1 `authenticatorLargeBlobs(get)` command, parses the serialized largeBlobArray, locates the entry matching the supplied per-credential `largeBlobKey` (AES-256-GCM authenticated decryption), and decompresses the deflated plaintext. Only the read path is implemented in this PR. `LargeBlobStorage::write` returns `Unsupported` in both bundled backends; the chunked write path with `pinUvAuthParam` binding is reserved for a follow-up. Includes 14 unit tests covering: in-memory round-trip, AEAD round-trip, wrong-key rejection, multi-entry array selection, corrupted/truncated array rejection, empty array handling, and a MockChannel-backed end-to-end test of the authenticator read flow. Refs: WebAuthn L3 §10.5, CTAP 2.2 §6.10 / §6.10.4 / §11.4. --- Cargo.lock | 44 ++ libwebauthn/Cargo.toml | 2 + libwebauthn/src/ops/webauthn/large_blob.rs | 688 +++++++++++++++++++++ libwebauthn/src/ops/webauthn/mod.rs | 5 + 4 files changed, 739 insertions(+) create mode 100644 libwebauthn/src/ops/webauthn/large_blob.rs diff --git a/Cargo.lock b/Cargo.lock index b2fc9f12..f0e273a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -571,6 +577,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -620,6 +635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1097,6 +1113,16 @@ dependencies = [ "winreg", ] +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flexiber" version = "0.1.3" @@ -1784,6 +1810,7 @@ name = "libwebauthn" version = "0.4.0" dependencies = [ "aes", + "aes-gcm", "apdu", "apdu-core", "async-trait", @@ -1796,6 +1823,7 @@ dependencies = [ "ctap-types", "curve25519-dalek", "dbus", + "flate2", "futures", "heapless", "hex", @@ -1972,6 +2000,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -3040,6 +3078,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" diff --git a/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml index f56bc852..9415c271 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -64,6 +64,8 @@ p256 = { version = "0.13.2", features = ["ecdh", "arithmetic", "serde"] } heapless = "0.7" cosey = "0.3.2" aes = "0.8.2" +aes-gcm = "0.10" +flate2 = "1.0" hmac = "0.12.1" cbc = { version = "0.1", features = ["alloc"] } hkdf = "0.12" diff --git a/libwebauthn/src/ops/webauthn/large_blob.rs b/libwebauthn/src/ops/webauthn/large_blob.rs new file mode 100644 index 00000000..cc12a204 --- /dev/null +++ b/libwebauthn/src/ops/webauthn/large_blob.rs @@ -0,0 +1,688 @@ +//! WebAuthn `largeBlob` storage abstraction. +//! +//! [`LargeBlobStorage`] is the pluggable backend libwebauthn uses for the +//! WebAuthn L3 `largeBlob` extension. Two backends ship with the library: +//! [`AuthenticatorLargeBlobStorage`] (drives CTAP `authenticatorLargeBlobs`) +//! and [`MemoryLargeBlobStorage`] (in-memory, for tests). +//! +//! Only the read path is implemented; [`LargeBlobStorage::write`] returns +//! [`LargeBlobError::Unsupported`] in the bundled impls. + +use std::collections::HashMap; +use std::io::Read; +use std::sync::Mutex; +use std::time::Duration; + +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; +use async_trait::async_trait; +use flate2::read::DeflateDecoder; +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex as AsyncMutex; +use tracing::{debug, trace, warn}; + +use crate::proto::ctap2::{Ctap2, Ctap2LargeBlobsRequest}; +use crate::webauthn::Error; + +/// Default chunk size for paginated `authenticatorLargeBlobs(get)` calls. +pub const LARGE_BLOB_DEFAULT_CHUNK: u32 = 1024; + +const LARGE_BLOB_HASH_LEN: usize = 16; +const LARGE_BLOB_NONCE_LEN: usize = 12; +const LARGE_BLOB_AD_PREFIX: &[u8] = b"blob"; + +/// Errors surfaced by a [`LargeBlobStorage`] backend. +#[derive(thiserror::Error, Debug)] +pub enum LargeBlobError { + #[error("Operation not supported by this LargeBlobStorage backend")] + Unsupported, + #[error("On-device largeBlobArray is malformed: {0}")] + Corrupted(String), + #[error("No entry in largeBlobArray verifies under this credential key")] + EntryNotFound, + #[error(transparent)] + Webauthn(#[from] Error), +} + +/// Read/write API for WebAuthn `largeBlob` payloads. Methods take `&self` +/// so a backend instance can be shared concurrently. +#[async_trait] +pub trait LargeBlobStorage: Send + Sync { + /// Returns the decrypted, decompressed blob for `credential_id`, or + /// `Ok(None)` if no blob is stored. + async fn read(&self, credential_id: &[u8]) -> Result>, LargeBlobError>; + + /// Stores `data` for `credential_id`, replacing any existing blob. + /// Bundled backends currently return [`LargeBlobError::Unsupported`]. + async fn write(&self, credential_id: &[u8], data: &[u8]) -> Result<(), LargeBlobError>; +} + +/// In-memory [`LargeBlobStorage`]. The backing map is shared across `clone()` +/// via an inner `Arc>`. +#[derive(Debug, Default, Clone)] +pub struct MemoryLargeBlobStorage { + inner: std::sync::Arc, Vec>>>, +} + +impl MemoryLargeBlobStorage { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&self, credential_id: &[u8], data: Vec) { + if let Ok(mut map) = self.inner.lock() { + map.insert(credential_id.to_vec(), data); + } + } + + pub fn len(&self) -> usize { + self.inner.lock().map(|m| m.len()).unwrap_or(0) + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +#[async_trait] +impl LargeBlobStorage for MemoryLargeBlobStorage { + async fn read(&self, credential_id: &[u8]) -> Result>, LargeBlobError> { + Ok(self + .inner + .lock() + .ok() + .and_then(|m| m.get(credential_id).cloned())) + } + + async fn write(&self, credential_id: &[u8], data: &[u8]) -> Result<(), LargeBlobError> { + if let Ok(mut m) = self.inner.lock() { + m.insert(credential_id.to_vec(), data.to_vec()); + } + Ok(()) + } +} + +/// Authenticator-backed [`LargeBlobStorage`] scoped to a single credential. +/// +/// Holds the `largeBlobKey` returned by `authenticatorGetAssertion` for one +/// credential, plus a borrowed [`Ctap2`] channel. `read` paginates +/// `authenticatorLargeBlobs(get)`, AES-256-GCM-authenticates each entry under +/// the held key, and RFC 1951 raw-deflate decompresses the plaintext. +pub struct AuthenticatorLargeBlobStorage<'a, C: Ctap2 + ?Sized> { + pub(crate) channel: AsyncMutex<&'a mut C>, + pub(crate) credential_id: Vec, + pub(crate) large_blob_key: [u8; 32], + pub(crate) max_chunk: u32, + pub(crate) timeout: Duration, +} + +impl<'a, C: Ctap2 + ?Sized> AuthenticatorLargeBlobStorage<'a, C> { + pub fn new( + channel: &'a mut C, + credential_id: Vec, + large_blob_key: [u8; 32], + timeout: Duration, + ) -> Self { + Self { + channel: AsyncMutex::new(channel), + credential_id, + large_blob_key, + max_chunk: LARGE_BLOB_DEFAULT_CHUNK, + timeout, + } + } + + pub fn with_chunk_size(mut self, max_chunk: u32) -> Self { + self.max_chunk = max_chunk; + self + } +} + +#[async_trait] +impl LargeBlobStorage for AuthenticatorLargeBlobStorage<'_, C> { + async fn read(&self, credential_id: &[u8]) -> Result>, LargeBlobError> { + // This backend is scoped to one credential's largeBlobKey; reject + // queries for any other credential without contacting the device. + if credential_id != self.credential_id.as_slice() { + return Ok(None); + } + + let serialized = self.fetch_serialized_array().await?; + let array_bytes = strip_array_trailer(&serialized)?; + let array: Vec = parse_large_blob_array(array_bytes)?; + for entry in &array { + if let Some(plaintext) = entry.try_decrypt(&self.large_blob_key)? { + return Ok(Some(plaintext)); + } + } + Ok(None) + } + + async fn write(&self, _credential_id: &[u8], _data: &[u8]) -> Result<(), LargeBlobError> { + Err(LargeBlobError::Unsupported) + } +} + +impl AuthenticatorLargeBlobStorage<'_, C> { + async fn fetch_serialized_array(&self) -> Result, LargeBlobError> { + // Static cap to bound a misbehaving device. + const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024; + let mut out: Vec = Vec::new(); + let mut offset: u32 = 0; + loop { + let req = Ctap2LargeBlobsRequest::new_get(offset, self.max_chunk); + let resp = { + let mut guard = self.channel.lock().await; + guard.ctap2_large_blobs(&req, self.timeout).await + } + .map_err(LargeBlobError::Webauthn)?; + let chunk = resp.config.map(|b| b.into_vec()).unwrap_or_default(); + let chunk_len = chunk.len(); + out.extend_from_slice(&chunk); + trace!( + offset, + chunk_len, + total = out.len(), + "authenticatorLargeBlobs(get) chunk" + ); + if chunk_len < self.max_chunk as usize { + debug!(total = out.len(), "largeBlobArray fully fetched"); + break; + } + if out.len() > MAX_TOTAL_BYTES { + warn!( + total = out.len(), + "largeBlobArray exceeded {}, aborting", MAX_TOTAL_BYTES + ); + return Err(LargeBlobError::Corrupted( + "serialized array exceeds platform cap".into(), + )); + } + offset = offset.saturating_add(chunk_len as u32); + } + Ok(out) + } +} + +const LARGE_BLOB_ENTRY_CIPHERTEXT: i128 = 0x01; +const LARGE_BLOB_ENTRY_NONCE: i128 = 0x02; +const LARGE_BLOB_ENTRY_ORIG_SIZE: i128 = 0x03; + +#[derive(Debug)] +struct LargeBlobMapEntry { + ciphertext: Vec, + nonce: Vec, + orig_size: u64, +} + +impl LargeBlobMapEntry { + /// `Ok(Some)` on AEAD success, `Ok(None)` on tag mismatch (caller continues + /// iterating to find an entry encrypted under this credential's key), + /// `Err` on structural errors. + fn try_decrypt(&self, key: &[u8; 32]) -> Result>, LargeBlobError> { + if self.nonce.len() != LARGE_BLOB_NONCE_LEN { + return Err(LargeBlobError::Corrupted(format!( + "nonce length {} != 12", + self.nonce.len() + ))); + } + + let mut ad = Vec::with_capacity(LARGE_BLOB_AD_PREFIX.len() + 8); + ad.extend_from_slice(LARGE_BLOB_AD_PREFIX); + ad.extend_from_slice(&self.orig_size.to_le_bytes()); + + let cipher = Aes256Gcm::new(Key::::from_slice(key)); + let nonce = Nonce::from_slice(&self.nonce); + let plaintext_compressed = match cipher.decrypt( + nonce, + aes_gcm::aead::Payload { + msg: &self.ciphertext, + aad: &ad, + }, + ) { + Ok(pt) => pt, + Err(_) => { + trace!("largeBlob entry: AES-256-GCM verification failed; skipping"); + return Ok(None); + } + }; + + let mut decoder = DeflateDecoder::new(plaintext_compressed.as_slice()); + let mut decompressed = Vec::with_capacity(self.orig_size as usize); + decoder + .read_to_end(&mut decompressed) + .map_err(|e| LargeBlobError::Corrupted(format!("deflate decompression failed: {e}")))?; + + if decompressed.len() as u64 != self.orig_size { + return Err(LargeBlobError::Corrupted(format!( + "decompressed length {} != origSize {}", + decompressed.len(), + self.orig_size + ))); + } + Ok(Some(decompressed)) + } +} + +fn strip_array_trailer(serialized: &[u8]) -> Result<&[u8], LargeBlobError> { + if serialized.len() < LARGE_BLOB_HASH_LEN { + return Err(LargeBlobError::Corrupted(format!( + "serialized array length {} < trailer length {}", + serialized.len(), + LARGE_BLOB_HASH_LEN + ))); + } + let split = serialized.len() - LARGE_BLOB_HASH_LEN; + let (array, expected_hash) = serialized.split_at(split); + + let mut hasher = Sha256::new(); + hasher.update(array); + let full_hash = hasher.finalize(); + if &full_hash[..LARGE_BLOB_HASH_LEN] != expected_hash { + return Err(LargeBlobError::Corrupted( + "trailer SHA-256 verification failed".into(), + )); + } + Ok(array) +} + +fn parse_large_blob_array(bytes: &[u8]) -> Result, LargeBlobError> { + if bytes == [0x80] || bytes.is_empty() { + return Ok(Vec::new()); + } + + let value: crate::proto::ctap2::cbor::Value = crate::proto::ctap2::cbor::from_slice(bytes) + .map_err(|e| { + LargeBlobError::Corrupted(format!("failed to parse largeBlobArray CBOR: {e}")) + })?; + + let array = match value { + crate::proto::ctap2::cbor::Value::Array(a) => a, + other => { + return Err(LargeBlobError::Corrupted(format!( + "expected CBOR array at top level, got {other:?}" + ))); + } + }; + + let mut entries = Vec::with_capacity(array.len()); + for value in array { + let map = match value { + crate::proto::ctap2::cbor::Value::Map(m) => m, + other => { + return Err(LargeBlobError::Corrupted(format!( + "expected CBOR map in array, got {other:?}" + ))); + } + }; + + let mut ciphertext = None; + let mut nonce = None; + let mut orig_size = None; + for (k, v) in map { + let key = match k { + crate::proto::ctap2::cbor::Value::Integer(i) => i, + _ => continue, + }; + match key { + LARGE_BLOB_ENTRY_CIPHERTEXT => { + if let crate::proto::ctap2::cbor::Value::Bytes(b) = v { + ciphertext = Some(b); + } + } + LARGE_BLOB_ENTRY_NONCE => { + if let crate::proto::ctap2::cbor::Value::Bytes(b) = v { + nonce = Some(b); + } + } + LARGE_BLOB_ENTRY_ORIG_SIZE => { + if let crate::proto::ctap2::cbor::Value::Integer(i) = v { + if i < 0 { + return Err(LargeBlobError::Corrupted(format!( + "negative origSize: {i}" + ))); + } + orig_size = Some(i as u64); + } + } + _ => {} // Unknown keys ignored for forward compatibility. + } + } + let ciphertext = + ciphertext.ok_or_else(|| LargeBlobError::Corrupted("entry missing 0x01".into()))?; + let nonce = nonce.ok_or_else(|| LargeBlobError::Corrupted("entry missing 0x02".into()))?; + let orig_size = + orig_size.ok_or_else(|| LargeBlobError::Corrupted("entry missing 0x03".into()))?; + entries.push(LargeBlobMapEntry { + ciphertext, + nonce, + orig_size, + }); + } + Ok(entries) +} + +/// Test helper: encrypt+compress `plaintext` into a single CBOR-encoded +/// `LargeBlobMap` entry under `key`. +#[cfg(test)] +pub(crate) fn encrypt_entry( + key: &[u8; 32], + nonce: &[u8], + plaintext: &[u8], +) -> Result, LargeBlobError> { + use flate2::write::DeflateEncoder; + use flate2::Compression; + use std::io::Write; + + if nonce.len() != LARGE_BLOB_NONCE_LEN { + return Err(LargeBlobError::Corrupted(format!( + "nonce length {} != 12", + nonce.len() + ))); + } + let mut compressed = Vec::new(); + { + let mut encoder = DeflateEncoder::new(&mut compressed, Compression::default()); + encoder + .write_all(plaintext) + .map_err(|e| LargeBlobError::Corrupted(format!("deflate failure: {e}")))?; + encoder + .finish() + .map_err(|e| LargeBlobError::Corrupted(format!("deflate finish failure: {e}")))?; + } + + let mut ad = Vec::with_capacity(LARGE_BLOB_AD_PREFIX.len() + 8); + ad.extend_from_slice(LARGE_BLOB_AD_PREFIX); + ad.extend_from_slice(&(plaintext.len() as u64).to_le_bytes()); + + let cipher = Aes256Gcm::new(Key::::from_slice(key)); + let nonce_obj = Nonce::from_slice(nonce); + let ciphertext = cipher + .encrypt( + nonce_obj, + aes_gcm::aead::Payload { + msg: &compressed, + aad: &ad, + }, + ) + .map_err(|_| LargeBlobError::Corrupted("AES-256-GCM encryption failed".into()))?; + + use serde_cbor_2::ser::Serializer; + use serde_cbor_2::value::Value as CborVal; + use std::collections::BTreeMap; + let mut map = BTreeMap::new(); + map.insert(CborVal::Integer(1), CborVal::Bytes(ciphertext)); + map.insert(CborVal::Integer(2), CborVal::Bytes(nonce.to_vec())); + map.insert( + CborVal::Integer(3), + CborVal::Integer(plaintext.len() as i128), + ); + + let mut buf = Vec::new(); + let mut ser = Serializer::new(&mut buf); + serde::Serialize::serialize(&CborVal::Map(map), &mut ser) + .map_err(|e| LargeBlobError::Corrupted(format!("entry CBOR serialize failure: {e}")))?; + Ok(buf) +} + +/// Test helper: assemble a CBOR array of entries plus the 16-byte SHA-256 +/// trailer, producing a complete serialized largeBlobArray. +#[cfg(test)] +pub(crate) fn build_serialized_array(entries: &[Vec]) -> Vec { + let mut out = Vec::new(); + let n = entries.len(); + if n <= 23 { + out.push(0x80 | n as u8); + } else if n <= 0xff { + out.push(0x98); + out.push(n as u8); + } else { + out.push(0x99); + out.extend_from_slice(&(n as u16).to_be_bytes()); + } + for entry in entries { + out.extend_from_slice(entry); + } + let mut hasher = Sha256::new(); + hasher.update(&out); + let h = hasher.finalize(); + out.extend_from_slice(&h[..LARGE_BLOB_HASH_LEN]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn memory_storage_round_trips_one_blob() { + let store = MemoryLargeBlobStorage::new(); + assert!(store.is_empty()); + let cred_id = b"cred-1"; + store + .write(cred_id, b"hello world") + .await + .expect("write should succeed"); + let got = store.read(cred_id).await.expect("read"); + assert_eq!(got.as_deref(), Some(&b"hello world"[..])); + assert_eq!(store.len(), 1); + } + + #[tokio::test] + async fn memory_storage_missing_credential_returns_none() { + let store = MemoryLargeBlobStorage::new(); + let got = store + .read(b"absent-cred") + .await + .expect("read should succeed even for missing entries"); + assert!(got.is_none()); + } + + #[tokio::test] + async fn memory_storage_supports_multiple_credentials() { + let store = MemoryLargeBlobStorage::new(); + store.write(b"a", b"alpha").await.unwrap(); + store.write(b"b", b"bravo").await.unwrap(); + assert_eq!( + store.read(b"a").await.unwrap().as_deref(), + Some(&b"alpha"[..]) + ); + assert_eq!( + store.read(b"b").await.unwrap().as_deref(), + Some(&b"bravo"[..]) + ); + assert_eq!(store.len(), 2); + } + + #[test] + fn encrypt_then_decrypt_round_trip() { + let key = [0x42u8; 32]; + let nonce = [0x07u8; 12]; + let plaintext = b"the quick brown fox".to_vec(); + let entry_bytes = encrypt_entry(&key, &nonce, &plaintext).expect("encrypt"); + + let serialized = build_serialized_array(&[entry_bytes]); + let array_bytes = strip_array_trailer(&serialized).expect("trailer"); + let parsed = parse_large_blob_array(array_bytes).expect("parse"); + assert_eq!(parsed.len(), 1); + let plaintext_decoded = parsed[0] + .try_decrypt(&key) + .expect("decrypt") + .expect("entry should verify under the correct key"); + assert_eq!(plaintext_decoded, plaintext); + } + + #[test] + fn decrypt_under_wrong_key_returns_none() { + let real_key = [0x42u8; 32]; + let wrong_key = [0x43u8; 32]; + let nonce = [0x07u8; 12]; + let plaintext = b"secret".to_vec(); + let entry_bytes = encrypt_entry(&real_key, &nonce, &plaintext).expect("encrypt"); + let serialized = build_serialized_array(&[entry_bytes]); + let array_bytes = strip_array_trailer(&serialized).expect("trailer"); + let parsed = parse_large_blob_array(array_bytes).expect("parse"); + let res = parsed[0] + .try_decrypt(&wrong_key) + .expect("decrypt should not error on AEAD failure"); + assert!(res.is_none()); + } + + #[test] + fn corrupted_trailer_is_rejected() { + let mut serialized = build_serialized_array(&[]); + let last = serialized.len() - 1; + serialized[last] ^= 0xff; + let err = strip_array_trailer(&serialized).unwrap_err(); + assert!(matches!(err, LargeBlobError::Corrupted(_))); + } + + #[test] + fn truncated_serialized_array_is_rejected() { + let too_short = vec![0u8; 8]; + let err = strip_array_trailer(&too_short).unwrap_err(); + assert!(matches!(err, LargeBlobError::Corrupted(_))); + } + + #[test] + fn empty_array_parses_to_zero_entries() { + let serialized = build_serialized_array(&[]); + let array_bytes = strip_array_trailer(&serialized).unwrap(); + let parsed = parse_large_blob_array(array_bytes).unwrap(); + assert!(parsed.is_empty()); + } + + #[test] + fn multi_entry_array_finds_matching_key() { + let key_a = [0xa1u8; 32]; + let key_b = [0xb2u8; 32]; + let key_c = [0xc3u8; 32]; + let nonce = [0x55u8; 12]; + let entry_a = encrypt_entry(&key_a, &nonce, b"alpha").unwrap(); + let entry_b = encrypt_entry(&key_b, &nonce, b"bravo").unwrap(); + let entry_c = encrypt_entry(&key_c, &nonce, b"charlie").unwrap(); + let serialized = build_serialized_array(&[entry_a, entry_b, entry_c]); + let array_bytes = strip_array_trailer(&serialized).unwrap(); + let parsed = parse_large_blob_array(array_bytes).unwrap(); + assert_eq!(parsed.len(), 3); + + let mut found_b = None; + for e in &parsed { + if let Some(pt) = e.try_decrypt(&key_b).unwrap() { + found_b = Some(pt); + } + } + assert_eq!(found_b.as_deref(), Some(&b"bravo"[..])); + } + + #[tokio::test] + async fn authenticator_storage_reads_from_mock_channel() { + use crate::proto::ctap2::cbor::{CborRequest, CborResponse}; + use crate::proto::ctap2::{Ctap2CommandCode, Ctap2LargeBlobsResponse}; + use crate::transport::mock::channel::MockChannel; + + let key = [0xC0u8; 32]; + let nonce = [0x11u8; 12]; + let plaintext = b"hello, largeBlob".to_vec(); + let entry = encrypt_entry(&key, &nonce, &plaintext).unwrap(); + let serialized = build_serialized_array(&[entry]); + assert!( + serialized.len() < LARGE_BLOB_DEFAULT_CHUNK as usize, + "test fixture should fit in one chunk" + ); + + let req = Ctap2LargeBlobsRequest::new_get(0, LARGE_BLOB_DEFAULT_CHUNK); + let req_bytes = crate::proto::ctap2::cbor::to_vec(&req).unwrap(); + let expected = CborRequest { + command: Ctap2CommandCode::AuthenticatorLargeBlobs, + encoded_data: req_bytes, + }; + + let resp = Ctap2LargeBlobsResponse { + config: Some(serde_bytes::ByteBuf::from(serialized)), + }; + let resp_bytes = crate::proto::ctap2::cbor::to_vec(&resp).unwrap(); + let response = CborResponse::new_success_from_slice(&resp_bytes); + + let mut channel = MockChannel::new(); + channel.push_command_pair(expected, response); + + let credential_id = b"my-cred".to_vec(); + let storage = AuthenticatorLargeBlobStorage::new( + &mut channel, + credential_id.clone(), + key, + Duration::from_secs(5), + ); + let got = storage + .read(&credential_id) + .await + .expect("read should succeed"); + assert_eq!(got.as_deref(), Some(plaintext.as_slice())); + } + + #[tokio::test] + async fn authenticator_storage_returns_none_for_different_credential() { + use crate::transport::mock::channel::MockChannel; + + let mut channel = MockChannel::new(); + let storage = AuthenticatorLargeBlobStorage::new( + &mut channel, + b"cred-A".to_vec(), + [0u8; 32], + Duration::from_secs(5), + ); + let got = storage.read(b"cred-B").await.expect("read"); + assert!(got.is_none()); + } + + #[tokio::test] + async fn authenticator_storage_write_returns_unsupported() { + use crate::transport::mock::channel::MockChannel; + + let mut channel = MockChannel::new(); + let storage = AuthenticatorLargeBlobStorage::new( + &mut channel, + b"cred-A".to_vec(), + [0u8; 32], + Duration::from_secs(5), + ); + let err = storage + .write(b"cred-A", b"payload") + .await + .expect_err("write should be unsupported"); + assert!(matches!(err, LargeBlobError::Unsupported)); + } + + #[tokio::test] + async fn authenticator_storage_handles_empty_array() { + use crate::proto::ctap2::cbor::{CborRequest, CborResponse}; + use crate::proto::ctap2::{Ctap2CommandCode, Ctap2LargeBlobsResponse}; + use crate::transport::mock::channel::MockChannel; + + let serialized = build_serialized_array(&[]); + let req = Ctap2LargeBlobsRequest::new_get(0, LARGE_BLOB_DEFAULT_CHUNK); + let req_bytes = crate::proto::ctap2::cbor::to_vec(&req).unwrap(); + let expected = CborRequest { + command: Ctap2CommandCode::AuthenticatorLargeBlobs, + encoded_data: req_bytes, + }; + let resp = Ctap2LargeBlobsResponse { + config: Some(serde_bytes::ByteBuf::from(serialized)), + }; + let resp_bytes = crate::proto::ctap2::cbor::to_vec(&resp).unwrap(); + let response = CborResponse::new_success_from_slice(&resp_bytes); + + let mut channel = MockChannel::new(); + channel.push_command_pair(expected, response); + + let storage = AuthenticatorLargeBlobStorage::new( + &mut channel, + b"cred-A".to_vec(), + [0xAA; 32], + Duration::from_secs(5), + ); + let got = storage.read(b"cred-A").await.expect("read"); + assert!(got.is_none()); + } +} diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 2a57e671..2121a297 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -1,6 +1,7 @@ mod client_data; mod get_assertion; pub mod idl; +mod large_blob; mod make_credential; pub mod psl; mod timeout; @@ -23,6 +24,10 @@ pub use idl::{ JsonFormat, RegistrationResponseJSON, ResponseSerializationError, WebAuthnIDL, WebAuthnIDLResponse, }; +pub use large_blob::{ + AuthenticatorLargeBlobStorage, LargeBlobError, LargeBlobStorage, MemoryLargeBlobStorage, + LARGE_BLOB_DEFAULT_CHUNK, +}; pub use make_credential::{ CredentialPropsExtension, CredentialProtectionExtension, CredentialProtectionPolicy, MakeCredentialLargeBlobExtension, MakeCredentialLargeBlobExtensionOutput, From 00c188d56b856324e11c0e376bad25d1dea54f22 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 10 May 2026 21:12:21 +0100 Subject: [PATCH 3/4] feat(webauthn): perform largeBlob.read via authenticatorLargeBlobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the WebAuthn `largeBlob: { read: true }` extension is requested and the authenticator returns a per-credential `largeBlobKey`, libwebauthn now runs `authenticatorLargeBlobs(get)` to fetch the on-device serialized array, decrypts the matching entry, and exposes the plaintext via the WebAuthn response's `unsigned_extensions_output.large_blob.blob` field. The read flow uses a per-assertion `AuthenticatorLargeBlobStorage` handle (introduced in the previous commit), so each credential is read against its own `largeBlobKey`. Failures are non-fatal: per WebAuthn L3 §10.5 the `blob` output is optional on success. Combined with the earlier fix that removed the key-disclosure bug, this completes the read half of the WebAuthn `largeBlob` extension. Refs: WebAuthn L3 §10.5, CTAP 2.2 §6.10. --- libwebauthn/src/ops/webauthn/large_blob.rs | 134 +++++++++++++++++++++ libwebauthn/src/webauthn.rs | 57 ++++++++- 2 files changed, 190 insertions(+), 1 deletion(-) diff --git a/libwebauthn/src/ops/webauthn/large_blob.rs b/libwebauthn/src/ops/webauthn/large_blob.rs index cc12a204..6f7311af 100644 --- a/libwebauthn/src/ops/webauthn/large_blob.rs +++ b/libwebauthn/src/ops/webauthn/large_blob.rs @@ -685,4 +685,138 @@ mod tests { let got = storage.read(b"cred-A").await.expect("read"); assert!(got.is_none()); } + + /// End-to-end check of the read path through `webauthn_get_assertion`: + /// drives the CTAP exchange, array parsing, AES-256-GCM decrypt, deflate + /// decompress, and surfaces the plaintext as the WebAuthn JSON output. + #[tokio::test] + async fn webauthn_get_assertion_returns_decrypted_large_blob() { + use crate::ops::webauthn::{ + GetAssertionLargeBlobExtension, GetAssertionRequest, GetAssertionRequestExtensions, + UserVerificationRequirement, + }; + use crate::proto::ctap2::cbor::{to_vec, CborRequest, CborResponse, Value}; + use crate::proto::ctap2::{ + Ctap2CommandCode, Ctap2GetInfoResponse, Ctap2LargeBlobsResponse, + }; + use crate::transport::mock::channel::MockChannel; + use crate::webauthn::WebAuthn; + use std::collections::{BTreeMap, HashMap}; + + let large_blob_key = [0x77u8; 32]; + let nonce = [0x22u8; 12]; + let plaintext = b"webauthn end-to-end largeBlob".to_vec(); + let entry = encrypt_entry(&large_blob_key, &nonce, &plaintext).unwrap(); + let serialized_array = build_serialized_array(&[entry]); + + // Build the assertion-response CBOR by hand so we can populate field + // 0x07 (largeBlobKey) without adding a Serialize impl to the response + // model. + let credential_id = b"cred-id".to_vec(); + let mut auth_data = vec![0u8; 37]; + auth_data[32] = 0x01; // USER_PRESENT flag + let mut cred_id_map = BTreeMap::new(); + cred_id_map.insert(Value::Text("type".into()), Value::Text("public-key".into())); + cred_id_map.insert( + Value::Text("id".into()), + Value::Bytes(credential_id.clone()), + ); + let mut response_map = BTreeMap::new(); + response_map.insert(Value::Integer(1), Value::Map(cred_id_map)); + response_map.insert(Value::Integer(2), Value::Bytes(auth_data)); + response_map.insert(Value::Integer(3), Value::Bytes(vec![0u8; 32])); + response_map.insert(Value::Integer(7), Value::Bytes(large_blob_key.to_vec())); + let assertion_resp_cbor = to_vec(&Value::Map(response_map)).unwrap(); + + // GetInfo response advertising support for largeBlobs. + let mut info = Ctap2GetInfoResponse { + versions: vec!["FIDO_2_1".into()], + ..Default::default() + }; + let mut options = HashMap::new(); + options.insert("largeBlobs".into(), true); + info.options = Some(options); + let info_cbor = to_vec(&info).unwrap(); + + let mut channel = MockChannel::new(); + + // 1. _webauthn_get_assertion_fido2 calls ctap2_get_info(). + channel.push_command_pair( + CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo), + CborResponse::new_success_from_slice(&info_cbor), + ); + // 2. user_verification calls ctap2_get_info() again. + channel.push_command_pair( + CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo), + CborResponse::new_success_from_slice(&info_cbor), + ); + // 3. ctap2_get_assertion. The default From + // impl produces options { up: true, uv: false }, matching the + // Discouraged UV path; that's what we exercise here. + let req = crate::proto::ctap2::Ctap2GetAssertionRequest::from(GetAssertionRequest { + relying_party_id: "example.com".into(), + challenge: vec![0u8; 32], + origin: "example.com".into(), + cross_origin: None, + allow: vec![], + extensions: Some(GetAssertionRequestExtensions { + cred_blob: false, + prf: None, + large_blob: Some(GetAssertionLargeBlobExtension::Read), + }), + user_verification: UserVerificationRequirement::Discouraged, + timeout: Duration::from_secs(5), + }); + let assertion_req_cbor = crate::proto::ctap2::cbor::to_vec(&req).unwrap(); + channel.push_command_pair( + CborRequest { + command: Ctap2CommandCode::AuthenticatorGetAssertion, + encoded_data: assertion_req_cbor, + }, + CborResponse::new_success_from_slice(&assertion_resp_cbor), + ); + // 4. authenticatorLargeBlobs(get). + let blobs_req = Ctap2LargeBlobsRequest::new_get(0, LARGE_BLOB_DEFAULT_CHUNK); + let blobs_resp = Ctap2LargeBlobsResponse { + config: Some(serde_bytes::ByteBuf::from(serialized_array)), + }; + channel.push_command_pair( + CborRequest { + command: Ctap2CommandCode::AuthenticatorLargeBlobs, + encoded_data: crate::proto::ctap2::cbor::to_vec(&blobs_req).unwrap(), + }, + CborResponse::new_success_from_slice( + &crate::proto::ctap2::cbor::to_vec(&blobs_resp).unwrap(), + ), + ); + + let request = GetAssertionRequest { + relying_party_id: "example.com".into(), + challenge: vec![0u8; 32], + origin: "example.com".into(), + cross_origin: None, + allow: vec![], + extensions: Some(GetAssertionRequestExtensions { + cred_blob: false, + prf: None, + large_blob: Some(GetAssertionLargeBlobExtension::Read), + }), + user_verification: UserVerificationRequirement::Discouraged, + timeout: Duration::from_secs(5), + }; + + let response = channel + .webauthn_get_assertion(&request) + .await + .expect("webauthn_get_assertion should succeed"); + assert_eq!(response.assertions.len(), 1); + let large_blob = response.assertions[0] + .unsigned_extensions_output + .as_ref() + .expect("unsigned extensions present") + .large_blob + .as_ref() + .expect("largeBlob extension output present"); + assert_eq!(large_blob.blob.as_deref(), Some(plaintext.as_slice())); + } } diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index 21746af9..bd4dc09d 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -6,7 +6,11 @@ use tracing::{debug, error, info, instrument, trace, warn}; use crate::fido::FidoProtocol; use crate::ops::u2f::{RegisterRequest, SignRequest, UpgradableResponse}; -use crate::ops::webauthn::{DowngradableRequest, GetAssertionRequest, GetAssertionResponse}; +use crate::ops::webauthn::{ + AuthenticatorLargeBlobStorage, DowngradableRequest, GetAssertionLargeBlobExtension, + GetAssertionLargeBlobExtensionOutput, GetAssertionRequest, GetAssertionResponse, + GetAssertionResponseUnsignedExtensions, LargeBlobStorage, +}; use crate::ops::webauthn::{MakeCredentialRequest, MakeCredentialResponse}; use crate::proto::ctap1::Ctap1; use crate::proto::ctap2::preflight::ctap2_preflight; @@ -235,6 +239,57 @@ where let response = self.ctap2_get_next_assertion(op.timeout).await?; assertions.push(response.into_assertion_output(op, self.get_auth_data())); } + + // largeBlob.read: fetch and decrypt the on-device blob via + // authenticatorLargeBlobs(get). Failures are non-fatal; per WebAuthn + // L3 §10.1.5 the `blob` field is absent when the read cannot complete. + let large_blob_read_requested = op.extensions.as_ref().and_then(|e| e.large_blob.as_ref()) + == Some(&GetAssertionLargeBlobExtension::Read); + if large_blob_read_requested { + for assertion in assertions.iter_mut() { + let Some(key_vec) = assertion.large_blob_key.as_ref() else { + continue; + }; + let Ok(key) = <[u8; 32]>::try_from(key_vec.as_slice()) else { + warn!( + len = key_vec.len(), + "largeBlobKey has unexpected length (expected 32); skipping fetch" + ); + continue; + }; + let credential_id = assertion + .credential_id + .as_ref() + .map(|c| c.id.to_vec()) + .unwrap_or_default(); + let storage = AuthenticatorLargeBlobStorage::new( + self, + credential_id.clone(), + key, + op.timeout, + ); + let blob = match storage.read(&credential_id).await { + Ok(b) => b, + Err(e) => { + warn!(?e, "authenticatorLargeBlobs(get) failed; no blob returned"); + None + } + }; + let entry = GetAssertionLargeBlobExtensionOutput { blob }; + match assertion.unsigned_extensions_output.as_mut() { + Some(unsigned) => unsigned.large_blob = Some(entry), + None => { + assertion.unsigned_extensions_output = + Some(GetAssertionResponseUnsignedExtensions { + hmac_get_secret: None, + large_blob: Some(entry), + prf: None, + }); + } + } + } + } + Ok(assertions.as_slice().into()) } From 33be1667d8675fe8a85590b8e78ab3277fff4c52 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:41:40 +0100 Subject: [PATCH 4/4] fixup: adapt to post-rebase API changes from #188 and #198 - GetAssertionRequest.cross_origin renamed to top_origin (#188) - Assertion no longer carries large_blob_key (#198); thread it through from Ctap2GetAssertionResponse instead --- libwebauthn/src/ops/webauthn/large_blob.rs | 4 ++-- libwebauthn/src/webauthn.rs | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/large_blob.rs b/libwebauthn/src/ops/webauthn/large_blob.rs index 6f7311af..a157caee 100644 --- a/libwebauthn/src/ops/webauthn/large_blob.rs +++ b/libwebauthn/src/ops/webauthn/large_blob.rs @@ -757,7 +757,7 @@ mod tests { relying_party_id: "example.com".into(), challenge: vec![0u8; 32], origin: "example.com".into(), - cross_origin: None, + top_origin: None, allow: vec![], extensions: Some(GetAssertionRequestExtensions { cred_blob: false, @@ -794,7 +794,7 @@ mod tests { relying_party_id: "example.com".into(), challenge: vec![0u8; 32], origin: "example.com".into(), - cross_origin: None, + top_origin: None, allow: vec![], extensions: Some(GetAssertionRequestExtensions { cred_blob: false, diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index bd4dc09d..0cbcd3f1 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -2,6 +2,7 @@ pub mod error; pub mod pin_uv_auth_token; use async_trait::async_trait; +use serde_bytes::ByteBuf; use tracing::{debug, error, info, instrument, trace, warn}; use crate::fido::FidoProtocol; @@ -232,11 +233,16 @@ where ) }?; let count = response.credentials_count.unwrap_or(1); + // Keep the per-response largeBlobKey alongside the assertion so we can + // run authenticatorLargeBlobs(get) below without leaking the key into + // the WebAuthn-level `Assertion` struct (see PR #198). + let mut large_blob_keys: Vec> = vec![response.large_blob_key.clone()]; let mut assertions = vec![response.into_assertion_output(op, self.get_auth_data())]; for i in 1..count { debug!({ i }, "Fetching additional credential"); // GetNextAssertion doesn't use PinUVAuthToken, so we don't need to check uv_auth_used here let response = self.ctap2_get_next_assertion(op.timeout).await?; + large_blob_keys.push(response.large_blob_key.clone()); assertions.push(response.into_assertion_output(op, self.get_auth_data())); } @@ -246,8 +252,8 @@ where let large_blob_read_requested = op.extensions.as_ref().and_then(|e| e.large_blob.as_ref()) == Some(&GetAssertionLargeBlobExtension::Read); if large_blob_read_requested { - for assertion in assertions.iter_mut() { - let Some(key_vec) = assertion.large_blob_key.as_ref() else { + for (assertion, key_opt) in assertions.iter_mut().zip(large_blob_keys.iter()) { + let Some(key_vec) = key_opt.as_ref() else { continue; }; let Ok(key) = <[u8; 32]>::try_from(key_vec.as_slice()) else {