From cca121a91ea3ac13fcb0620d1f3452b13323f7a7 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 10 May 2026 21:11:10 +0100 Subject: [PATCH 1/3] 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 50e3a98a..402283df 100644 --- a/libwebauthn/src/proto/ctap2/protocol.rs +++ b/libwebauthn/src/proto/ctap2/protocol.rs @@ -15,8 +15,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); @@ -90,6 +90,11 @@ pub trait Ctap2 { request: &Ctap2CredentialManagementRequest, timeout: Duration, ) -> Result; + async fn ctap2_large_blobs( + &mut self, + request: &Ctap2LargeBlobsRequest, + timeout: Duration, + ) -> Result; } #[async_trait] @@ -284,6 +289,31 @@ 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()) + } + } } #[cfg(test)] From faa46c48b5d36a643b5e5ef00ea41cc1aa19b8da Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 19 May 2026 19:38:19 +0100 Subject: [PATCH 2/3] feat(webauthn): perform largeBlob.read via authenticatorLargeBlobs Adds the read-path helper for the WebAuthn L3 largeBlob extension. After get_assertion returns a largeBlobKey, the platform paginates authenticatorLargeBlobs(get), AES-256-GCM-authenticates each entry under the per-credential key, and RFC 1951 raw-deflate decompresses the plaintext into unsigned_extensions_output.large_blob.blob. Read failures surface as blob absent, per WebAuthn L3 sec 10.1.5. The chunk size honours maxFragmentLength = maxMsgSize - 64 from GetInfo. origSize is capped at 1 MiB to bound platform allocation. Per-entry structural problems are skipped, not propagated, since the on-device array is shared across credentials. --- Cargo.lock | 49 ++ libwebauthn/Cargo.toml | 2 + libwebauthn/src/ops/webauthn/large_blob.rs | 715 +++++++++++++++++++++ libwebauthn/src/ops/webauthn/mod.rs | 2 + libwebauthn/src/webauthn.rs | 58 +- 5 files changed, 822 insertions(+), 4 deletions(-) create mode 100644 libwebauthn/src/ops/webauthn/large_blob.rs diff --git a/Cargo.lock b/Cargo.lock index 45f855e5..22f9ac90 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" @@ -598,6 +604,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" @@ -647,6 +662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1102,6 +1118,7 @@ dependencies = [ "serde-indexed 0.1.1", "serde_bytes", "sha2 0.10.9", + "trussed-chunked", "trussed-core", "trussed-fs-info", "trussed-hkdf", @@ -1124,6 +1141,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" @@ -1800,6 +1827,7 @@ name = "libwebauthn" version = "0.5.0" dependencies = [ "aes", + "aes-gcm", "apdu", "apdu-core", "async-trait", @@ -1813,6 +1841,7 @@ dependencies = [ "ctap-types", "curve25519-dalek", "dbus", + "flate2", "futures", "heapless", "hex", @@ -1861,18 +1890,22 @@ dependencies = [ name = "libwebauthn-tests" version = "0.0.0" dependencies = [ + "aes-gcm", "base64-url", "cosey", "ctaphid", "ctaphid-dispatch", "delog", "fido-authenticator", + "flate2", "interchange", "libwebauthn", "littlefs2", "num_enum", "rand 0.8.6", "serde_bytes", + "serde_cbor_2", + "sha2 0.10.9", "tempfile", "test-log", "tokio", @@ -1989,6 +2022,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" @@ -3075,6 +3118,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 66c1b100..6915290d 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -70,6 +70,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..884e9022 --- /dev/null +++ b/libwebauthn/src/ops/webauthn/large_blob.rs @@ -0,0 +1,715 @@ +//! WebAuthn `largeBlob` read path (CTAP 2.1 §6.10). Write is deferred. + +use std::io::Read; +use std::time::Duration; + +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; +use flate2::read::DeflateDecoder; +use sha2::{Digest, Sha256}; +use tracing::{debug, trace, warn}; + +use crate::proto::ctap2::{Ctap2, Ctap2LargeBlobsRequest}; +use crate::webauthn::Error; + +/// Spec default for `maxFragmentLength` when `maxMsgSize` is absent (CTAP 2.1 §6.10.2). +pub(crate) const LARGE_BLOB_DEFAULT_FRAGMENT: u32 = 960; + +/// Cap on `origSize` per entry. CTAP 2.1 §6.10.3 RECOMMENDs at least 1 MiB. +const LARGE_BLOB_MAX_ORIG_SIZE: u64 = 1024 * 1024; + +/// Static cap on the total serialized array size, to bound a misbehaving device. +const LARGE_BLOB_MAX_ARRAY_BYTES: usize = 4 * 1024 * 1024; + +const LARGE_BLOB_HASH_LEN: usize = 16; +const LARGE_BLOB_NONCE_LEN: usize = 12; +const LARGE_BLOB_AD_PREFIX: &[u8] = b"blob"; + +#[derive(thiserror::Error, Debug)] +pub(crate) enum LargeBlobError { + #[error("On-device largeBlobArray is malformed: {0}")] + Corrupted(String), + #[error(transparent)] + Webauthn(#[from] Error), +} + +/// `maxFragmentLength` per CTAP 2.1 §6.10.2 (`maxMsgSize - 64`, default 960). +pub(crate) fn max_fragment_length(max_msg_size: Option) -> u32 { + max_msg_size + .and_then(|m| m.checked_sub(64)) + .unwrap_or(LARGE_BLOB_DEFAULT_FRAGMENT) + .max(LARGE_BLOB_DEFAULT_FRAGMENT) +} + +/// Fetch and decrypt the largeBlob for one credential. +pub(crate) async fn read_authenticator_large_blob( + channel: &mut C, + large_blob_key: &[u8; 32], + max_fragment: u32, + timeout: Duration, +) -> Result>, LargeBlobError> { + let serialized = fetch_serialized_array(channel, max_fragment, timeout).await?; + let array_bytes = strip_array_trailer(&serialized)?; + let entries = parse_large_blob_array(array_bytes)?; + for entry in &entries { + if let Some(plaintext) = entry.try_decrypt(large_blob_key)? { + return Ok(Some(plaintext)); + } + } + Ok(None) +} + +async fn fetch_serialized_array( + channel: &mut C, + max_fragment: u32, + timeout: Duration, +) -> Result, LargeBlobError> { + let mut out: Vec = Vec::new(); + let mut offset: u32 = 0; + loop { + let req = Ctap2LargeBlobsRequest::new_get(offset, max_fragment); + let resp = channel + .ctap2_large_blobs(&req, 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 < max_fragment as usize { + debug!(total = out.len(), "largeBlobArray fully fetched"); + break; + } + if out.len() > LARGE_BLOB_MAX_ARRAY_BYTES { + warn!( + total = out.len(), + "largeBlobArray exceeded {LARGE_BLOB_MAX_ARRAY_BYTES}, aborting" + ); + return Err(LargeBlobError::Corrupted( + "serialized array exceeds platform cap".into(), + )); + } + offset = offset + .checked_add(chunk_len as u32) + .ok_or_else(|| LargeBlobError::Corrupted("offset overflow".into()))?; + } + 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(None)` on AEAD failure (skip to next entry), `Err` only on structural problems. + fn try_decrypt(&self, key: &[u8; 32]) -> Result>, LargeBlobError> { + if self.nonce.len() != LARGE_BLOB_NONCE_LEN { + return Ok(None); + } + if self.orig_size > LARGE_BLOB_MAX_ORIG_SIZE { + warn!( + orig_size = self.orig_size, + cap = LARGE_BLOB_MAX_ORIG_SIZE, + "largeBlob entry origSize exceeds platform cap; skipping" + ); + return Ok(None); + } + + 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 cap = self.orig_size as usize; + let mut decompressed = Vec::with_capacity(cap); + DeflateDecoder::new(plaintext_compressed.as_slice()) + .take(self.orig_size + 1) + .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) +} + +/// Parse entries, skipping any with per-entry structural errors (CTAP 2.1 §6.10.3). +fn parse_large_blob_array(bytes: &[u8]) -> Result, LargeBlobError> { + if 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 crate::proto::ctap2::cbor::Value::Map(map) = value else { + trace!("largeBlobArray entry is not a CBOR map; skipping"); + continue; + }; + + let mut ciphertext = None; + let mut nonce = None; + let mut orig_size: Option = None; + for (k, v) in map { + let crate::proto::ctap2::cbor::Value::Integer(key) = k else { + 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 { + orig_size = Some(i as u64); + } + } + } + _ => {} + } + } + match (ciphertext, nonce, orig_size) { + (Some(ciphertext), Some(nonce), Some(orig_size)) => entries.push(LargeBlobMapEntry { + ciphertext, + nonce, + orig_size, + }), + _ => trace!("largeBlobArray entry missing one of 0x01/0x02/0x03; skipping"), + } + } + Ok(entries) +} + +/// Test helper: encrypt+compress one 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 serialized largeBlobArray (entries + 16-byte trailer). +#[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::*; + + #[test] + fn max_fragment_uses_get_info_when_available() { + assert_eq!(max_fragment_length(Some(2048)), 2048 - 64); + } + + #[test] + fn max_fragment_falls_back_to_spec_default() { + assert_eq!(max_fragment_length(None), LARGE_BLOB_DEFAULT_FRAGMENT); + } + + #[test] + fn max_fragment_does_not_underflow() { + assert_eq!(max_fragment_length(Some(32)), LARGE_BLOB_DEFAULT_FRAGMENT); + } + + #[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"[..])); + } + + /// Per CTAP 2.1 §6.10.3, a malformed entry MUST be skipped, not aborted. + /// Construct an array containing one bad entry (non-map) plus one good + /// entry; verify we still find the good one. + #[test] + fn malformed_entry_is_skipped_not_errored() { + use serde_cbor_2::value::Value as CborVal; + + let key = [0xCAu8; 32]; + let nonce = [0x33u8; 12]; + let good = encrypt_entry(&key, &nonce, b"survivor").unwrap(); + + let bad_entry_bytes = { + let mut buf = Vec::new(); + let mut ser = serde_cbor_2::ser::Serializer::new(&mut buf); + serde::Serialize::serialize(&CborVal::Text("not-a-map".into()), &mut ser).unwrap(); + buf + }; + let serialized = build_serialized_array(&[bad_entry_bytes, good]); + let array_bytes = strip_array_trailer(&serialized).unwrap(); + let parsed = parse_large_blob_array(array_bytes).expect("parse must not error"); + assert_eq!(parsed.len(), 1, "bad entry skipped, good entry kept"); + let pt = parsed[0].try_decrypt(&key).unwrap().unwrap(); + assert_eq!(pt, b"survivor"); + } + + /// Entry missing the ciphertext field is skipped without erroring. + #[test] + fn entry_missing_required_field_is_skipped() { + use serde_cbor_2::value::Value as CborVal; + use std::collections::BTreeMap; + + let key = [0xCBu8; 32]; + let nonce = [0x44u8; 12]; + let good = encrypt_entry(&key, &nonce, b"present").unwrap(); + + let incomplete = { + let mut map = BTreeMap::new(); + map.insert(CborVal::Integer(2), CborVal::Bytes(vec![0u8; 12])); + map.insert(CborVal::Integer(3), CborVal::Integer(5)); + let mut buf = Vec::new(); + let mut ser = serde_cbor_2::ser::Serializer::new(&mut buf); + serde::Serialize::serialize(&CborVal::Map(map), &mut ser).unwrap(); + buf + }; + let serialized = build_serialized_array(&[incomplete, good]); + let array_bytes = strip_array_trailer(&serialized).unwrap(); + let parsed = parse_large_blob_array(array_bytes).expect("parse must not error"); + assert_eq!(parsed.len(), 1); + let pt = parsed[0].try_decrypt(&key).unwrap().unwrap(); + assert_eq!(pt, b"present"); + } + + /// An entry advertising an oversized origSize must not OOM the platform. + /// `try_decrypt` returns `Ok(None)` rather than allocating. + #[test] + fn oversized_orig_size_is_skipped_without_allocating() { + let entry = LargeBlobMapEntry { + ciphertext: vec![0u8; 16], + nonce: vec![0u8; 12], + orig_size: LARGE_BLOB_MAX_ORIG_SIZE + 1, + }; + let key = [0u8; 32]; + let res = entry.try_decrypt(&key).expect("must not error"); + assert!(res.is_none()); + } + + #[tokio::test] + async fn read_authenticator_large_blob_via_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_FRAGMENT as usize, + "test fixture should fit in one chunk" + ); + + let req = Ctap2LargeBlobsRequest::new_get(0, LARGE_BLOB_DEFAULT_FRAGMENT); + 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 got = read_authenticator_large_blob( + &mut channel, + &key, + LARGE_BLOB_DEFAULT_FRAGMENT, + Duration::from_secs(5), + ) + .await + .expect("read should succeed"); + assert_eq!(got.as_deref(), Some(plaintext.as_slice())); + } + + #[tokio::test] + async fn read_authenticator_large_blob_empty_array_returns_none() { + 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_FRAGMENT); + 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 got = read_authenticator_large_blob( + &mut channel, + &[0xAA; 32], + LARGE_BLOB_DEFAULT_FRAGMENT, + Duration::from_secs(5), + ) + .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]); + + 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(); + + 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. 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. Discouraged UV path → up=true, uv=false. + let req = crate::proto::ctap2::Ctap2GetAssertionRequest::from(GetAssertionRequest { + relying_party_id: "example.com".into(), + challenge: vec![0u8; 32], + origin: "example.com".into(), + top_origin: None, + allow: vec![], + extensions: Some(GetAssertionRequestExtensions { + appid: None, + 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). Info omits max_msg_size, so we + // expect the spec default fragment. + let blobs_req = Ctap2LargeBlobsRequest::new_get(0, LARGE_BLOB_DEFAULT_FRAGMENT); + 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(), + top_origin: None, + allow: vec![], + extensions: Some(GetAssertionRequestExtensions { + appid: None, + 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/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 767c6c0a..e8f79d8f 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,7 @@ pub use idl::{ JsonFormat, RegistrationResponseJSON, ResponseSerializationError, WebAuthnIDL, WebAuthnIDLResponse, }; +pub(crate) use large_blob::{max_fragment_length, read_authenticator_large_blob}; pub use make_credential::{ CredentialPropsExtension, CredentialProtectionExtension, CredentialProtectionPolicy, MakeCredentialLargeBlobExtension, MakeCredentialLargeBlobExtensionInput, diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index ca537a06..6171729c 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -7,7 +7,9 @@ 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, UserVerificationRequirement, + max_fragment_length, read_authenticator_large_blob, DowngradableRequest, + GetAssertionLargeBlobExtension, GetAssertionLargeBlobExtensionOutput, GetAssertionRequest, + GetAssertionResponse, GetAssertionResponseUnsignedExtensions, UserVerificationRequirement, }; use crate::ops::webauthn::{MakeCredentialRequest, MakeCredentialResponse}; use crate::proto::ctap1::Ctap1; @@ -281,13 +283,61 @@ async fn get_assertion_fido2( ) }?; let count = response.credentials_count.unwrap_or(1); - let mut assertions = vec![response.into_assertion_output(op, channel.get_auth_data())]; + let mut ctap_responses = vec![response]; 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 = channel.ctap2_get_next_assertion(op.timeout).await?; - assertions.push(response.into_assertion_output(op, channel.get_auth_data())); + ctap_responses.push(channel.ctap2_get_next_assertion(op.timeout).await?); } + + // 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. + // Resolved here, before the CTAP response is converted to Assertion, so + // the per-credential largeBlobKey never leaves this scope. + let large_blob_read_requested = op.extensions.as_ref().and_then(|e| e.large_blob.as_ref()) + == Some(&GetAssertionLargeBlobExtension::Read); + let mut blobs: Vec>> = vec![None; ctap_responses.len()]; + if large_blob_read_requested { + let max_fragment = max_fragment_length(get_info_response.max_msg_size); + for (i, resp) in ctap_responses.iter().enumerate() { + let Some(key_buf) = resp.large_blob_key.as_ref() else { + continue; + }; + let Ok(key) = <[u8; 32]>::try_from(key_buf.as_slice()) else { + warn!( + len = key_buf.len(), + "largeBlobKey has unexpected length (expected 32); skipping fetch" + ); + continue; + }; + match read_authenticator_large_blob(channel, &key, max_fragment, op.timeout).await { + Ok(blob) => blobs[i] = blob, + Err(e) => warn!(?e, "authenticatorLargeBlobs(get) failed; no blob returned"), + } + } + } + + let mut assertions: Vec<_> = ctap_responses + .into_iter() + .map(|r| r.into_assertion_output(op, channel.get_auth_data())) + .collect(); + if large_blob_read_requested { + for (assertion, blob) in assertions.iter_mut().zip(blobs) { + 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 { + large_blob: Some(entry), + ..Default::default() + }); + } + } + } + } + Ok(assertions.as_slice().into()) } From 3e876203ba86db6b58922ab440542716153d0907 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 19 May 2026 19:38:48 +0100 Subject: [PATCH 3/3] test(libwebauthn-tests): integration test for largeBlob read Enables largeBlobs storage on the virt fido-authenticator and adds an end-to-end test: registers a credential, plants an encrypted entry directly via authenticatorLargeBlobs(set), then asserts webauthn_get_assertion with largeBlob.read surfaces the plaintext. Requires the chunked feature on fido-authenticator to lift the 1024-byte single-message cap on the device-wide array, and a non-zero max_msg_size in the virt config so the per-chunk length check passes. --- libwebauthn-tests/Cargo.toml | 6 +- libwebauthn-tests/src/virt/device.rs | 7 +- libwebauthn-tests/tests/large_blob.rs | 225 ++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 libwebauthn-tests/tests/large_blob.rs diff --git a/libwebauthn-tests/Cargo.toml b/libwebauthn-tests/Cargo.toml index e61300f9..e5a6cbb9 100644 --- a/libwebauthn-tests/Cargo.toml +++ b/libwebauthn-tests/Cargo.toml @@ -12,7 +12,7 @@ cosey = "0.3.2" ctaphid = { version = "0.3.1", default-features = false } ctaphid-dispatch = "0.3" delog = { version = "0.1", features = ["std-log"] } -fido-authenticator = { git = "https://github.com/Nitrokey/fido-authenticator.git", tag = "v0.1.1-nitrokey.27", features = ["dispatch", "log-all"] } +fido-authenticator = { git = "https://github.com/Nitrokey/fido-authenticator.git", tag = "v0.1.1-nitrokey.27", features = ["chunked", "dispatch", "log-all"] } interchange = "0.3.0" littlefs2 = "0.6.0" num_enum = "0.7.1" @@ -22,8 +22,12 @@ trussed = { version = "0.1", features = ["virt"] } trussed-staging = { version = "0.3.0", features = ["chunked", "hkdf", "virt", "fs-info"] } [dev-dependencies] +aes-gcm = "0.10" base64-url = "3.0.0" +flate2 = "1.0" serde_bytes = "0.11.5" +serde_cbor_2 = "0.13" +sha2 = "0.10" tempfile = "3.21" test-log = "0.2" tokio = { version = "1.45", features = ["full"] } diff --git a/libwebauthn-tests/src/virt/device.rs b/libwebauthn-tests/src/virt/device.rs index 710488bc..c4214e1d 100644 --- a/libwebauthn-tests/src/virt/device.rs +++ b/libwebauthn-tests/src/virt/device.rs @@ -66,10 +66,13 @@ where client, Conforming {}, Config { - max_msg_size: 0, + max_msg_size: 1200, skip_up_timeout: None, max_resident_credential_count: options.max_resident_credential_count, - large_blobs: None, + large_blobs: Some(fido_authenticator::LargeBlobsConfig { + location: trussed::types::Location::Internal, + max_size: 4096, + }), nfc_transport: false, }, ); diff --git a/libwebauthn-tests/tests/large_blob.rs b/libwebauthn-tests/tests/large_blob.rs new file mode 100644 index 00000000..f44f1d51 --- /dev/null +++ b/libwebauthn-tests/tests/large_blob.rs @@ -0,0 +1,225 @@ +//! End-to-end test of WebAuthn largeBlob.read against the virt authenticator. + +use std::time::Duration; + +use libwebauthn::ops::webauthn::{ + GetAssertionLargeBlobExtension, GetAssertionRequest, GetAssertionRequestExtensions, + MakeCredentialLargeBlobExtension, MakeCredentialLargeBlobExtensionInput, MakeCredentialRequest, + MakeCredentialsRequestExtensions, ResidentKeyRequirement, UserVerificationRequirement, +}; +use libwebauthn::proto::ctap2::{ + Ctap2, Ctap2CredentialType, Ctap2GetAssertionRequest, Ctap2LargeBlobsRequest, + Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity, + Ctap2PublicKeyCredentialUserEntity, +}; +use libwebauthn::transport::{Channel, Device}; +use libwebauthn::webauthn::WebAuthn; +use libwebauthn::UvUpdate; +use libwebauthn_tests::virt::get_virtual_device; +use rand::{thread_rng, Rng}; +use test_log::test; +use tokio::sync::broadcast::Receiver; + +const TIMEOUT: Duration = Duration::from_secs(10); +const RP: &str = "example.org"; + +async fn handle_updates(mut state_recv: Receiver) { + // MakeCredential update + assert_eq!(state_recv.recv().await, Ok(UvUpdate::PresenceRequired)); + // GetAssertion update + assert_eq!(state_recv.recv().await, Ok(UvUpdate::PresenceRequired)); +} + +#[test(tokio::test)] +async fn test_webauthn_large_blob_read_returns_planted_blob() { + let mut device = get_virtual_device(); + let mut channel = device.channel().await.unwrap(); + + let user_id: [u8; 32] = thread_rng().gen(); + let challenge: [u8; 32] = thread_rng().gen(); + + let make = MakeCredentialRequest { + origin: RP.into(), + challenge: challenge.to_vec(), + relying_party: Ctap2PublicKeyCredentialRpEntity::new(RP, RP), + user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "alice", "Alice"), + resident_key: Some(ResidentKeyRequirement::Required), + user_verification: UserVerificationRequirement::Discouraged, + algorithms: vec![Ctap2CredentialType::default()], + exclude: None, + extensions: Some(MakeCredentialsRequestExtensions { + large_blob: Some(MakeCredentialLargeBlobExtensionInput { + support: MakeCredentialLargeBlobExtension::Required, + }), + ..Default::default() + }), + timeout: TIMEOUT, + top_origin: None, + }; + + let state_recv = channel.get_ux_update_receiver(); + let update_handle = tokio::spawn(handle_updates(state_recv)); + + let response = channel + .webauthn_make_credential(&make) + .await + .expect("MakeCredential should succeed"); + assert_eq!( + response + .unsigned_extensions_output + .large_blob + .as_ref() + .and_then(|lb| lb.supported), + Some(true), + "device must report largeBlob.supported=true" + ); + let credential: Ctap2PublicKeyCredentialDescriptor = + (&response.authenticator_data).try_into().unwrap(); + + let key = capture_large_blob_key(&mut channel, &credential, &challenge).await; + let plaintext = b"hello, planted largeBlob".to_vec(); + let nonce: [u8; 12] = thread_rng().gen(); + let serialized = encode_serialized_array(&[encode_entry(&key, &nonce, &plaintext)]); + plant_large_blob_array(&mut channel, serialized).await; + + let ga = GetAssertionRequest { + relying_party_id: RP.into(), + origin: RP.into(), + challenge: challenge.to_vec(), + allow: vec![credential], + user_verification: UserVerificationRequirement::Discouraged, + extensions: Some(GetAssertionRequestExtensions { + appid: None, + cred_blob: false, + prf: None, + large_blob: Some(GetAssertionLargeBlobExtension::Read), + }), + timeout: TIMEOUT, + top_origin: None, + }; + let ga_response = channel + .webauthn_get_assertion(&ga) + .await + .expect("GetAssertion should succeed"); + let blob = ga_response.assertions[0] + .unsigned_extensions_output + .as_ref() + .and_then(|u| u.large_blob.as_ref()) + .and_then(|lb| lb.blob.as_ref()) + .expect("largeBlob.blob populated"); + assert_eq!(blob.as_slice(), plaintext.as_slice()); + + update_handle.await.unwrap(); +} + +/// Capture the per-credential AES key via a direct CTAP GetAssertion. +async fn capture_large_blob_key( + channel: &mut libwebauthn::transport::hid::channel::HidChannel<'_>, + credential: &Ctap2PublicKeyCredentialDescriptor, + challenge: &[u8; 32], +) -> [u8; 32] { + let ga_for_key = GetAssertionRequest { + relying_party_id: RP.into(), + origin: RP.into(), + challenge: challenge.to_vec(), + allow: vec![credential.clone()], + user_verification: UserVerificationRequirement::Discouraged, + extensions: Some(GetAssertionRequestExtensions { + appid: None, + cred_blob: false, + prf: None, + large_blob: Some(GetAssertionLargeBlobExtension::Read), + }), + timeout: TIMEOUT, + top_origin: None, + }; + let ctap_req: Ctap2GetAssertionRequest = ga_for_key.into(); + let ctap_resp = channel + .ctap2_get_assertion(&ctap_req, TIMEOUT) + .await + .expect("CTAP get_assertion succeeds"); + let key_buf = ctap_resp + .large_blob_key + .expect("device returns largeBlobKey when extension requested"); + key_buf + .as_slice() + .try_into() + .expect("largeBlobKey is 32 bytes") +} + +/// Plant a serialized largeBlobArray via direct CTAP set (no-PIN path). +async fn plant_large_blob_array( + channel: &mut libwebauthn::transport::hid::channel::HidChannel<'_>, + serialized: Vec, +) { + let length = serialized.len() as u32; + let req = Ctap2LargeBlobsRequest { + get: None, + set: Some(serde_bytes::ByteBuf::from(serialized)), + offset: 0, + length: Some(length), + pin_uv_auth_param: None, + pin_uv_auth_protocol: None, + }; + channel + .ctap2_large_blobs(&req, TIMEOUT) + .await + .expect("authenticatorLargeBlobs(set) succeeds without PIN"); +} + +/// Encode one largeBlobMap entry per CTAP 2.1 §6.10.3. +fn encode_entry(key: &[u8; 32], nonce: &[u8; 12], plaintext: &[u8]) -> Vec { + use aes_gcm::aead::Aead; + use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; + use flate2::write::DeflateEncoder; + use flate2::Compression; + use serde_cbor_2::value::Value as CborVal; + use std::collections::BTreeMap; + use std::io::Write; + + let mut compressed = Vec::new(); + { + let mut enc = DeflateEncoder::new(&mut compressed, Compression::default()); + enc.write_all(plaintext).unwrap(); + enc.finish().unwrap(); + } + let mut ad = b"blob".to_vec(); + ad.extend_from_slice(&(plaintext.len() as u64).to_le_bytes()); + let cipher = Aes256Gcm::new(Key::::from_slice(key)); + let ciphertext = cipher + .encrypt( + Nonce::from_slice(nonce), + aes_gcm::aead::Payload { + msg: &compressed, + aad: &ad, + }, + ) + .unwrap(); + + 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(); + serde_cbor_2::to_writer(&mut buf, &CborVal::Map(map)).unwrap(); + buf +} + +/// Wrap entries in a CBOR array + 16-byte left-SHA-256 trailer (CTAP 2.1 §6.10.3). +fn encode_serialized_array(entries: &[Vec]) -> Vec { + use sha2::{Digest, Sha256}; + assert!( + entries.len() <= 23, + "test fixture uses short-form CBOR array" + ); + let mut out = vec![0x80 | entries.len() as u8]; + for e in entries { + out.extend_from_slice(e); + } + let h = Sha256::digest(&out); + out.extend_from_slice(&h[..16]); + out +}