From fa2aa83de0b3370823afc22d1eda890e94ea6c50 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 19 May 2026 21:39:27 +0100 Subject: [PATCH 1/4] feat(ctap2): authenticatorLargeBlobs(set) chunked write constructors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new_set_first and new_set_continuation per CTAP 2.2 §6.10.2: length is present only when offset is zero, omitted otherwise. Both take Option<(auth_param, protocol)> so unprotected authenticators can issue the command without pinUvAuthParam. --- .../src/proto/ctap2/model/large_blobs.rs | 110 +++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/libwebauthn/src/proto/ctap2/model/large_blobs.rs b/libwebauthn/src/proto/ctap2/model/large_blobs.rs index 41bb254e..ec8d121e 100644 --- a/libwebauthn/src/proto/ctap2/model/large_blobs.rs +++ b/libwebauthn/src/proto/ctap2/model/large_blobs.rs @@ -1,4 +1,4 @@ -//! CTAP 2.1 `authenticatorLargeBlobs` command (`0x0C`). Wire-level model only; +//! CTAP 2.2 `authenticatorLargeBlobs` command (`0x0C`). Wire-level model only; //! see [`crate::ops::webauthn::large_blob`] for the high-level read pipeline. use serde_bytes::ByteBuf; @@ -42,6 +42,48 @@ impl Ctap2LargeBlobsRequest { pin_uv_auth_protocol: None, } } + + /// First chunk of a chunked write. CTAP 2.2 §6.10.2 requires `length` only when `offset == 0`. + /// Pass `None` for `pin_uv_auth` on unprotected authenticators (no clientPin, no built-in UV). + pub fn new_set_first( + chunk: Vec, + total_length: u32, + pin_uv_auth: Option<(Vec, u32)>, + ) -> Self { + let (pin_uv_auth_param, pin_uv_auth_protocol) = match pin_uv_auth { + Some((p, v)) => (Some(ByteBuf::from(p)), Some(v)), + None => (None, None), + }; + Self { + get: None, + set: Some(ByteBuf::from(chunk)), + offset: 0, + length: Some(total_length), + pin_uv_auth_param, + pin_uv_auth_protocol, + } + } + + /// Continuation chunk. CTAP 2.2 §6.10.2 forbids `length` when `offset != 0`. + /// Pass `None` for `pin_uv_auth` on unprotected authenticators. + pub fn new_set_continuation( + chunk: Vec, + offset: u32, + pin_uv_auth: Option<(Vec, u32)>, + ) -> Self { + let (pin_uv_auth_param, pin_uv_auth_protocol) = match pin_uv_auth { + Some((p, v)) => (Some(ByteBuf::from(p)), Some(v)), + None => (None, None), + }; + Self { + get: None, + set: Some(ByteBuf::from(chunk)), + offset, + length: None, + pin_uv_auth_param, + pin_uv_auth_protocol, + } + } } #[cfg_attr(test, derive(SerializeIndexed))] @@ -68,4 +110,70 @@ mod tests { }; assert_eq!(map.len(), 2); } + + #[test] + fn set_first_encodes_length_and_offset_zero() { + let req = + Ctap2LargeBlobsRequest::new_set_first(vec![0x01, 0x02], 17, Some((vec![0xAA; 16], 2))); + let bytes = cbor::to_vec(&req).expect("serialize"); + let value: cbor::Value = cbor::from_slice(&bytes).expect("deserialize"); + let cbor::Value::Map(map) = value else { + panic!("expected map"); + }; + let pairs: std::collections::BTreeMap<_, _> = map + .into_iter() + .filter_map(|(k, v)| match k { + cbor::Value::Integer(i) => Some((i, v)), + _ => None, + }) + .collect(); + assert!(matches!(pairs.get(&0x02), Some(cbor::Value::Bytes(_)))); + assert_eq!(pairs.get(&0x03), Some(&cbor::Value::Integer(0))); + assert_eq!(pairs.get(&0x04), Some(&cbor::Value::Integer(17))); + assert!(matches!(pairs.get(&0x05), Some(cbor::Value::Bytes(_)))); + assert_eq!(pairs.get(&0x06), Some(&cbor::Value::Integer(2))); + assert!(!pairs.contains_key(&0x01), "get must not be present"); + } + + #[test] + fn set_continuation_omits_length() { + let req = + Ctap2LargeBlobsRequest::new_set_continuation(vec![0xFF], 64, Some((vec![0xBB; 16], 2))); + let bytes = cbor::to_vec(&req).expect("serialize"); + let value: cbor::Value = cbor::from_slice(&bytes).expect("deserialize"); + let cbor::Value::Map(map) = value else { + panic!("expected map"); + }; + let pairs: std::collections::BTreeMap<_, _> = map + .into_iter() + .filter_map(|(k, v)| match k { + cbor::Value::Integer(i) => Some((i, v)), + _ => None, + }) + .collect(); + assert_eq!(pairs.get(&0x03), Some(&cbor::Value::Integer(64))); + assert!(!pairs.contains_key(&0x04), "length must be absent"); + assert!(matches!(pairs.get(&0x02), Some(cbor::Value::Bytes(_)))); + } + + #[test] + fn set_first_unauthenticated_omits_auth_params() { + // CTAP 2.2 §6.10.2: unprotected authenticators skip the pinUvAuth verification block, + // so the platform omits both pinUvAuthParam and pinUvAuthProtocol. + let req = Ctap2LargeBlobsRequest::new_set_first(vec![0x01, 0x02], 17, None); + let bytes = cbor::to_vec(&req).expect("serialize"); + let value: cbor::Value = cbor::from_slice(&bytes).expect("deserialize"); + let cbor::Value::Map(map) = value else { + panic!("expected map"); + }; + let pairs: std::collections::BTreeMap<_, _> = map + .into_iter() + .filter_map(|(k, v)| match k { + cbor::Value::Integer(i) => Some((i, v)), + _ => None, + }) + .collect(); + assert!(!pairs.contains_key(&0x05)); + assert!(!pairs.contains_key(&0x06)); + } } From 637ca93a640bf64d2de2d38102c1b634845ec7d7 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 19 May 2026 21:39:43 +0100 Subject: [PATCH 2/4] feat(webauthn): largeBlob write and delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements WebAuthn L3 §10.1.5 write and a libwebauthn-side delete on top of CTAP 2.2 §6.10.6 update-or-erase. The platform fetches the existing large-blob array, drops any entry that decrypts under the credential's largeBlobKey, optionally appends a fresh encrypted entry, and re-uploads the canonical CBOR array with a fresh SHA-256 trailer. Foreign entries (different key, malformed, unknown fields) are preserved byte-for-byte per the §6.10.2 platform contract. Upload is chunked per maxFragmentLength with pinUvAuthParam computed as authenticate(token, 32*0xff || 0x0c 0x00 || u32_le(offset) || SHA-256(set)) per §6.10.2. The Ctap2GetAssertionRequest now ORs LARGE_BLOB_WRITE into its permissions when write or delete is requested so user_verification negotiates a token covering the lbw permission. To keep PIN-protected devices working under UV=Discouraged, the Ctap2UserVerifiableRequest trait gains needs_pin_uv_auth_token, which suppresses the OnlyForSharedSecret downgrade in user_verification. Unprotected authenticators continue to accept the write without auth params per spec line 137. Delete with no matching entry returns LargeBlobError::NoMatch (written=false), matching the strict §6.10.6 'Return an error' branch at line 303. --- libwebauthn/src/ops/webauthn/get_assertion.rs | 30 +- libwebauthn/src/ops/webauthn/idl/get.rs | 1 + libwebauthn/src/ops/webauthn/large_blob.rs | 539 +++++++++++++++++- libwebauthn/src/ops/webauthn/mod.rs | 3 +- libwebauthn/src/proto/ctap2/model.rs | 6 + .../src/proto/ctap2/model/get_assertion.rs | 49 +- libwebauthn/src/webauthn.rs | 236 ++++++-- libwebauthn/src/webauthn/pin_uv_auth_token.rs | 12 +- 8 files changed, 795 insertions(+), 81 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index d5649eb4..41b07150 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -336,9 +336,12 @@ impl TryFrom for HMACGetSecretInput { #[derive(Debug, Clone, PartialEq, Eq)] pub enum GetAssertionLargeBlobExtension { + /// Per WebAuthn L3 §10.1.5 (read=true): fetch the credential's blob. Read, - // Not yet supported - // Write(Vec), + /// Per WebAuthn L3 §10.1.5 (write=ArrayBuffer): store this blob against the credential. + Write(Vec), + /// CTAP 2.2 §6.10.6 erase branch. Not exposed via WebAuthn L3 JSON IDL. + Delete, } impl TryFrom for GetAssertionLargeBlobExtension { @@ -350,13 +353,19 @@ impl TryFrom for GetAssertionLargeBlobExtension { "largeBlob.support is only valid at registration".to_string(), )); } + // WebAuthn L3 §10.1.5: read and write are mutually exclusive. + if value.read == Some(true) && value.write.is_some() { + return Err(GetAssertionPrepareError::NotSupported( + "largeBlob.read and largeBlob.write are mutually exclusive".to_string(), + )); + } + if let Some(write) = value.write { + return Ok(GetAssertionLargeBlobExtension::Write(write.to_vec())); + } match value.read { Some(true) => Ok(GetAssertionLargeBlobExtension::Read), - Some(false) => Err(GetAssertionPrepareError::NotSupported( - "largeBlob writes not supported".to_string(), - )), - None => Err(GetAssertionPrepareError::NotSupported( - "largeBlob read not requested".to_string(), + _ => Err(GetAssertionPrepareError::NotSupported( + "largeBlob input must set read=true or write".to_string(), )), } } @@ -366,9 +375,8 @@ impl TryFrom for GetAssertionLargeBlobExtension { pub struct GetAssertionLargeBlobExtensionOutput { #[serde(skip_serializing_if = "Option::is_none")] pub blob: Option>, - // Not yet supported - // #[serde(skip_serializing_if = "Option::is_none")] - // pub written: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub written: Option, } #[derive(Debug, Default, Clone, PartialEq)] @@ -543,7 +551,7 @@ impl Assertion { .blob .as_ref() .map(|b| Base64UrlString::from(b.as_slice())), - written: None, // Write not yet supported + written: large_blob.written, }); } diff --git a/libwebauthn/src/ops/webauthn/idl/get.rs b/libwebauthn/src/ops/webauthn/idl/get.rs index af1ec556..2e61977b 100644 --- a/libwebauthn/src/ops/webauthn/idl/get.rs +++ b/libwebauthn/src/ops/webauthn/idl/get.rs @@ -63,6 +63,7 @@ pub struct GetAssertionRequestExtensionsJSON { pub struct LargeBlobInputJson { pub support: Option, pub read: Option, + pub write: Option, } #[derive(Debug, Clone, Deserialize)] diff --git a/libwebauthn/src/ops/webauthn/large_blob.rs b/libwebauthn/src/ops/webauthn/large_blob.rs index 487c9879..f79f74cd 100644 --- a/libwebauthn/src/ops/webauthn/large_blob.rs +++ b/libwebauthn/src/ops/webauthn/large_blob.rs @@ -1,4 +1,4 @@ -//! WebAuthn `largeBlob` read path (CTAP 2.1 §6.10). Write is deferred. +//! WebAuthn `largeBlob` read/write/delete pipeline. Wire-level spec: CTAP 2.2 §6.10. use std::io::Read; use std::time::Duration; @@ -9,13 +9,15 @@ use flate2::read::DeflateDecoder; use sha2::{Digest, Sha256}; use tracing::{debug, trace, warn}; -use crate::proto::ctap2::{Ctap2, Ctap2LargeBlobsRequest}; +use crate::pin::PinUvAuthProtocol; +use crate::proto::ctap2::cbor::Value; +use crate::proto::ctap2::{Ctap2, Ctap2LargeBlobsRequest, Ctap2PinUvAuthProtocol}; use crate::webauthn::Error; -/// Spec default for `maxFragmentLength` when `maxMsgSize` is absent (CTAP 2.1 §6.10.2). +/// Spec default for `maxFragmentLength` when `maxMsgSize` is absent (CTAP 2.2 §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. +/// Cap on `origSize` per entry. CTAP 2.2 §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. @@ -29,11 +31,14 @@ const LARGE_BLOB_AD_PREFIX: &[u8] = b"blob"; pub(crate) enum LargeBlobError { #[error("On-device largeBlobArray is malformed: {0}")] Corrupted(String), + /// CTAP 2.2 §6.10.6 line 303 "Return an error": delete called but no entry decrypted under our key. + #[error("largeBlobArray has no entry to delete for this credential")] + NoMatch, #[error(transparent)] Webauthn(#[from] Error), } -/// `maxFragmentLength` per CTAP 2.1 §6.10.2 (`maxMsgSize - 64`, default 960). Floored at 1. +/// `maxFragmentLength` per CTAP 2.2 §6.10.2 (`maxMsgSize - 64`, default 960). Floored at 1. pub(crate) fn max_fragment_length(max_msg_size: Option) -> u32 { match max_msg_size { Some(m) => m.saturating_sub(64).max(1), @@ -213,7 +218,7 @@ fn strip_array_trailer(serialized: &[u8]) -> Result<&[u8], LargeBlobError> { Ok(array) } -/// Parse entries, skipping any with per-entry structural errors (CTAP 2.1 §6.10.3). +/// Parse entries, skipping any with per-entry structural errors (CTAP 2.2 §6.10.3). fn parse_large_blob_array(bytes: &[u8]) -> Result, LargeBlobError> { if bytes.is_empty() { return Ok(Vec::new()); @@ -280,8 +285,7 @@ fn parse_large_blob_array(bytes: &[u8]) -> Result, LargeB Ok(entries) } -/// Test helper: encrypt+compress one entry under `key`. -#[cfg(test)] +/// Encrypt+compress one entry under `key`, returning the canonical CBOR map per CTAP 2.2 §6.10.3. pub(crate) fn encrypt_entry( key: &[u8; 32], nonce: &[u8], @@ -342,7 +346,8 @@ pub(crate) fn encrypt_entry( Ok(buf) } -/// Test helper: assemble a serialized largeBlobArray (entries + 16-byte trailer). +/// Assemble a serialized largeBlobArray (entries + 16-byte trailer). CTAP 2.2 §6.10.2 trailer = `LEFT(SHA-256(array_bytes), 16)`. +/// Production write uses `rebuild_serialized_array`; this helper is retained for the read/decrypt unit tests. #[cfg(test)] pub(crate) fn build_serialized_array(entries: &[Vec]) -> Vec { let mut out = Vec::new(); @@ -366,6 +371,238 @@ pub(crate) fn build_serialized_array(entries: &[Vec]) -> Vec { out } +/// `pinUvAuthParam` for an `authenticatorLargeBlobs(set)` chunk. CTAP 2.2 §6.10.2: +/// `authenticate(token, 32×0xff || h'0c00' || uint32LittleEndian(offset) || SHA-256(set))`. +pub(crate) fn large_blob_pin_uv_auth_param( + token: &[u8], + proto: &dyn PinUvAuthProtocol, + offset: u32, + chunk: &[u8], +) -> Result, Error> { + let mut buf = Vec::with_capacity(32 + 2 + 4 + 32); + buf.extend_from_slice(&[0xff; 32]); + buf.extend_from_slice(&[0x0c, 0x00]); + buf.extend_from_slice(&offset.to_le_bytes()); + buf.extend_from_slice(&Sha256::digest(chunk)); + proto.authenticate(token, &buf) +} + +/// Top-level CBOR array of large-blob maps as raw `Value`s (preserves unknown fields). +fn parse_large_blob_array_values(bytes: &[u8]) -> Result, LargeBlobError> { + if bytes.is_empty() { + return Ok(Vec::new()); + } + let value: Value = crate::proto::ctap2::cbor::from_slice(bytes) + .map_err(|e| LargeBlobError::Corrupted(format!("array parse: {e}")))?; + match value { + Value::Array(a) => Ok(a), + other => Err(LargeBlobError::Corrupted(format!( + "expected CBOR array, got {other:?}" + ))), + } +} + +/// AEAD-verify an entry under `key`. Used to identify the credential's own entry during RMW. +fn entry_decrypts_under_key(entry: &Value, key: &[u8; 32]) -> bool { + let Value::Map(map) = entry else { + return false; + }; + let mut ciphertext: Option<&[u8]> = None; + let mut nonce: Option<&[u8]> = None; + let mut orig_size: Option = None; + for (k, v) in map.iter() { + let Value::Integer(ki) = k else { continue }; + match *ki { + LARGE_BLOB_ENTRY_CIPHERTEXT => { + if let Value::Bytes(b) = v { + ciphertext = Some(b.as_slice()); + } + } + LARGE_BLOB_ENTRY_NONCE => { + if let Value::Bytes(b) = v { + nonce = Some(b.as_slice()); + } + } + LARGE_BLOB_ENTRY_ORIG_SIZE => { + if let Value::Integer(i) = v { + if *i >= 0 { + orig_size = Some(*i as u64); + } + } + } + _ => {} + } + } + let (Some(ct), Some(n), Some(os)) = (ciphertext, nonce, orig_size) else { + return false; + }; + if n.len() != LARGE_BLOB_NONCE_LEN { + return false; + } + let mut ad = Vec::with_capacity(LARGE_BLOB_AD_PREFIX.len() + 8); + ad.extend_from_slice(LARGE_BLOB_AD_PREFIX); + ad.extend_from_slice(&os.to_le_bytes()); + let cipher = Aes256Gcm::new(Key::::from_slice(key)); + let nonce_obj = Nonce::from_slice(n); + cipher + .decrypt(nonce_obj, aes_gcm::aead::Payload { msg: ct, aad: &ad }) + .is_ok() +} + +/// Drop entries that AEAD-verify under `drop_key`, optionally append `new_entry`, re-serialize with trailer. +/// Foreign entries (different key, malformed, unknown fields) are preserved verbatim per CTAP 2.2 §6.10.2. +fn rebuild_serialized_array( + existing: &[Value], + drop_key: &[u8; 32], + new_entry: Option, +) -> Result, LargeBlobError> { + let mut kept: Vec = Vec::with_capacity(existing.len() + 1); + for entry in existing { + if entry_decrypts_under_key(entry, drop_key) { + trace!("largeBlob RMW: dropping entry owned by this credential"); + continue; + } + kept.push(entry.clone()); + } + if let Some(v) = new_entry { + kept.push(v); + } + let array_value = Value::Array(kept); + let mut bytes = crate::proto::ctap2::cbor::to_vec(&array_value) + .map_err(|e| LargeBlobError::Corrupted(format!("array serialize: {e}")))?; + let hash = Sha256::digest(&bytes); + bytes.extend_from_slice(&hash[..LARGE_BLOB_HASH_LEN]); + Ok(bytes) +} + +/// Fetch + parse the existing array. On trailer/parse failure, return empty per CTAP 2.2 §6.10.2. +async fn fetch_or_initial( + channel: &mut C, + max_fragment: u32, + timeout: Duration, +) -> Result, LargeBlobError> { + let serialized = fetch_serialized_array(channel, max_fragment, timeout).await?; + match strip_array_trailer(&serialized) { + Ok(array_bytes) => parse_large_blob_array_values(array_bytes), + Err(_) => { + warn!("largeBlobArray trailer mismatch; treating as initial empty array (CTAP 2.2 §6.10.2)"); + Ok(Vec::new()) + } + } +} + +/// Drive the chunked write protocol (CTAP 2.2 §6.10.2) for one serialized array. +/// `pin_uv_auth` is `None` for unprotected authenticators (spec line 100, conditional auth block). +async fn upload_serialized_array( + channel: &mut C, + serialized: &[u8], + max_fragment: u32, + pin_uv_auth: Option<(&[u8], Ctap2PinUvAuthProtocol)>, + timeout: Duration, +) -> Result<(), LargeBlobError> { + let total: u32 = serialized + .len() + .try_into() + .map_err(|_| LargeBlobError::Corrupted("serialized array exceeds u32::MAX".into()))?; + if (total as usize) < 17 { + // CTAP 2.2 §6.10.2 requires length >= 17 (empty array sentinel). + return Err(LargeBlobError::Corrupted(format!( + "serialized array length {total} below 17-byte minimum" + ))); + } + if (total as usize) > LARGE_BLOB_MAX_ARRAY_BYTES { + return Err(LargeBlobError::Corrupted(format!( + "serialized array {total} exceeds platform cap {LARGE_BLOB_MAX_ARRAY_BYTES}" + ))); + } + let proto = pin_uv_auth + .as_ref() + .map(|(_, v)| v.create_protocol_object()); + let chunk_cap = max_fragment as usize; + let mut offset: u32 = 0; + while (offset as usize) < serialized.len() { + let end = (offset as usize + chunk_cap).min(serialized.len()); + let chunk = &serialized[offset as usize..end]; + let chunk_auth = match (&pin_uv_auth, &proto) { + (Some((token, version)), Some(proto)) => { + let param = large_blob_pin_uv_auth_param(token, proto.as_ref(), offset, chunk) + .map_err(LargeBlobError::Webauthn)?; + Some((param, *version as u32)) + } + _ => None, + }; + let req = if offset == 0 { + Ctap2LargeBlobsRequest::new_set_first(chunk.to_vec(), total, chunk_auth) + } else { + Ctap2LargeBlobsRequest::new_set_continuation(chunk.to_vec(), offset, chunk_auth) + }; + trace!( + offset, + chunk_len = chunk.len(), + total, + "authenticatorLargeBlobs(set) chunk" + ); + channel + .ctap2_large_blobs(&req, timeout) + .await + .map_err(LargeBlobError::Webauthn)?; + offset = offset + .checked_add(chunk.len() as u32) + .ok_or_else(|| LargeBlobError::Corrupted("offset overflow".into()))?; + } + debug!(total, "largeBlobArray fully written"); + Ok(()) +} + +/// Store `blob` against the credential identified by `large_blob_key`, replacing any prior entry. +/// Implements WebAuthn L3 §10.1.5 write atop the §6.10.6 update-or-append loop. +/// `pin_uv_auth` is `None` on unprotected authenticators (CTAP 2.2 §6.10.2). +pub(crate) async fn write_authenticator_large_blob( + channel: &mut C, + large_blob_key: &[u8; 32], + blob: &[u8], + max_fragment: u32, + pin_uv_auth: Option<(&[u8], Ctap2PinUvAuthProtocol)>, + timeout: Duration, +) -> Result<(), LargeBlobError> { + if (blob.len() as u64) > LARGE_BLOB_MAX_ORIG_SIZE { + return Err(LargeBlobError::Corrupted(format!( + "blob length {} exceeds platform cap {LARGE_BLOB_MAX_ORIG_SIZE}", + blob.len() + ))); + } + let existing = fetch_or_initial(channel, max_fragment, timeout).await?; + let mut nonce = [0u8; LARGE_BLOB_NONCE_LEN]; + use rand::RngCore; + rand::thread_rng().fill_bytes(&mut nonce); + let entry_bytes = encrypt_entry(large_blob_key, &nonce, blob)?; + let new_entry: Value = crate::proto::ctap2::cbor::from_slice(&entry_bytes) + .map_err(|e| LargeBlobError::Corrupted(format!("entry parse: {e}")))?; + let serialized = rebuild_serialized_array(&existing, large_blob_key, Some(new_entry))?; + upload_serialized_array(channel, &serialized, max_fragment, pin_uv_auth, timeout).await +} + +/// Erase the credential's entry (CTAP 2.2 §6.10.6 "Erase the current array element"). +/// No-op if no entry matches. +pub(crate) async fn delete_authenticator_large_blob( + channel: &mut C, + large_blob_key: &[u8; 32], + max_fragment: u32, + pin_uv_auth: Option<(&[u8], Ctap2PinUvAuthProtocol)>, + timeout: Duration, +) -> Result<(), LargeBlobError> { + let existing = fetch_or_initial(channel, max_fragment, timeout).await?; + let any_owned = existing + .iter() + .any(|e| entry_decrypts_under_key(e, large_blob_key)); + if !any_owned { + // Strict CTAP 2.2 §6.10.6 reading: no matching entry => error path (line 303). + return Err(LargeBlobError::NoMatch); + } + let serialized = rebuild_serialized_array(&existing, large_blob_key, None)?; + upload_serialized_array(channel, &serialized, max_fragment, pin_uv_auth, timeout).await +} + #[cfg(test)] mod tests { use super::*; @@ -468,7 +705,7 @@ mod tests { assert_eq!(found_b.as_deref(), Some(&b"bravo"[..])); } - /// Per CTAP 2.1 §6.10.3, a malformed entry MUST be skipped, not aborted. + /// Per CTAP 2.2 §6.10.4, 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] @@ -1115,5 +1352,287 @@ mod tests { compressed[0], 0x78, "raw DEFLATE must not begin with a zlib CMF byte" ); + /// Spot-check the CTAP 2.2 §6.10.2 auth-param construction byte-for-byte: + /// the message MUST be `32×0xff || 0x0c, 0x00 || u32_le(offset) || SHA-256(chunk)`. + #[test] + fn large_blob_pin_uv_auth_param_matches_spec_message() { + use crate::pin::PinUvAuthProtocolTwo; + use hmac::Mac; + + let token = [0x11u8; 32]; + let chunk = b"some chunk bytes"; + let offset: u32 = 0x12345678; + + let proto = PinUvAuthProtocolTwo::new(); + let got = large_blob_pin_uv_auth_param(&token, &proto, offset, chunk).expect("auth_param"); + + let mut expected_msg = Vec::new(); + expected_msg.extend_from_slice(&[0xff; 32]); + expected_msg.extend_from_slice(&[0x0c, 0x00]); + expected_msg.extend_from_slice(&offset.to_le_bytes()); + expected_msg.extend_from_slice(&Sha256::digest(chunk)); + let mut mac = as hmac::Mac>::new_from_slice(&token).unwrap(); + mac.update(&expected_msg); + let expected = mac.finalize().into_bytes(); + + assert_eq!(got, expected.as_slice()); + } + + #[test] + fn entry_decrypts_under_key_matches_owned_entry() { + let key = [0x42u8; 32]; + let nonce = [0x07u8; 12]; + let entry_bytes = encrypt_entry(&key, &nonce, b"owned blob").unwrap(); + let entry: Value = crate::proto::ctap2::cbor::from_slice(&entry_bytes).unwrap(); + assert!(entry_decrypts_under_key(&entry, &key)); + } + + #[test] + fn entry_decrypts_under_key_rejects_foreign_entry() { + let owner = [0xa1u8; 32]; + let other = [0xb2u8; 32]; + let nonce = [0x33u8; 12]; + let entry_bytes = encrypt_entry(&owner, &nonce, b"someone else's blob").unwrap(); + let entry: Value = crate::proto::ctap2::cbor::from_slice(&entry_bytes).unwrap(); + assert!(!entry_decrypts_under_key(&entry, &other)); + } + + #[test] + fn entry_decrypts_under_key_rejects_non_map() { + let v = Value::Text("not a map".into()); + assert!(!entry_decrypts_under_key(&v, &[0u8; 32])); + } + + #[test] + fn rebuild_appends_and_drops_only_owned() { + let owner_a = [0xa1u8; 32]; + let owner_b = [0xb2u8; 32]; + let nonce = [0x55u8; 12]; + let entry_a = encrypt_entry(&owner_a, &nonce, b"alpha").unwrap(); + let entry_b = encrypt_entry(&owner_b, &nonce, b"bravo").unwrap(); + let entry_a_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_a).unwrap(); + let entry_b_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_b).unwrap(); + + let new_entry_bytes = encrypt_entry(&owner_a, &[0x99u8; 12], b"alpha v2").unwrap(); + let new_entry: Value = crate::proto::ctap2::cbor::from_slice(&new_entry_bytes).unwrap(); + + let rebuilt = + rebuild_serialized_array(&[entry_a_v, entry_b_v.clone()], &owner_a, Some(new_entry)) + .unwrap(); + + let array_bytes = strip_array_trailer(&rebuilt).unwrap(); + let parsed = parse_large_blob_array_values(array_bytes).unwrap(); + assert_eq!( + parsed.len(), + 2, + "owner_b entry kept + new owner_a entry appended" + ); + assert!(entry_decrypts_under_key(&parsed[0], &owner_b)); + assert!(entry_decrypts_under_key(&parsed[1], &owner_a)); + } + + #[test] + fn rebuild_delete_drops_only_owned() { + let owner_a = [0xa1u8; 32]; + let owner_b = [0xb2u8; 32]; + let nonce = [0x55u8; 12]; + let entry_a = encrypt_entry(&owner_a, &nonce, b"alpha").unwrap(); + let entry_b = encrypt_entry(&owner_b, &nonce, b"bravo").unwrap(); + let entry_a_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_a).unwrap(); + let entry_b_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_b).unwrap(); + + let rebuilt = rebuild_serialized_array(&[entry_a_v, entry_b_v], &owner_a, None).unwrap(); + let array_bytes = strip_array_trailer(&rebuilt).unwrap(); + let parsed = parse_large_blob_array_values(array_bytes).unwrap(); + assert_eq!(parsed.len(), 1); + assert!(entry_decrypts_under_key(&parsed[0], &owner_b)); + } + + /// Delete with no matching entry is a no-op: array returns unchanged (+ valid trailer). + #[test] + fn rebuild_delete_no_match_is_noop() { + let owner_a = [0xa1u8; 32]; + let owner_b = [0xb2u8; 32]; + let nonce = [0x55u8; 12]; + let entry_b = encrypt_entry(&owner_b, &nonce, b"bravo").unwrap(); + let entry_b_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_b).unwrap(); + let rebuilt = rebuild_serialized_array(&[entry_b_v], &owner_a, None).unwrap(); + let array_bytes = strip_array_trailer(&rebuilt).unwrap(); + let parsed = parse_large_blob_array_values(array_bytes).unwrap(); + assert_eq!(parsed.len(), 1); + assert!(entry_decrypts_under_key(&parsed[0], &owner_b)); + } + + /// Foreign entries with unknown CBOR fields must round-trip unmodified through RMW. + #[test] + fn rebuild_preserves_unknown_fields_in_foreign_entries() { + use std::collections::BTreeMap; + let owner_a = [0xa1u8; 32]; + let owner_b = [0xb2u8; 32]; + + let entry_a_bytes = encrypt_entry(&owner_a, &[0x55u8; 12], b"alpha").unwrap(); + let entry_a_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_a_bytes).unwrap(); + + // Construct a "future fields" entry encrypted under owner_b with an extra key 0x07. + let entry_b_base = encrypt_entry(&owner_b, &[0x66u8; 12], b"bravo").unwrap(); + let entry_b_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_b_base).unwrap(); + let Value::Map(mut map_b) = entry_b_v else { + panic!("entry_b is a map"); + }; + map_b.insert(Value::Integer(0x07), Value::Text("future field".into())); + let entry_b_v = Value::Map(map_b); + // Re-extract the unknown-field marker for later inspection. + let original_b_clone = entry_b_v.clone(); + + let new_entry_bytes = encrypt_entry(&owner_a, &[0x99u8; 12], b"alpha v2").unwrap(); + let new_entry: Value = crate::proto::ctap2::cbor::from_slice(&new_entry_bytes).unwrap(); + + let rebuilt = + rebuild_serialized_array(&[entry_a_v, entry_b_v], &owner_a, Some(new_entry)).unwrap(); + let array_bytes = strip_array_trailer(&rebuilt).unwrap(); + let parsed = parse_large_blob_array_values(array_bytes).unwrap(); + assert_eq!(parsed.len(), 2); + let kept_b = &parsed[0]; + assert_eq!( + kept_b, &original_b_clone, + "foreign entry preserved verbatim" + ); + let Value::Map(map_b) = kept_b else { + panic!("kept_b is a map"); + }; + let _ = BTreeMap::<&Value, &Value>::from_iter(map_b.iter()); + assert_eq!( + map_b.get(&Value::Integer(0x07)), + Some(&Value::Text("future field".into())), + "unknown field 0x07 preserved" + ); + } + + #[test] + fn rebuild_meets_minimum_17_bytes_when_empty() { + // CTAP 2.2 §6.10.2: serialized array length MUST be >= 17. + let rebuilt = rebuild_serialized_array(&[], &[0u8; 32], None).unwrap(); + assert!(rebuilt.len() >= 17); + // Empty array: 0x80 (1 byte) + 16-byte trailer = 17 bytes. + assert_eq!(rebuilt.len(), 17); + assert_eq!(rebuilt[0], 0x80); + } + + /// CTAP 2.2 §6.10 spec text: "The initial serialized large-blob array ... is the byte string + /// `h'8076be8b528d0075f7aae98d6fa57a6d3c'`". Asserting byte-for-byte locks our canonical CBOR + /// emission against future serializer drift. + #[test] + fn rebuild_empty_array_matches_spec_initial_bytes() { + let rebuilt = rebuild_serialized_array(&[], &[0u8; 32], None).unwrap(); + assert_eq!(hex::encode(&rebuilt), "8076be8b528d0075f7aae98d6fa57a6d3c"); + } + + /// `upload_serialized_array` issues set_first with the precise pinUvAuthParam derived per CTAP 2.2 §6.10.2. + #[tokio::test] + async fn upload_single_chunk_uses_set_first_with_correct_auth_param() { + use crate::pin::{PinUvAuthProtocol, PinUvAuthProtocolTwo}; + use crate::proto::ctap2::cbor::{CborRequest, CborResponse}; + use crate::proto::ctap2::{Ctap2CommandCode, Ctap2LargeBlobsResponse}; + use crate::transport::mock::channel::MockChannel; + + let key = [0xC0u8; 32]; + let token = [0x11u8; 32]; + let proto = PinUvAuthProtocolTwo::new(); + let plaintext = b"round-trip blob".to_vec(); + + let nonce = [0x07u8; 12]; + let entry_bytes = encrypt_entry(&key, &nonce, &plaintext).unwrap(); + let entry_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_bytes).unwrap(); + let serialized = rebuild_serialized_array(&[], &key, Some(entry_v)).unwrap(); + assert!( + serialized.len() <= LARGE_BLOB_DEFAULT_FRAGMENT as usize, + "test fixture must fit in one chunk" + ); + + let auth_param = + large_blob_pin_uv_auth_param(&token, &proto, 0, &serialized).expect("auth_param"); + let set_req = Ctap2LargeBlobsRequest::new_set_first( + serialized.clone(), + serialized.len() as u32, + Some((auth_param, proto.version() as u32)), + ); + let mut channel = MockChannel::new(); + channel.push_command_pair( + CborRequest { + command: Ctap2CommandCode::AuthenticatorLargeBlobs, + encoded_data: crate::proto::ctap2::cbor::to_vec(&set_req).unwrap(), + }, + CborResponse::new_success_from_slice( + &crate::proto::ctap2::cbor::to_vec(&Ctap2LargeBlobsResponse { config: None }) + .unwrap(), + ), + ); + + upload_serialized_array( + &mut channel, + &serialized, + LARGE_BLOB_DEFAULT_FRAGMENT, + Some((&token, Ctap2PinUvAuthProtocol::Two)), + Duration::from_secs(5), + ) + .await + .expect("upload"); + } + + #[tokio::test] + async fn upload_chunks_when_array_exceeds_max_fragment() { + use crate::pin::{PinUvAuthProtocol, PinUvAuthProtocolTwo}; + use crate::proto::ctap2::cbor::{CborRequest, CborResponse}; + use crate::proto::ctap2::{Ctap2CommandCode, Ctap2LargeBlobsResponse}; + use crate::transport::mock::channel::MockChannel; + + let token = [0x22u8; 32]; + let proto = PinUvAuthProtocolTwo::new(); + // Small max_fragment to force chunking with a small payload. + const MF: u32 = 32; + // Build a synthetic 70-byte "serialized array" (the helpers only check length >= 17). + let serialized: Vec = (0u8..70).collect(); + assert_eq!(serialized.len(), 70); + + let mut channel = MockChannel::new(); + // Chunks: 0..32, 32..64, 64..70. Three calls. + for (offset, chunk_len) in [(0u32, 32), (32u32, 32), (64u32, 6)] { + let chunk = serialized[offset as usize..(offset as usize + chunk_len)].to_vec(); + let auth_param = + large_blob_pin_uv_auth_param(&token, &proto, offset, &chunk).expect("auth_param"); + let req = if offset == 0 { + Ctap2LargeBlobsRequest::new_set_first( + chunk, + 70, + Some((auth_param, proto.version() as u32)), + ) + } else { + Ctap2LargeBlobsRequest::new_set_continuation( + chunk, + offset, + Some((auth_param, proto.version() as u32)), + ) + }; + channel.push_command_pair( + CborRequest { + command: Ctap2CommandCode::AuthenticatorLargeBlobs, + encoded_data: crate::proto::ctap2::cbor::to_vec(&req).unwrap(), + }, + CborResponse::new_success_from_slice( + &crate::proto::ctap2::cbor::to_vec(&Ctap2LargeBlobsResponse { config: None }) + .unwrap(), + ), + ); + } + + upload_serialized_array( + &mut channel, + &serialized, + MF, + Some((&token, Ctap2PinUvAuthProtocol::Two)), + Duration::from_secs(5), + ) + .await + .expect("chunked upload"); } } diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 6ccb94db..f2616a31 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -26,7 +26,8 @@ pub use idl::{ ResponseSerializationError, WebAuthnIDLResponse, }; pub(crate) use large_blob::{ - decrypt_first_matching, fetch_large_blob_entries, max_fragment_length, + decrypt_first_matching, delete_authenticator_large_blob, fetch_large_blob_entries, + max_fragment_length, write_authenticator_large_blob, }; pub use make_credential::{ CredentialPropsExtension, CredentialProtectionExtension, CredentialProtectionPolicy, diff --git a/libwebauthn/src/proto/ctap2/model.rs b/libwebauthn/src/proto/ctap2/model.rs index 87880fdc..71ddb541 100644 --- a/libwebauthn/src/proto/ctap2/model.rs +++ b/libwebauthn/src/proto/ctap2/model.rs @@ -333,6 +333,12 @@ pub trait Ctap2UserVerifiableRequest { fn persistent_token_rejected(&self) -> bool { false } + /// True if the request requires a full pinUvAuthToken (not just a shared secret). Drives + /// `user_verification` to skip the `OnlyForSharedSecret` downgrade on UV=Discouraged. + /// Default false: HMAC/PRF-style requests are satisfied by shared-secret-only. + fn needs_pin_uv_auth_token(&self, _info: &Ctap2GetInfoResponse) -> bool { + false + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index cba6e6dc..1ab04b37 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -200,19 +200,32 @@ pub struct Ctap2GetAssertionRequestExtensions { pub large_blob_key: Option, #[serde(skip)] pub(crate) hmac_or_prf: Option, + /// Set when the WebAuthn `largeBlob` extension is `Write`/`Delete`. Drives the + /// `lbw` bit in `permissions()` so the negotiated pinUvAuthToken can authorize + /// `authenticatorLargeBlobs(set)` (CTAP 2.2 §6.10.2/§6.5.5.7.1). + #[serde(skip)] + pub(crate) large_blob_write: bool, } impl From for Ctap2GetAssertionRequestExtensions { fn from(other: GetAssertionRequestExtensions) -> Self { + let needs_key = matches!( + other.large_blob, + Some(GetAssertionLargeBlobExtension::Read) + | Some(GetAssertionLargeBlobExtension::Write(_)) + | Some(GetAssertionLargeBlobExtension::Delete) + ); + let is_write = matches!( + other.large_blob, + Some(GetAssertionLargeBlobExtension::Write(_)) + | Some(GetAssertionLargeBlobExtension::Delete) + ); Ctap2GetAssertionRequestExtensions { cred_blob: other.cred_blob, hmac_secret: None, // Gets calculated later hmac_or_prf: other.prf.map(GetAssertionHmacOrPrfInput::Prf), - large_blob_key: if other.large_blob == Some(GetAssertionLargeBlobExtension::Read) { - Some(true) - } else { - None - }, + large_blob_key: if needs_key { Some(true) } else { None }, + large_blob_write: is_write, } } } @@ -400,7 +413,14 @@ impl Ctap2UserVerifiableRequest for Ctap2GetAssertionRequest { } fn permissions(&self) -> Ctap2AuthTokenPermissionRole { - Ctap2AuthTokenPermissionRole::GET_ASSERTION + let mut perms = Ctap2AuthTokenPermissionRole::GET_ASSERTION; + if self.extensions.as_ref().is_some_and(|e| e.large_blob_write) { + // CTAP 2.2 §6.10.2 requires the lbw bit on the token used for + // authenticatorLargeBlobs(set). Negotiating it alongside ga avoids a + // second PIN/UV ceremony for the chunked upload that follows. + perms |= Ctap2AuthTokenPermissionRole::LARGE_BLOB_WRITE; + } + perms } fn permissions_rpid(&self) -> Option<&str> { @@ -426,7 +446,17 @@ impl Ctap2UserVerifiableRequest for Ctap2GetAssertionRequest { .as_ref() .map(|e| e.hmac_or_prf.is_some()) .unwrap_or_default(); - hmac_requested && hmac_supported + (hmac_requested && hmac_supported) || self.needs_pin_uv_auth_token(get_info_response) + } + + fn needs_pin_uv_auth_token(&self, info: &Ctap2GetInfoResponse) -> bool { + // largeBlob.write/delete needs a full token with the `lbw` permission + // (CTAP 2.2 §6.10.2 line 113). Only require it when the device is + // UV-protected; an unprotected authenticator accepts writes without + // auth per spec line 137. + self.extensions.as_ref().is_some_and(|e| e.large_blob_write) + && info.option_enabled("largeBlobs") + && info.is_uv_protected() } } @@ -516,7 +546,10 @@ impl Ctap2GetAssertionResponseExtensions { .extensions .as_ref() .and_then(|ext| ext.large_blob.as_ref()) - .map(|_| GetAssertionLargeBlobExtensionOutput { blob: None }); + .map(|_| GetAssertionLargeBlobExtensionOutput { + blob: None, + written: None, + }); // FIDO AppID extension: on the FIDO2 path the application parameter // is always derived from rp.id, so if the caller requested `appid` diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index c0d5da9a..55af71ef 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -22,19 +22,21 @@ use tracing::{debug, error, info, instrument, trace, warn}; use crate::fido::FidoProtocol; use crate::ops::u2f::{RegisterRequest, SignRequest, UpgradableResponse}; use crate::ops::webauthn::{ - decrypt_first_matching, fetch_large_blob_entries, max_fragment_length, DowngradableRequest, - GetAssertionLargeBlobExtension, GetAssertionLargeBlobExtensionOutput, GetAssertionRequest, - GetAssertionResponse, GetAssertionResponseUnsignedExtensions, UserVerificationRequirement, + decrypt_first_matching, delete_authenticator_large_blob, fetch_large_blob_entries, + max_fragment_length, write_authenticator_large_blob, DowngradableRequest, + GetAssertionLargeBlobExtension, + GetAssertionLargeBlobExtensionOutput, GetAssertionRequest, GetAssertionResponse, + GetAssertionResponseUnsignedExtensions, UserVerificationRequirement, }; use crate::ops::webauthn::{MakeCredentialRequest, MakeCredentialResponse}; use crate::proto::ctap1::Ctap1; use crate::proto::ctap2::preflight::{ctap2_preflight, ctap2_preflight_with_appid}; use crate::proto::ctap2::{ - Ctap2, Ctap2ClientPinRequest, Ctap2GetAssertionRequest, Ctap2MakeCredentialRequest, - Ctap2UserVerificationOperation, + Ctap2, Ctap2ClientPinRequest, Ctap2GetAssertionRequest, Ctap2GetAssertionResponse, + Ctap2MakeCredentialRequest, Ctap2UserVerificationOperation, }; pub use crate::transport::error::TransportError; -use crate::transport::Channel; +use crate::transport::{AuthTokenData, Channel}; pub use crate::webauthn::error::{CtapError, Error, PlatformError}; use crate::UvUpdate; @@ -254,6 +256,21 @@ async fn get_assertion_fido2( channel: &mut C, op: &GetAssertionRequest, ) -> Result { + // WebAuthn L3 §10.1.5: largeBlob.write/delete requires exactly one allowCredentials entry. + let large_blob_ext = op.extensions.as_ref().and_then(|e| e.large_blob.as_ref()); + if matches!( + large_blob_ext, + Some(GetAssertionLargeBlobExtension::Write(_)) + | Some(GetAssertionLargeBlobExtension::Delete) + ) && op.allow.len() != 1 + { + warn!( + count = op.allow.len(), + "largeBlob.write/delete requires exactly one allowCredentials entry" + ); + return Err(Error::Platform(PlatformError::NotSupported)); + } + let get_info_response = channel.ctap2_get_info().await?; let mut ctap2_request = Ctap2GetAssertionRequest::from_webauthn_request(op, &get_info_response)?; @@ -330,57 +347,115 @@ async fn get_assertion_fido2( ctap_responses.push(channel.ctap2_get_next_assertion(op.timeout).await?); } - // largeBlob.read 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 stays in scope. - let large_blob_read_requested = op.extensions.as_ref().and_then(|e| e.large_blob.as_ref()) - == Some(&GetAssertionLargeBlobExtension::Read); - let large_blob_outputs: Vec>> = if large_blob_read_requested { - let max_fragment = max_fragment_length(get_info_response.max_msg_size); - // The largeBlobArray is device-wide: fetch and parse once, decrypt per credential. - let entries = if ctap_responses.iter().any(|r| r.large_blob_key.is_some()) { - match fetch_large_blob_entries(channel, max_fragment, op.timeout).await { - Ok(entries) => Some(entries), - Err(e) => { - warn!(?e, "authenticatorLargeBlobs(get) failed; no blob returned"); - None - } - } - } else { - None - }; - ctap_responses - .iter() - .map(|resp| { - let entries = entries.as_ref()?; - let key_buf = resp.large_blob_key.as_ref()?; - let Ok(key) = <[u8; 32]>::try_from(key_buf.as_slice()) else { - warn!( - len = key_buf.len(), - "largeBlobKey has unexpected length (expected 32); skipping" - ); - return None; - }; - match decrypt_first_matching(entries, &key) { - Ok(blob) => blob, + // largeBlob extension (WebAuthn L3 §10.1.5): + // Read → authenticatorLargeBlobs(get): decrypt and surface the per-credential blob. + // Write → authenticatorLargeBlobs(get+set): RMW + chunked upload of the updated array. + // Delete → same as Write but with the entry erased (no new entry appended). + // Failures are non-fatal: per L3, `blob` is absent on read failure and + // `written` is `false` on write/delete failure. Resolved here before the + // CTAP responses are converted to Assertion so the largeBlobKey stays + // within this scope. + let max_fragment = max_fragment_length(get_info_response.max_msg_size); + let large_blob_outputs = match large_blob_ext { + Some(GetAssertionLargeBlobExtension::Read) => { + // The largeBlobArray is device-wide: fetch and parse once, decrypt per credential. + let entries = if ctap_responses.iter().any(|r| r.large_blob_key.is_some()) { + match fetch_large_blob_entries(channel, max_fragment, op.timeout).await { + Ok(entries) => Some(entries), Err(e) => { - warn!(?e, "largeBlob decrypt failed; no blob returned"); + warn!(?e, "authenticatorLargeBlobs(get) failed; no blob returned"); None } } - }) - .collect() - } else { - Vec::new() + } else { + None + }; + ctap_responses + .iter() + .map(|resp| { + let blob = match (entries.as_ref(), extract_large_blob_key(resp)) { + (Some(entries), Some(key)) => match decrypt_first_matching(entries, &key) { + Ok(blob) => blob, + Err(e) => { + warn!(?e, "largeBlob decrypt failed; no blob returned"); + None + } + }, + _ => None, + }; + GetAssertionLargeBlobExtensionOutput { blob, written: None } + }) + .collect::>() + } + Some(GetAssertionLargeBlobExtension::Write(payload)) => { + // L3 §10.1.5: write applies to the single matched credential. We + // enforced allow.len()==1 above, and ctap_responses then has + // exactly one element. + let auth_data = channel.get_auth_data().cloned(); + let written = match ctap_responses.first() { + Some(resp) => { + write_or_delete_for_first( + channel, + resp, + auth_data, + max_fragment, + op.timeout, + WriteOrDelete::Write(payload.as_slice()), + ) + .await + } + None => false, + }; + let mut outs: Vec<_> = vec![ + GetAssertionLargeBlobExtensionOutput { + blob: None, + written: Some(written), + }; + ctap_responses.len() + ]; + // Only the first (and only) assertion carries the `written` flag. + for o in outs.iter_mut().skip(1) { + o.written = None; + } + outs + } + Some(GetAssertionLargeBlobExtension::Delete) => { + let auth_data = channel.get_auth_data().cloned(); + let written = match ctap_responses.first() { + Some(resp) => { + write_or_delete_for_first( + channel, + resp, + auth_data, + max_fragment, + op.timeout, + WriteOrDelete::Delete, + ) + .await + } + None => false, + }; + let mut outs: Vec<_> = vec![ + GetAssertionLargeBlobExtensionOutput { + blob: None, + written: Some(written), + }; + ctap_responses.len() + ]; + for o in outs.iter_mut().skip(1) { + o.written = None; + } + outs + } + None => Vec::new(), }; 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(large_blob_outputs) { - let entry = GetAssertionLargeBlobExtensionOutput { blob }; + if !large_blob_outputs.is_empty() { + for (assertion, entry) in assertions.iter_mut().zip(large_blob_outputs) { match assertion.unsigned_extensions_output.as_mut() { Some(unsigned) => unsigned.large_blob = Some(entry), None => { @@ -397,6 +472,73 @@ async fn get_assertion_fido2( Ok(assertions.as_slice().into()) } +enum WriteOrDelete<'a> { + Write(&'a [u8]), + Delete, +} + +fn extract_large_blob_key(resp: &Ctap2GetAssertionResponse) -> Option<[u8; 32]> { + let buf = resp.large_blob_key.as_ref()?; + match <[u8; 32]>::try_from(buf.as_slice()) { + Ok(k) => Some(k), + Err(_) => { + warn!( + len = buf.len(), + "largeBlobKey has unexpected length (expected 32); skipping" + ); + None + } + } +} + +/// Drive `write_authenticator_large_blob` / `delete_authenticator_large_blob` for the single +/// credential of a write/delete assertion. Returns the `written` flag per WebAuthn L3 §10.1.5. +async fn write_or_delete_for_first( + channel: &mut C, + resp: &Ctap2GetAssertionResponse, + auth_data: Option, + max_fragment: u32, + timeout: std::time::Duration, + op: WriteOrDelete<'_>, +) -> bool { + let Some(key) = extract_large_blob_key(resp) else { + warn!("largeBlobKey absent from assertion; cannot write/delete"); + return false; + }; + // CTAP 2.2 §6.10.2 lines 100-115: the authenticator enforces pinUvAuthParam + // only if it is UV-protected. On an unprotected authenticator (no clientPin, + // no built-in UV) user_verification stores no token, so we send the chunks + // without auth params and let the authenticator's "skip auth block" path run. + let pin_uv_auth: Option<(&[u8], _)> = auth_data.as_ref().and_then(|d| { + d.pin_uv_auth_token + .as_deref() + .map(|t| (t, d.protocol_version)) + }); + let result = match op { + WriteOrDelete::Write(payload) => { + write_authenticator_large_blob( + channel, + &key, + payload, + max_fragment, + pin_uv_auth, + timeout, + ) + .await + } + WriteOrDelete::Delete => { + delete_authenticator_large_blob(channel, &key, max_fragment, pin_uv_auth, timeout).await + } + }; + match result { + Ok(()) => true, + Err(e) => { + warn!(?e, "authenticatorLargeBlobs(set) failed; written=false"); + false + } + } +} + async fn get_assertion_u2f( channel: &mut C, op: &GetAssertionRequest, diff --git a/libwebauthn/src/webauthn/pin_uv_auth_token.rs b/libwebauthn/src/webauthn/pin_uv_auth_token.rs index ffadd83e..85c68afc 100644 --- a/libwebauthn/src/webauthn/pin_uv_auth_token.rs +++ b/libwebauthn/src/webauthn/pin_uv_auth_token.rs @@ -213,10 +213,14 @@ where return Ok(UsedPinUvAuthToken::LegacyUV); } } else if rp_uv_discouraged && needs_shared_secret { - // We are not using LegacyUV, but have full support, however RP - // discouraged UV, but the request requires a shared secret. - // Then we are downgrading the 'supported' uv_operation to OnlyForSharedSecret - uv_operation = Ctap2UserVerificationOperation::OnlyForSharedSecret; + // RP picked UV=Discouraged but the request needs a shared secret. The + // historical downgrade is "OnlyForSharedSecret" (no full token). For + // requests that need a full pinUvAuthToken (e.g. largeBlob.write + // needs the `lbw` permission per CTAP 2.2 §6.10.2 line 113), keep + // the standard token-acquiring flow even when UV is Discouraged. + if !ctap2_request.needs_pin_uv_auth_token(get_info_response) { + uv_operation = Ctap2UserVerificationOperation::OnlyForSharedSecret; + } } let Some(uv_proto) = select_uv_proto( From d9575ee2cd55ce95b7a7a015f4330dfa9dae7223 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 19 May 2026 21:39:49 +0100 Subject: [PATCH 3/4] test(libwebauthn-tests): largeBlob write, replace, delete round-trips Exercises the WebAuthn write/delete extensions against the virt authenticator end-to-end: - write then read returns the planted bytes - second write replaces the first entry - delete after write removes the entry; subsequent read returns no blob - delete with no prior entry reports written=false --- libwebauthn-tests/tests/large_blob.rs | 270 +++++++++++++++++++++++++- 1 file changed, 268 insertions(+), 2 deletions(-) diff --git a/libwebauthn-tests/tests/large_blob.rs b/libwebauthn-tests/tests/large_blob.rs index 71ef767c..366b4365 100644 --- a/libwebauthn-tests/tests/large_blob.rs +++ b/libwebauthn-tests/tests/large_blob.rs @@ -30,6 +30,13 @@ async fn handle_updates(mut state_recv: Receiver) { assert_eq!(state_recv.recv().await, Ok(UvUpdate::PresenceRequired)); } +/// Drain `n` `PresenceRequired` updates. One per high-level WebAuthn ceremony. +async fn handle_updates_n(mut state_recv: Receiver, n: usize) { + for _ in 0..n { + 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(); @@ -167,7 +174,7 @@ async fn plant_large_blob_array( .expect("authenticatorLargeBlobs(set) succeeds without PIN"); } -/// Encode one largeBlobMap entry per CTAP 2.1 §6.10.3. +/// Encode one largeBlobMap entry per CTAP 2.2 §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}; @@ -208,7 +215,7 @@ fn encode_entry(key: &[u8; 32], nonce: &[u8; 12], plaintext: &[u8]) -> Vec { buf } -/// Wrap entries in a CBOR array + 16-byte left-SHA-256 trailer (CTAP 2.1 §6.10.3). +/// Wrap entries in a CBOR array + 16-byte left-SHA-256 trailer (CTAP 2.2 §6.10.2). fn encode_serialized_array(entries: &[Vec]) -> Vec { use sha2::{Digest, Sha256}; assert!( @@ -223,3 +230,262 @@ fn encode_serialized_array(entries: &[Vec]) -> Vec { out.extend_from_slice(&h[..16]); out } + +async fn register_with_large_blob( + channel: &mut libwebauthn::transport::hid::channel::HidChannel<'_>, + user_handle: &str, + challenge: &[u8; 32], +) -> Ctap2PublicKeyCredentialDescriptor { + let user_id: [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, user_handle, user_handle), + 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 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" + ); + (&response.authenticator_data) + .try_into() + .expect("credential descriptor") +} + +fn ga_request( + credential: &Ctap2PublicKeyCredentialDescriptor, + challenge: &[u8; 32], + ext: GetAssertionLargeBlobExtension, +) -> GetAssertionRequest { + 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(ext), + }), + timeout: TIMEOUT, + top_origin: None, + } +} + +/// End-to-end round trip via the production write+read paths. Drives WebAuthn +/// `largeBlob.write` → `largeBlob.read` against the virt authenticator and +/// asserts that the read returns exactly the bytes written. +#[test(tokio::test)] +async fn test_webauthn_large_blob_write_then_read_returns_blob() { + let mut device = get_virtual_device(); + let mut channel = device.channel().await.unwrap(); + let challenge: [u8; 32] = thread_rng().gen(); + + let state_recv = channel.get_ux_update_receiver(); + // MakeCredential + GetAssertion(write) + GetAssertion(read) = 3 PresenceRequired updates. + let update_handle = tokio::spawn(handle_updates_n(state_recv, 3)); + + let credential = register_with_large_blob(&mut channel, "alice", &challenge).await; + let plaintext = b"webauthn largeBlob via WebAuthn API".to_vec(); + + let write_resp = channel + .webauthn_get_assertion(&ga_request( + &credential, + &challenge, + GetAssertionLargeBlobExtension::Write(plaintext.clone()), + )) + .await + .expect("Write assertion should succeed"); + let written = write_resp.assertions[0] + .unsigned_extensions_output + .as_ref() + .and_then(|u| u.large_blob.as_ref()) + .and_then(|lb| lb.written); + assert_eq!(written, Some(true), "largeBlob.written should be true"); + + let read_resp = channel + .webauthn_get_assertion(&ga_request( + &credential, + &challenge, + GetAssertionLargeBlobExtension::Read, + )) + .await + .expect("Read assertion should succeed"); + let blob = read_resp.assertions[0] + .unsigned_extensions_output + .as_ref() + .and_then(|u| u.large_blob.as_ref()) + .and_then(|lb| lb.blob.as_ref()) + .expect("blob present after write"); + assert_eq!(blob.as_slice(), plaintext.as_slice()); + + update_handle.await.unwrap(); +} + +/// `largeBlob.write` followed by a second `largeBlob.write` of different bytes: +/// per CTAP 2.2 §6.10.6 the second write replaces (not appends to) the first. +#[test(tokio::test)] +async fn test_webauthn_large_blob_write_replaces_existing_entry() { + let mut device = get_virtual_device(); + let mut channel = device.channel().await.unwrap(); + let challenge: [u8; 32] = thread_rng().gen(); + + let state_recv = channel.get_ux_update_receiver(); + let update_handle = tokio::spawn(handle_updates_n(state_recv, 4)); + + let credential = register_with_large_blob(&mut channel, "bob", &challenge).await; + + let first = b"first blob payload".to_vec(); + let second = b"second, longer blob payload that supersedes the first".to_vec(); + + channel + .webauthn_get_assertion(&ga_request( + &credential, + &challenge, + GetAssertionLargeBlobExtension::Write(first.clone()), + )) + .await + .expect("first write"); + + channel + .webauthn_get_assertion(&ga_request( + &credential, + &challenge, + GetAssertionLargeBlobExtension::Write(second.clone()), + )) + .await + .expect("second write"); + + let read_resp = channel + .webauthn_get_assertion(&ga_request( + &credential, + &challenge, + GetAssertionLargeBlobExtension::Read, + )) + .await + .expect("read"); + let blob = read_resp.assertions[0] + .unsigned_extensions_output + .as_ref() + .and_then(|u| u.large_blob.as_ref()) + .and_then(|lb| lb.blob.as_ref()) + .expect("blob present after second write"); + assert_eq!(blob.as_slice(), second.as_slice(), "second write replaced"); + + update_handle.await.unwrap(); +} + +/// Delete on a credential with no prior largeBlob returns written=false per the strict +/// CTAP 2.2 §6.10.6 "Return an error" branch (line 303). +#[test(tokio::test)] +async fn test_webauthn_large_blob_delete_without_existing_entry_reports_false() { + let mut device = get_virtual_device(); + let mut channel = device.channel().await.unwrap(); + let challenge: [u8; 32] = thread_rng().gen(); + + let state_recv = channel.get_ux_update_receiver(); + let update_handle = tokio::spawn(handle_updates_n(state_recv, 2)); + + let credential = register_with_large_blob(&mut channel, "dave", &challenge).await; + + let del_resp = channel + .webauthn_get_assertion(&ga_request( + &credential, + &challenge, + GetAssertionLargeBlobExtension::Delete, + )) + .await + .expect("Delete assertion should still return the assertion"); + assert_eq!( + del_resp.assertions[0] + .unsigned_extensions_output + .as_ref() + .and_then(|u| u.large_blob.as_ref()) + .and_then(|lb| lb.written), + Some(false), + "delete with no existing entry reports written=false" + ); + + update_handle.await.unwrap(); +} + +/// Delete after write erases the entry; subsequent read returns no blob. +#[test(tokio::test)] +async fn test_webauthn_large_blob_delete_removes_entry() { + let mut device = get_virtual_device(); + let mut channel = device.channel().await.unwrap(); + let challenge: [u8; 32] = thread_rng().gen(); + + let state_recv = channel.get_ux_update_receiver(); + let update_handle = tokio::spawn(handle_updates_n(state_recv, 4)); + + let credential = register_with_large_blob(&mut channel, "carol", &challenge).await; + let payload = b"to be deleted".to_vec(); + + channel + .webauthn_get_assertion(&ga_request( + &credential, + &challenge, + GetAssertionLargeBlobExtension::Write(payload), + )) + .await + .expect("write"); + + let del_resp = channel + .webauthn_get_assertion(&ga_request( + &credential, + &challenge, + GetAssertionLargeBlobExtension::Delete, + )) + .await + .expect("delete"); + assert_eq!( + del_resp.assertions[0] + .unsigned_extensions_output + .as_ref() + .and_then(|u| u.large_blob.as_ref()) + .and_then(|lb| lb.written), + Some(true), + "delete reports written=true" + ); + + let read_resp = channel + .webauthn_get_assertion(&ga_request( + &credential, + &challenge, + GetAssertionLargeBlobExtension::Read, + )) + .await + .expect("read after delete"); + let blob_after = read_resp.assertions[0] + .unsigned_extensions_output + .as_ref() + .and_then(|u| u.large_blob.as_ref()) + .and_then(|lb| lb.blob.as_ref()); + assert!(blob_after.is_none(), "blob absent after delete"); + + update_handle.await.unwrap(); +} From 6799ed64bb57f97774dc28ff605c518aa733fd0d Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 7 Jun 2026 16:13:00 +0100 Subject: [PATCH 4/4] fix(webauthn): address largeBlob write/delete review findings Preserve foreign largeBlobArray entries by their exact bytes on read-modify-write rather than re-serialising them, which also drops a redundant re-encode of the new entry. Treat a valid-trailer but unparseable array as a fail-safe error rather than clobbering it. Satisfy the production clippy::indexing_slicing denies in the write path. Refuse the U2F downgrade for largeBlob write and delete. Adds tests for nonce uniqueness across writes, the pinUvAuthParam protocol-1 path, the JSON write input, the PIN-protected write token ceremony under UV=Discouraged, foreign-blob survival across write and delete, the single-allowCredential guard, and shrink-on-replace. --- libwebauthn-tests/tests/large_blob.rs | 247 +++- libwebauthn/src/ops/webauthn/get_assertion.rs | 41 +- libwebauthn/src/ops/webauthn/large_blob.rs | 1078 +++++++++++------ libwebauthn/src/webauthn.rs | 10 +- libwebauthn/src/webauthn/pin_uv_auth_token.rs | 136 ++- 5 files changed, 1104 insertions(+), 408 deletions(-) diff --git a/libwebauthn-tests/tests/large_blob.rs b/libwebauthn-tests/tests/large_blob.rs index 366b4365..5ff21fb1 100644 --- a/libwebauthn-tests/tests/large_blob.rs +++ b/libwebauthn-tests/tests/large_blob.rs @@ -13,7 +13,7 @@ use libwebauthn::proto::ctap2::{ Ctap2PublicKeyCredentialUserEntity, }; use libwebauthn::transport::{Channel, ChannelSettings, Device}; -use libwebauthn::webauthn::WebAuthn; +use libwebauthn::webauthn::{Error, PlatformError, WebAuthn}; use libwebauthn::UvUpdate; use libwebauthn_tests::virt::get_virtual_device; use rand::{thread_rng, Rng}; @@ -301,7 +301,7 @@ fn ga_request( #[test(tokio::test)] async fn test_webauthn_large_blob_write_then_read_returns_blob() { let mut device = get_virtual_device(); - let mut channel = device.channel().await.unwrap(); + let mut channel = device.channel(ChannelSettings::default()).await.unwrap(); let challenge: [u8; 32] = thread_rng().gen(); let state_recv = channel.get_ux_update_receiver(); @@ -350,7 +350,7 @@ async fn test_webauthn_large_blob_write_then_read_returns_blob() { #[test(tokio::test)] async fn test_webauthn_large_blob_write_replaces_existing_entry() { let mut device = get_virtual_device(); - let mut channel = device.channel().await.unwrap(); + let mut channel = device.channel(ChannelSettings::default()).await.unwrap(); let challenge: [u8; 32] = thread_rng().gen(); let state_recv = channel.get_ux_update_receiver(); @@ -403,7 +403,7 @@ async fn test_webauthn_large_blob_write_replaces_existing_entry() { #[test(tokio::test)] async fn test_webauthn_large_blob_delete_without_existing_entry_reports_false() { let mut device = get_virtual_device(); - let mut channel = device.channel().await.unwrap(); + let mut channel = device.channel(ChannelSettings::default()).await.unwrap(); let challenge: [u8; 32] = thread_rng().gen(); let state_recv = channel.get_ux_update_receiver(); @@ -436,7 +436,7 @@ async fn test_webauthn_large_blob_delete_without_existing_entry_reports_false() #[test(tokio::test)] async fn test_webauthn_large_blob_delete_removes_entry() { let mut device = get_virtual_device(); - let mut channel = device.channel().await.unwrap(); + let mut channel = device.channel(ChannelSettings::default()).await.unwrap(); let challenge: [u8; 32] = thread_rng().gen(); let state_recv = channel.get_ux_update_receiver(); @@ -489,3 +489,240 @@ async fn test_webauthn_large_blob_delete_removes_entry() { update_handle.await.unwrap(); } + +async fn write_large_blob( + channel: &mut libwebauthn::transport::hid::channel::HidChannel<'_>, + credential: &Ctap2PublicKeyCredentialDescriptor, + challenge: &[u8; 32], + blob: Vec, +) { + let resp = channel + .webauthn_get_assertion(&ga_request( + credential, + challenge, + GetAssertionLargeBlobExtension::Write(blob), + )) + .await + .expect("write assertion should succeed"); + assert_eq!( + resp.assertions[0] + .unsigned_extensions_output + .as_ref() + .and_then(|u| u.large_blob.as_ref()) + .and_then(|lb| lb.written), + Some(true), + "largeBlob.written should be true" + ); +} + +async fn read_large_blob( + channel: &mut libwebauthn::transport::hid::channel::HidChannel<'_>, + credential: &Ctap2PublicKeyCredentialDescriptor, + challenge: &[u8; 32], +) -> Option> { + let resp = channel + .webauthn_get_assertion(&ga_request( + credential, + challenge, + GetAssertionLargeBlobExtension::Read, + )) + .await + .expect("read assertion should succeed"); + resp.assertions[0] + .unsigned_extensions_output + .as_ref() + .and_then(|u| u.large_blob.as_ref()) + .and_then(|lb| lb.blob.clone()) +} + +/// Two largeBlob-capable credentials coexist on one authenticator. A's write +/// (replace) and delete must leave B's foreign entry intact (CTAP 2.2 §6.10.6). +#[test(tokio::test)] +async fn test_webauthn_large_blob_foreign_entry_survives_write_and_delete() { + let mut device = get_virtual_device(); + let mut channel = device.channel(ChannelSettings::default()).await.unwrap(); + let challenge: [u8; 32] = thread_rng().gen(); + + let state_recv = channel.get_ux_update_receiver(); + // 2 registrations + 3 writes + 4 reads + 1 delete = 10 ceremonies. + let update_handle = tokio::spawn(handle_updates_n(state_recv, 10)); + + let cred_a = register_with_large_blob(&mut channel, "alice", &challenge).await; + let cred_b = register_with_large_blob(&mut channel, "bob", &challenge).await; + + let blob_a = b"alice's blob".to_vec(); + let blob_b = b"bob's blob must survive".to_vec(); + + write_large_blob(&mut channel, &cred_a, &challenge, blob_a.clone()).await; + write_large_blob(&mut channel, &cred_b, &challenge, blob_b.clone()).await; + + assert_eq!( + read_large_blob(&mut channel, &cred_a, &challenge) + .await + .as_deref(), + Some(blob_a.as_slice()), + "A reads back its own blob" + ); + assert_eq!( + read_large_blob(&mut channel, &cred_b, &challenge) + .await + .as_deref(), + Some(blob_b.as_slice()), + "B reads back its own blob" + ); + + let blob_a2 = b"alice's replacement blob, longer than the first".to_vec(); + write_large_blob(&mut channel, &cred_a, &challenge, blob_a2.clone()).await; + assert_eq!( + read_large_blob(&mut channel, &cred_b, &challenge) + .await + .as_deref(), + Some(blob_b.as_slice()), + "B's blob survives A's replace" + ); + + let del_resp = channel + .webauthn_get_assertion(&ga_request( + &cred_a, + &challenge, + GetAssertionLargeBlobExtension::Delete, + )) + .await + .expect("delete A should succeed"); + assert_eq!( + del_resp.assertions[0] + .unsigned_extensions_output + .as_ref() + .and_then(|u| u.large_blob.as_ref()) + .and_then(|lb| lb.written), + Some(true), + "delete A reports written=true" + ); + assert_eq!( + read_large_blob(&mut channel, &cred_b, &challenge) + .await + .as_deref(), + Some(blob_b.as_slice()), + "B's blob survives A's delete" + ); + + update_handle.await.unwrap(); +} + +/// WebAuthn L3 §10.1.5: largeBlob.write/delete requires exactly one allowCredentials +/// entry. The platform guard in get_assertion_fido2 rejects a write with two (or zero) +/// allowed credentials as NotSupported, before any CTAP traffic. +#[test(tokio::test)] +async fn test_webauthn_large_blob_write_requires_single_allow_credential() { + let mut device = get_virtual_device(); + let mut channel = device.channel(ChannelSettings::default()).await.unwrap(); + let challenge: [u8; 32] = thread_rng().gen(); + + let state_recv = channel.get_ux_update_receiver(); + // Two registrations, one PresenceRequired each. The rejected writes emit none. + let update_handle = tokio::spawn(handle_updates_n(state_recv, 2)); + + let cred_a = register_with_large_blob(&mut channel, "alice", &challenge).await; + let cred_b = register_with_large_blob(&mut channel, "bob", &challenge).await; + + let mut two = ga_request( + &cred_a, + &challenge, + GetAssertionLargeBlobExtension::Write(b"blob".to_vec()), + ); + two.allow = vec![cred_a.clone(), cred_b]; + let err = channel + .webauthn_get_assertion(&two) + .await + .expect_err("write with two allowCredentials must be rejected"); + assert_eq!(err, Error::Platform(PlatformError::NotSupported)); + + let mut none = ga_request( + &cred_a, + &challenge, + GetAssertionLargeBlobExtension::Write(b"blob".to_vec()), + ); + none.allow = vec![]; + let err = channel + .webauthn_get_assertion(&none) + .await + .expect_err("write with empty allowCredentials must be rejected"); + assert_eq!(err, Error::Platform(PlatformError::NotSupported)); + + update_handle.await.unwrap(); +} + +/// Replacing a blob with a strictly smaller one shrinks the stored array: the +/// read-modify-write rebuild must drop the larger entry entirely so the read +/// returns exactly the smaller blob (no stale trailing bytes). +#[test(tokio::test)] +async fn test_webauthn_large_blob_write_replaces_with_smaller_blob() { + let mut device = get_virtual_device(); + let mut channel = device.channel(ChannelSettings::default()).await.unwrap(); + let challenge: [u8; 32] = thread_rng().gen(); + + let state_recv = channel.get_ux_update_receiver(); + // MakeCredential + write(large) + read + write(small) + read = 5 PresenceRequired updates. + let update_handle = tokio::spawn(handle_updates_n(state_recv, 5)); + + let credential = register_with_large_blob(&mut channel, "erin", &challenge).await; + + let large = b"compressible largeBlob payload ".repeat(20); + let small = b"tiny".to_vec(); + + channel + .webauthn_get_assertion(&ga_request( + &credential, + &challenge, + GetAssertionLargeBlobExtension::Write(large.clone()), + )) + .await + .expect("large write"); + + let read_large = channel + .webauthn_get_assertion(&ga_request( + &credential, + &challenge, + GetAssertionLargeBlobExtension::Read, + )) + .await + .expect("read after large write"); + let blob_large = read_large.assertions[0] + .unsigned_extensions_output + .as_ref() + .and_then(|u| u.large_blob.as_ref()) + .and_then(|lb| lb.blob.as_ref()) + .expect("blob present after large write"); + assert_eq!(blob_large.as_slice(), large.as_slice()); + + channel + .webauthn_get_assertion(&ga_request( + &credential, + &challenge, + GetAssertionLargeBlobExtension::Write(small.clone()), + )) + .await + .expect("small write"); + + let read_small = channel + .webauthn_get_assertion(&ga_request( + &credential, + &challenge, + GetAssertionLargeBlobExtension::Read, + )) + .await + .expect("read after small write"); + let blob_small = read_small.assertions[0] + .unsigned_extensions_output + .as_ref() + .and_then(|u| u.large_blob.as_ref()) + .and_then(|lb| lb.blob.as_ref()) + .expect("blob present after small write"); + assert_eq!( + blob_small.as_slice(), + small.as_slice(), + "smaller write replaced larger" + ); + + update_handle.await.unwrap(); +} diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 41b07150..e84af098 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -214,7 +214,7 @@ impl FromIdlModel for GetAssertionRequest .and_then(|e| e.large_blob.as_ref()) { // L3 §10.1.5: largeBlob without read=true or support is a no-op, not an error. - Some(lb) if lb.support.is_none() && lb.read != Some(true) => None, + Some(lb) if lb.support.is_none() && lb.read != Some(true) && lb.write.is_none() => None, Some(lb) => Some(GetAssertionLargeBlobExtension::try_from(lb.clone())?), None => None, }; @@ -353,8 +353,8 @@ impl TryFrom for GetAssertionLargeBlobExtension { "largeBlob.support is only valid at registration".to_string(), )); } - // WebAuthn L3 §10.1.5: read and write are mutually exclusive. - if value.read == Some(true) && value.write.is_some() { + // WebAuthn L3 §10.1.5: read and write present together is an error. + if value.read.is_some() && value.write.is_some() { return Err(GetAssertionPrepareError::NotSupported( "largeBlob.read and largeBlob.write are mutually exclusive".to_string(), )); @@ -604,6 +604,15 @@ impl DowngradableRequest> for GetAssertionRequest { return false; } + if matches!( + self.extensions.as_ref().and_then(|e| e.large_blob.as_ref()), + Some(GetAssertionLargeBlobExtension::Write(_)) + | Some(GetAssertionLargeBlobExtension::Delete) + ) { + debug!("Not downgradable: largeBlob write/delete requires FIDO2"); + return false; + } + true } @@ -1409,6 +1418,7 @@ mod tests { GetAssertionLargeBlobExtension::try_from(LargeBlobInputJson { support: None, read: Some(true), + write: None, }) .unwrap(), GetAssertionLargeBlobExtension::Read @@ -1417,6 +1427,7 @@ mod tests { GetAssertionLargeBlobExtension::try_from(LargeBlobInputJson { support: Some("required".to_string()), read: Some(true), + write: None, }), Err(GetAssertionPrepareError::NotSupported(_)) )); @@ -1453,4 +1464,28 @@ mod tests { .expect("largeBlob.read=false must be a no-op, not an error"); assert!(req.extensions.and_then(|e| e.large_blob).is_none()); } + + #[test] + fn large_blob_json_write_input_and_mutual_exclusion() { + use crate::ops::webauthn::idl::get::LargeBlobInputJson; + + let blob = b"blob to write".to_vec(); + assert_eq!( + GetAssertionLargeBlobExtension::try_from(LargeBlobInputJson { + support: None, + read: None, + write: Some(Base64UrlString::from(blob.clone())), + }) + .unwrap(), + GetAssertionLargeBlobExtension::Write(blob) + ); + assert!(matches!( + GetAssertionLargeBlobExtension::try_from(LargeBlobInputJson { + support: None, + read: Some(true), + write: Some(Base64UrlString::from(b"x".to_vec())), + }), + Err(GetAssertionPrepareError::NotSupported(_)) + )); + } } diff --git a/libwebauthn/src/ops/webauthn/large_blob.rs b/libwebauthn/src/ops/webauthn/large_blob.rs index f79f74cd..14787bf5 100644 --- a/libwebauthn/src/ops/webauthn/large_blob.rs +++ b/libwebauthn/src/ops/webauthn/large_blob.rs @@ -46,7 +46,7 @@ pub(crate) fn max_fragment_length(max_msg_size: Option) -> u32 { } } -/// `LEFT(SHA-256(data), 16)` array trailer (CTAP 2.1 §6.10.2). +/// `LEFT(SHA-256(data), 16)` array trailer (CTAP 2.2 §6.10.2). fn array_trailer(data: &[u8]) -> [u8; LARGE_BLOB_HASH_LEN] { let digest = Sha256::digest(data); let mut out = [0u8; LARGE_BLOB_HASH_LEN]; @@ -387,19 +387,100 @@ pub(crate) fn large_blob_pin_uv_auth_param( proto.authenticate(token, &buf) } -/// Top-level CBOR array of large-blob maps as raw `Value`s (preserves unknown fields). -fn parse_large_blob_array_values(bytes: &[u8]) -> Result, LargeBlobError> { +/// One array element kept as its exact CBOR bytes, plus a best-effort decode for ownership testing. +struct RawArrayEntry { + raw: Vec, + value: Option, +} + +fn read_byte(cursor: &mut std::io::Cursor<&[u8]>) -> Result { + use std::io::Read; + let mut b = [0u8; 1]; + cursor + .read_exact(&mut b) + .map_err(|_| LargeBlobError::Corrupted("truncated CBOR".into()))?; + let [byte] = b; + Ok(byte) +} + +fn read_uint(cursor: &mut std::io::Cursor<&[u8]>, n: usize) -> Result { + let mut val: u64 = 0; + for _ in 0..n { + val = (val << 8) | read_byte(cursor)? as u64; + } + Ok(val) +} + +/// Read a definite-length CBOR array header (major type 4), returning the element count. +fn read_array_header(cursor: &mut std::io::Cursor<&[u8]>) -> Result { + let initial = read_byte(cursor)?; + if initial >> 5 != 4 { + return Err(LargeBlobError::Corrupted(format!( + "expected CBOR array, got initial byte {initial:#x}" + ))); + } + let count = match initial & 0x1f { + n @ 0..=23 => n as u64, + 24 => read_uint(cursor, 1)?, + 25 => read_uint(cursor, 2)?, + 26 => read_uint(cursor, 4)?, + 27 => read_uint(cursor, 8)?, + ai => { + return Err(LargeBlobError::Corrupted(format!( + "unsupported CBOR array length encoding (additional info {ai})" + ))) + } + }; + usize::try_from(count).map_err(|_| LargeBlobError::Corrupted("array too large".into())) +} + +/// CBOR definite-length array header for `n` elements. +fn encode_array_header(n: usize) -> Vec { + let mut out = Vec::new(); + if n <= 23 { + out.push(0x80 | n as u8); + } else if n <= 0xff { + out.push(0x98); + out.push(n as u8); + } else if n <= 0xffff { + out.push(0x99); + out.extend_from_slice(&(n as u16).to_be_bytes()); + } else { + out.push(0x9a); + out.extend_from_slice(&(n as u32).to_be_bytes()); + } + out +} + +/// Parse the top-level array into per-element raw byte spans, preserving foreign entries exactly. +fn parse_array_raw_entries(bytes: &[u8]) -> Result, LargeBlobError> { if bytes.is_empty() { return Ok(Vec::new()); } - let value: Value = crate::proto::ctap2::cbor::from_slice(bytes) - .map_err(|e| LargeBlobError::Corrupted(format!("array parse: {e}")))?; - match value { - Value::Array(a) => Ok(a), - other => Err(LargeBlobError::Corrupted(format!( - "expected CBOR array, got {other:?}" - ))), + let mut cursor = std::io::Cursor::new(bytes); + let count = read_array_header(&mut cursor)?; + // Bound the pre-allocation by the remaining bytes (each element is >= 1 byte) so a + // device-declared count cannot drive an unbounded allocation. + let remaining = bytes.len().saturating_sub(cursor.position() as usize); + let mut entries = Vec::with_capacity(count.min(remaining)); + for _ in 0..count { + let start = cursor.position() as usize; + let _: serde::de::IgnoredAny = crate::proto::ctap2::cbor::from_cursor(&mut cursor) + .map_err(|e| LargeBlobError::Corrupted(format!("array element parse: {e}")))?; + let end = cursor.position() as usize; + let raw = bytes + .get(start..end) + .ok_or_else(|| LargeBlobError::Corrupted("array element span out of range".into()))? + .to_vec(); + let value = crate::proto::ctap2::cbor::from_slice::(&raw).ok(); + entries.push(RawArrayEntry { raw, value }); } + if cursor.position() as usize != bytes.len() { + return Err(LargeBlobError::Corrupted( + "trailing bytes after largeBlobArray".into(), + )); + } + Ok(entries) } /// AEAD-verify an entry under `key`. Used to identify the credential's own entry during RMW. @@ -449,41 +530,46 @@ fn entry_decrypts_under_key(entry: &Value, key: &[u8; 32]) -> bool { .is_ok() } -/// Drop entries that AEAD-verify under `drop_key`, optionally append `new_entry`, re-serialize with trailer. -/// Foreign entries (different key, malformed, unknown fields) are preserved verbatim per CTAP 2.2 §6.10.2. +/// Drop entries that AEAD-verify under `drop_key`, optionally append `new_entry`, append the trailer. +/// Foreign entries are spliced back by their original bytes per CTAP 2.2 §6.10.2. fn rebuild_serialized_array( - existing: &[Value], + existing: &[RawArrayEntry], drop_key: &[u8; 32], - new_entry: Option, + new_entry: Option>, ) -> Result, LargeBlobError> { - let mut kept: Vec = Vec::with_capacity(existing.len() + 1); + let mut kept: Vec<&[u8]> = Vec::with_capacity(existing.len() + 1); for entry in existing { - if entry_decrypts_under_key(entry, drop_key) { + if entry + .value + .as_ref() + .is_some_and(|v| entry_decrypts_under_key(v, drop_key)) + { trace!("largeBlob RMW: dropping entry owned by this credential"); continue; } - kept.push(entry.clone()); + kept.push(&entry.raw); } - if let Some(v) = new_entry { - kept.push(v); + if let Some(ref n) = new_entry { + kept.push(n); } - let array_value = Value::Array(kept); - let mut bytes = crate::proto::ctap2::cbor::to_vec(&array_value) - .map_err(|e| LargeBlobError::Corrupted(format!("array serialize: {e}")))?; - let hash = Sha256::digest(&bytes); - bytes.extend_from_slice(&hash[..LARGE_BLOB_HASH_LEN]); + let mut bytes = encode_array_header(kept.len()); + for element in &kept { + bytes.extend_from_slice(element); + } + bytes.extend_from_slice(&array_trailer(&bytes)); Ok(bytes) } -/// Fetch + parse the existing array. On trailer/parse failure, return empty per CTAP 2.2 §6.10.2. +/// Trailer mismatch yields the initial empty array. A valid-trailer parse error is propagated +/// (fail-safe: avoids clobbering a hash-valid foreign array). async fn fetch_or_initial( channel: &mut C, max_fragment: u32, timeout: Duration, -) -> Result, LargeBlobError> { +) -> Result, LargeBlobError> { let serialized = fetch_serialized_array(channel, max_fragment, timeout).await?; match strip_array_trailer(&serialized) { - Ok(array_bytes) => parse_large_blob_array_values(array_bytes), + Ok(array_bytes) => parse_array_raw_entries(array_bytes), Err(_) => { warn!("largeBlobArray trailer mismatch; treating as initial empty array (CTAP 2.2 §6.10.2)"); Ok(Vec::new()) @@ -522,7 +608,11 @@ async fn upload_serialized_array( let mut offset: u32 = 0; while (offset as usize) < serialized.len() { let end = (offset as usize + chunk_cap).min(serialized.len()); - let chunk = &serialized[offset as usize..end]; + let Some(chunk) = serialized.get(offset as usize..end) else { + return Err(LargeBlobError::Corrupted( + "chunk offset out of range".into(), + )); + }; let chunk_auth = match (&pin_uv_auth, &proto) { (Some((token, version)), Some(proto)) => { let param = large_blob_pin_uv_auth_param(token, proto.as_ref(), offset, chunk) @@ -576,9 +666,7 @@ pub(crate) async fn write_authenticator_large_blob( use rand::RngCore; rand::thread_rng().fill_bytes(&mut nonce); let entry_bytes = encrypt_entry(large_blob_key, &nonce, blob)?; - let new_entry: Value = crate::proto::ctap2::cbor::from_slice(&entry_bytes) - .map_err(|e| LargeBlobError::Corrupted(format!("entry parse: {e}")))?; - let serialized = rebuild_serialized_array(&existing, large_blob_key, Some(new_entry))?; + let serialized = rebuild_serialized_array(&existing, large_blob_key, Some(entry_bytes))?; upload_serialized_array(channel, &serialized, max_fragment, pin_uv_auth, timeout).await } @@ -592,9 +680,11 @@ pub(crate) async fn delete_authenticator_large_blob( timeout: Duration, ) -> Result<(), LargeBlobError> { let existing = fetch_or_initial(channel, max_fragment, timeout).await?; - let any_owned = existing - .iter() - .any(|e| entry_decrypts_under_key(e, large_blob_key)); + let any_owned = existing.iter().any(|e| { + e.value + .as_ref() + .is_some_and(|v| entry_decrypts_under_key(v, large_blob_key)) + }); if !any_owned { // Strict CTAP 2.2 §6.10.6 reading: no matching entry => error path (line 303). return Err(LargeBlobError::NoMatch); @@ -607,6 +697,13 @@ pub(crate) async fn delete_authenticator_large_blob( mod tests { use super::*; + fn raw_entry(bytes: &[u8]) -> RawArrayEntry { + RawArrayEntry { + raw: bytes.to_vec(), + value: crate::proto::ctap2::cbor::from_slice::(bytes).ok(), + } + } + #[test] fn max_fragment_uses_get_info_when_available() { assert_eq!(max_fragment_length(Some(2048)), 2048 - 64); @@ -1089,120 +1186,440 @@ mod tests { assert_eq!(blob(1).as_deref(), Some(pt1.as_slice())); } - /// Pseudo-random (incompressible) bytes, so the serialized array length is predictable. - fn incompressible(len: usize) -> Vec { - let mut state: u32 = 0x1234_5678; - (0..len) - .map(|_| { - state = state.wrapping_mul(1664525).wrapping_add(1013904223); - (state >> 24) as u8 - }) - .collect() + /// Spot-check the CTAP 2.2 §6.10.2 auth-param construction byte-for-byte: + /// the message MUST be `32×0xff || 0x0c, 0x00 || u32_le(offset) || SHA-256(chunk)`. + #[test] + fn large_blob_pin_uv_auth_param_matches_spec_message() { + use crate::pin::PinUvAuthProtocolTwo; + use hmac::Mac; + + let token = [0x11u8; 32]; + let chunk = b"some chunk bytes"; + let offset: u32 = 0x12345678; + + let proto = PinUvAuthProtocolTwo::new(); + let got = large_blob_pin_uv_auth_param(&token, &proto, offset, chunk).expect("auth_param"); + + let mut expected_msg = Vec::new(); + expected_msg.extend_from_slice(&[0xff; 32]); + expected_msg.extend_from_slice(&[0x0c, 0x00]); + expected_msg.extend_from_slice(&offset.to_le_bytes()); + expected_msg.extend_from_slice(&Sha256::digest(chunk)); + let mut mac = as hmac::Mac>::new_from_slice(&token).unwrap(); + mac.update(&expected_msg); + let expected = mac.finalize().into_bytes(); + + assert_eq!(got, expected.as_slice()); } - /// Serve `serialized` to `fetch_serialized_array` in `max_fragment`-sized get() fragments, - /// mirroring the device side: one command/response pair per fragment at increasing offsets, - /// plus the trailing empty get() when the length is an exact multiple. - fn serve_get_fragments( - channel: &mut crate::transport::mock::channel::MockChannel, - serialized: &[u8], - max_fragment: u32, - ) { - use crate::proto::ctap2::cbor::{to_vec, CborRequest, CborResponse}; - use crate::proto::ctap2::{Ctap2CommandCode, Ctap2LargeBlobsResponse}; - use serde_bytes::ByteBuf; + #[test] + fn entry_decrypts_under_key_matches_owned_entry() { + let key = [0x42u8; 32]; + let nonce = [0x07u8; 12]; + let entry_bytes = encrypt_entry(&key, &nonce, b"owned blob").unwrap(); + let entry: Value = crate::proto::ctap2::cbor::from_slice(&entry_bytes).unwrap(); + assert!(entry_decrypts_under_key(&entry, &key)); + } - let mf = max_fragment as usize; - let mut offset = 0usize; - loop { - let end = (offset + mf).min(serialized.len()); - let chunk = serialized[offset..end].to_vec(); - let chunk_len = chunk.len(); - let req = Ctap2LargeBlobsRequest::new_get(offset as u32, max_fragment); - let expected = CborRequest { - command: Ctap2CommandCode::AuthenticatorLargeBlobs, - encoded_data: to_vec(&req).unwrap(), - }; - let resp = Ctap2LargeBlobsResponse { - config: Some(ByteBuf::from(chunk)), - }; - channel.push_command_pair( - expected, - CborResponse::new_success_from_slice(&to_vec(&resp).unwrap()), - ); - offset = end; - if chunk_len < mf { - break; - } - } + #[test] + fn entry_decrypts_under_key_rejects_foreign_entry() { + let owner = [0xa1u8; 32]; + let other = [0xb2u8; 32]; + let nonce = [0x33u8; 12]; + let entry_bytes = encrypt_entry(&owner, &nonce, b"someone else's blob").unwrap(); + let entry: Value = crate::proto::ctap2::cbor::from_slice(&entry_bytes).unwrap(); + assert!(!entry_decrypts_under_key(&entry, &other)); } - #[tokio::test] - async fn fetch_reassembles_multi_fragment_read_with_short_final_fragment() { - use crate::transport::mock::channel::MockChannel; + #[test] + fn entry_decrypts_under_key_rejects_non_map() { + let v = Value::Text("not a map".into()); + assert!(!entry_decrypts_under_key(&v, &[0u8; 32])); + } - const MF: u32 = 32; - let key = [0xC0u8; 32]; - let nonce = [0x11u8; 12]; - let plaintext = incompressible(31); - let entry = encrypt_entry(&key, &nonce, &plaintext).unwrap(); - let serialized = build_serialized_array(&[entry]); - assert!( - serialized.len() > 2 * MF as usize, - "should span several fragments" - ); - assert_ne!( - serialized.len() % MF as usize, - 0, - "final fragment must be shorter than max_fragment" + #[test] + fn rebuild_appends_and_drops_only_owned() { + let owner_a = [0xa1u8; 32]; + let owner_b = [0xb2u8; 32]; + let nonce = [0x55u8; 12]; + let entry_a = encrypt_entry(&owner_a, &nonce, b"alpha").unwrap(); + let entry_b = encrypt_entry(&owner_b, &nonce, b"bravo").unwrap(); + + let new_entry = encrypt_entry(&owner_a, &[0x99u8; 12], b"alpha v2").unwrap(); + + let rebuilt = rebuild_serialized_array( + &[raw_entry(&entry_a), raw_entry(&entry_b)], + &owner_a, + Some(new_entry), + ) + .unwrap(); + + let array_bytes = strip_array_trailer(&rebuilt).unwrap(); + let parsed = parse_array_raw_entries(array_bytes).unwrap(); + assert_eq!( + parsed.len(), + 2, + "owner_b entry kept + new owner_a entry appended" ); + assert!(entry_decrypts_under_key( + parsed[0].value.as_ref().unwrap(), + &owner_b + )); + assert!(entry_decrypts_under_key( + parsed[1].value.as_ref().unwrap(), + &owner_a + )); + } - let mut channel = MockChannel::new(); - serve_get_fragments(&mut channel, &serialized, MF); + #[test] + fn rebuild_delete_drops_only_owned() { + let owner_a = [0xa1u8; 32]; + let owner_b = [0xb2u8; 32]; + let nonce = [0x55u8; 12]; + let entry_a = encrypt_entry(&owner_a, &nonce, b"alpha").unwrap(); + let entry_b = encrypt_entry(&owner_b, &nonce, b"bravo").unwrap(); - let entries = fetch_large_blob_entries(&mut channel, MF, Duration::from_secs(5)) - .await - .expect("fetch"); - let got = decrypt_first_matching(&entries, &key).expect("decrypt"); - assert_eq!(got.as_deref(), Some(plaintext.as_slice())); + let rebuilt = + rebuild_serialized_array(&[raw_entry(&entry_a), raw_entry(&entry_b)], &owner_a, None) + .unwrap(); + let array_bytes = strip_array_trailer(&rebuilt).unwrap(); + let parsed = parse_array_raw_entries(array_bytes).unwrap(); + assert_eq!(parsed.len(), 1); + assert!(entry_decrypts_under_key( + parsed[0].value.as_ref().unwrap(), + &owner_b + )); } - #[tokio::test] - async fn fetch_reassembles_exact_multiple_read_via_trailing_empty_get() { - use crate::transport::mock::channel::MockChannel; + /// Delete with no matching entry is a no-op: array returns unchanged (+ valid trailer). + #[test] + fn rebuild_delete_no_match_is_noop() { + let owner_a = [0xa1u8; 32]; + let owner_b = [0xb2u8; 32]; + let nonce = [0x55u8; 12]; + let entry_b = encrypt_entry(&owner_b, &nonce, b"bravo").unwrap(); + let rebuilt = rebuild_serialized_array(&[raw_entry(&entry_b)], &owner_a, None).unwrap(); + let array_bytes = strip_array_trailer(&rebuilt).unwrap(); + let parsed = parse_array_raw_entries(array_bytes).unwrap(); + assert_eq!(parsed.len(), 1); + assert!(entry_decrypts_under_key( + parsed[0].value.as_ref().unwrap(), + &owner_b + )); + } - const MF: u32 = 32; - let key = [0xD0u8; 32]; - let nonce = [0x22u8; 12]; - let plaintext = incompressible(37); - let entry = encrypt_entry(&key, &nonce, &plaintext).unwrap(); - let serialized = build_serialized_array(&[entry]); - assert!( - serialized.len() > 2 * MF as usize, - "should span several fragments" + /// Foreign entries with unknown CBOR fields must round-trip unmodified through RMW. + #[test] + fn rebuild_preserves_unknown_fields_in_foreign_entries() { + let owner_a = [0xa1u8; 32]; + let owner_b = [0xb2u8; 32]; + + let entry_a_bytes = encrypt_entry(&owner_a, &[0x55u8; 12], b"alpha").unwrap(); + + // Foreign entry under owner_b carrying an extra (future) key 0x07. + let entry_b_base = encrypt_entry(&owner_b, &[0x66u8; 12], b"bravo").unwrap(); + let Value::Map(mut map_b) = crate::proto::ctap2::cbor::from_slice(&entry_b_base).unwrap() + else { + panic!("entry_b is a map"); + }; + map_b.insert(Value::Integer(0x07), Value::Text("future field".into())); + // Non-canonical indefinite-length map: decodes to the same Value but re-encodes to a + // definite-length map, so this fixture only round-trips byte-for-byte under the raw splice. + let canonical = crate::proto::ctap2::cbor::to_vec(&Value::Map(map_b)).unwrap(); + let mut entry_b_bytes = vec![0xBF]; + entry_b_bytes.extend_from_slice(&canonical[1..]); + entry_b_bytes.push(0xFF); + + let new_entry = encrypt_entry(&owner_a, &[0x99u8; 12], b"alpha v2").unwrap(); + + let rebuilt = rebuild_serialized_array( + &[raw_entry(&entry_a_bytes), raw_entry(&entry_b_bytes)], + &owner_a, + Some(new_entry), + ) + .unwrap(); + let array_bytes = strip_array_trailer(&rebuilt).unwrap(); + let parsed = parse_array_raw_entries(array_bytes).unwrap(); + assert_eq!(parsed.len(), 2); + assert_eq!( + parsed[0].raw, entry_b_bytes, + "foreign entry preserved byte-for-byte" ); + let Value::Map(map_b) = parsed[0].value.as_ref().unwrap() else { + panic!("kept_b is a map"); + }; assert_eq!( - serialized.len() % MF as usize, - 0, - "exact multiple: loop terminates on a trailing empty get" + map_b.get(&Value::Integer(0x07)), + Some(&Value::Text("future field".into())), + "unknown field 0x07 preserved" ); + } - let mut channel = MockChannel::new(); - serve_get_fragments(&mut channel, &serialized, MF); - - let entries = fetch_large_blob_entries(&mut channel, MF, Duration::from_secs(5)) - .await - .expect("fetch"); - let got = decrypt_first_matching(&entries, &key).expect("decrypt"); - assert_eq!(got.as_deref(), Some(plaintext.as_slice())); + #[test] + fn parse_array_raw_entries_rejects_hostile_headers() { + let e0 = encrypt_entry(&[0x01u8; 32], &[0u8; 12], b"a").unwrap(); + let e1 = encrypt_entry(&[0x02u8; 32], &[0u8; 12], b"b").unwrap(); + let mut canonical = encode_array_header(2); + canonical.extend_from_slice(&e0); + canonical.extend_from_slice(&e1); + assert_eq!(parse_array_raw_entries(&canonical).unwrap().len(), 2); + + // Huge declared count (array(2^32-1)) with no element bytes: bounded alloc, errors fast. + assert!(parse_array_raw_entries(&[0x9a, 0xff, 0xff, 0xff, 0xff]).is_err()); + + // Valid array plus one trailing byte: rejected by the full-consumption check. + let mut trailing = canonical.clone(); + trailing.push(0x00); + assert!(parse_array_raw_entries(&trailing).is_err()); + + // Header count smaller than the actual element bytes: rejected. + let mut short = encode_array_header(1); + short.extend_from_slice(&e0); + short.extend_from_slice(&e1); + assert!(parse_array_raw_entries(&short).is_err()); } - /// `try_decrypt` rejects an oversize `origSize` (skip) and a decompression bomb (Corrupted). #[test] - fn try_decrypt_enforces_orig_size_and_inflation_caps() { - use flate2::write::DeflateEncoder; - use flate2::Compression; - use std::io::Write; + fn rebuild_meets_minimum_17_bytes_when_empty() { + // CTAP 2.2 §6.10.2: serialized array length MUST be >= 17. + let rebuilt = rebuild_serialized_array(&[], &[0u8; 32], None).unwrap(); + assert!(rebuilt.len() >= 17); + // Empty array: 0x80 (1 byte) + 16-byte trailer = 17 bytes. + assert_eq!(rebuilt.len(), 17); + assert_eq!(rebuilt[0], 0x80); + } + + /// CTAP 2.2 §6.10 spec text: "The initial serialized large-blob array ... is the byte string + /// `h'8076be8b528d0075f7aae98d6fa57a6d3c'`". Asserting byte-for-byte locks our canonical CBOR + /// emission against future serializer drift. + #[test] + fn rebuild_empty_array_matches_spec_initial_bytes() { + let rebuilt = rebuild_serialized_array(&[], &[0u8; 32], None).unwrap(); + assert_eq!(hex::encode(&rebuilt), "8076be8b528d0075f7aae98d6fa57a6d3c"); + } + + /// `upload_serialized_array` issues set_first with the precise pinUvAuthParam derived per CTAP 2.2 §6.10.2. + #[tokio::test] + async fn upload_single_chunk_uses_set_first_with_correct_auth_param() { + use crate::pin::{PinUvAuthProtocol, PinUvAuthProtocolTwo}; + use crate::proto::ctap2::cbor::{CborRequest, CborResponse}; + use crate::proto::ctap2::{Ctap2CommandCode, Ctap2LargeBlobsResponse}; + use crate::transport::mock::channel::MockChannel; + + let key = [0xC0u8; 32]; + let token = [0x11u8; 32]; + let proto = PinUvAuthProtocolTwo::new(); + let plaintext = b"round-trip blob".to_vec(); + + let nonce = [0x07u8; 12]; + let entry_bytes = encrypt_entry(&key, &nonce, &plaintext).unwrap(); + let serialized = rebuild_serialized_array(&[], &key, Some(entry_bytes)).unwrap(); + assert!( + serialized.len() <= LARGE_BLOB_DEFAULT_FRAGMENT as usize, + "test fixture must fit in one chunk" + ); + + let auth_param = + large_blob_pin_uv_auth_param(&token, &proto, 0, &serialized).expect("auth_param"); + let set_req = Ctap2LargeBlobsRequest::new_set_first( + serialized.clone(), + serialized.len() as u32, + Some((auth_param, proto.version() as u32)), + ); + let mut channel = MockChannel::new(); + channel.push_command_pair( + CborRequest { + command: Ctap2CommandCode::AuthenticatorLargeBlobs, + encoded_data: crate::proto::ctap2::cbor::to_vec(&set_req).unwrap(), + }, + CborResponse::new_success_from_slice( + &crate::proto::ctap2::cbor::to_vec(&Ctap2LargeBlobsResponse { config: None }) + .unwrap(), + ), + ); + + upload_serialized_array( + &mut channel, + &serialized, + LARGE_BLOB_DEFAULT_FRAGMENT, + Some((&token, Ctap2PinUvAuthProtocol::Two)), + Duration::from_secs(5), + ) + .await + .expect("upload"); + } + + #[tokio::test] + async fn upload_chunks_when_array_exceeds_max_fragment() { + use crate::pin::{PinUvAuthProtocol, PinUvAuthProtocolTwo}; + use crate::proto::ctap2::cbor::{CborRequest, CborResponse}; + use crate::proto::ctap2::{Ctap2CommandCode, Ctap2LargeBlobsResponse}; + use crate::transport::mock::channel::MockChannel; + + let token = [0x22u8; 32]; + let proto = PinUvAuthProtocolTwo::new(); + // Small max_fragment to force chunking with a small payload. + const MF: u32 = 32; + // Build a synthetic 70-byte "serialized array" (the helpers only check length >= 17). + let serialized: Vec = (0u8..70).collect(); + assert_eq!(serialized.len(), 70); + + let mut channel = MockChannel::new(); + // Chunks: 0..32, 32..64, 64..70. Three calls. + for (offset, chunk_len) in [(0u32, 32), (32u32, 32), (64u32, 6)] { + let chunk = serialized[offset as usize..(offset as usize + chunk_len)].to_vec(); + let auth_param = + large_blob_pin_uv_auth_param(&token, &proto, offset, &chunk).expect("auth_param"); + let req = if offset == 0 { + Ctap2LargeBlobsRequest::new_set_first( + chunk, + 70, + Some((auth_param, proto.version() as u32)), + ) + } else { + Ctap2LargeBlobsRequest::new_set_continuation( + chunk, + offset, + Some((auth_param, proto.version() as u32)), + ) + }; + channel.push_command_pair( + CborRequest { + command: Ctap2CommandCode::AuthenticatorLargeBlobs, + encoded_data: crate::proto::ctap2::cbor::to_vec(&req).unwrap(), + }, + CborResponse::new_success_from_slice( + &crate::proto::ctap2::cbor::to_vec(&Ctap2LargeBlobsResponse { config: None }) + .unwrap(), + ), + ); + } + + upload_serialized_array( + &mut channel, + &serialized, + MF, + Some((&token, Ctap2PinUvAuthProtocol::Two)), + Duration::from_secs(5), + ) + .await + .expect("chunked upload"); + } + + /// Pseudo-random (incompressible) bytes, so the serialized array length is predictable. + fn incompressible(len: usize) -> Vec { + let mut state: u32 = 0x1234_5678; + (0..len) + .map(|_| { + state = state.wrapping_mul(1664525).wrapping_add(1013904223); + (state >> 24) as u8 + }) + .collect() + } + + /// Serve `serialized` to `fetch_serialized_array` in `max_fragment`-sized get() fragments, + /// mirroring the device side: one command/response pair per fragment at increasing offsets, + /// plus the trailing empty get() when the length is an exact multiple. + fn serve_get_fragments( + channel: &mut crate::transport::mock::channel::MockChannel, + serialized: &[u8], + max_fragment: u32, + ) { + use crate::proto::ctap2::cbor::{to_vec, CborRequest, CborResponse}; + use crate::proto::ctap2::{Ctap2CommandCode, Ctap2LargeBlobsResponse}; + use serde_bytes::ByteBuf; + + let mf = max_fragment as usize; + let mut offset = 0usize; + loop { + let end = (offset + mf).min(serialized.len()); + let chunk = serialized[offset..end].to_vec(); + let chunk_len = chunk.len(); + let req = Ctap2LargeBlobsRequest::new_get(offset as u32, max_fragment); + let expected = CborRequest { + command: Ctap2CommandCode::AuthenticatorLargeBlobs, + encoded_data: to_vec(&req).unwrap(), + }; + let resp = Ctap2LargeBlobsResponse { + config: Some(ByteBuf::from(chunk)), + }; + channel.push_command_pair( + expected, + CborResponse::new_success_from_slice(&to_vec(&resp).unwrap()), + ); + offset = end; + if chunk_len < mf { + break; + } + } + } + + #[tokio::test] + async fn fetch_reassembles_multi_fragment_read_with_short_final_fragment() { + use crate::transport::mock::channel::MockChannel; + + const MF: u32 = 32; + let key = [0xC0u8; 32]; + let nonce = [0x11u8; 12]; + let plaintext = incompressible(31); + let entry = encrypt_entry(&key, &nonce, &plaintext).unwrap(); + let serialized = build_serialized_array(&[entry]); + assert!( + serialized.len() > 2 * MF as usize, + "should span several fragments" + ); + assert_ne!( + serialized.len() % MF as usize, + 0, + "final fragment must be shorter than max_fragment" + ); + + let mut channel = MockChannel::new(); + serve_get_fragments(&mut channel, &serialized, MF); + + let entries = fetch_large_blob_entries(&mut channel, MF, Duration::from_secs(5)) + .await + .expect("fetch"); + let got = decrypt_first_matching(&entries, &key).expect("decrypt"); + assert_eq!(got.as_deref(), Some(plaintext.as_slice())); + } + + #[tokio::test] + async fn fetch_reassembles_exact_multiple_read_via_trailing_empty_get() { + use crate::transport::mock::channel::MockChannel; + + const MF: u32 = 32; + let key = [0xD0u8; 32]; + let nonce = [0x22u8; 12]; + let plaintext = incompressible(37); + let entry = encrypt_entry(&key, &nonce, &plaintext).unwrap(); + let serialized = build_serialized_array(&[entry]); + assert!( + serialized.len() > 2 * MF as usize, + "should span several fragments" + ); + assert_eq!( + serialized.len() % MF as usize, + 0, + "exact multiple: loop terminates on a trailing empty get" + ); + + let mut channel = MockChannel::new(); + serve_get_fragments(&mut channel, &serialized, MF); + + let entries = fetch_large_blob_entries(&mut channel, MF, Duration::from_secs(5)) + .await + .expect("fetch"); + let got = decrypt_first_matching(&entries, &key).expect("decrypt"); + assert_eq!(got.as_deref(), Some(plaintext.as_slice())); + } + + /// `try_decrypt` rejects an oversize `origSize` (skip) and a decompression bomb (Corrupted). + #[test] + fn try_decrypt_enforces_orig_size_and_inflation_caps() { + use flate2::write::DeflateEncoder; + use flate2::Compression; + use std::io::Write; let key = [0x42u8; 32]; @@ -1276,7 +1693,6 @@ mod tests { /// compressed bytes via AES-256-GCM and confirm they inflate raw yet are not a zlib stream. #[test] fn encrypt_entry_uses_raw_deflate_not_zlib() { - use crate::proto::ctap2::cbor::Value; use flate2::read::ZlibDecoder; let key = [0x5Au8; 32]; @@ -1352,287 +1768,161 @@ mod tests { compressed[0], 0x78, "raw DEFLATE must not begin with a zlib CMF byte" ); - /// Spot-check the CTAP 2.2 §6.10.2 auth-param construction byte-for-byte: - /// the message MUST be `32×0xff || 0x0c, 0x00 || u32_le(offset) || SHA-256(chunk)`. - #[test] - fn large_blob_pin_uv_auth_param_matches_spec_message() { - use crate::pin::PinUvAuthProtocolTwo; - use hmac::Mac; - - let token = [0x11u8; 32]; - let chunk = b"some chunk bytes"; - let offset: u32 = 0x12345678; - - let proto = PinUvAuthProtocolTwo::new(); - let got = large_blob_pin_uv_auth_param(&token, &proto, offset, chunk).expect("auth_param"); - - let mut expected_msg = Vec::new(); - expected_msg.extend_from_slice(&[0xff; 32]); - expected_msg.extend_from_slice(&[0x0c, 0x00]); - expected_msg.extend_from_slice(&offset.to_le_bytes()); - expected_msg.extend_from_slice(&Sha256::digest(chunk)); - let mut mac = as hmac::Mac>::new_from_slice(&token).unwrap(); - mac.update(&expected_msg); - let expected = mac.finalize().into_bytes(); - - assert_eq!(got, expected.as_slice()); - } - - #[test] - fn entry_decrypts_under_key_matches_owned_entry() { - let key = [0x42u8; 32]; - let nonce = [0x07u8; 12]; - let entry_bytes = encrypt_entry(&key, &nonce, b"owned blob").unwrap(); - let entry: Value = crate::proto::ctap2::cbor::from_slice(&entry_bytes).unwrap(); - assert!(entry_decrypts_under_key(&entry, &key)); - } - - #[test] - fn entry_decrypts_under_key_rejects_foreign_entry() { - let owner = [0xa1u8; 32]; - let other = [0xb2u8; 32]; - let nonce = [0x33u8; 12]; - let entry_bytes = encrypt_entry(&owner, &nonce, b"someone else's blob").unwrap(); - let entry: Value = crate::proto::ctap2::cbor::from_slice(&entry_bytes).unwrap(); - assert!(!entry_decrypts_under_key(&entry, &other)); - } - - #[test] - fn entry_decrypts_under_key_rejects_non_map() { - let v = Value::Text("not a map".into()); - assert!(!entry_decrypts_under_key(&v, &[0u8; 32])); } - #[test] - fn rebuild_appends_and_drops_only_owned() { - let owner_a = [0xa1u8; 32]; - let owner_b = [0xb2u8; 32]; - let nonce = [0x55u8; 12]; - let entry_a = encrypt_entry(&owner_a, &nonce, b"alpha").unwrap(); - let entry_b = encrypt_entry(&owner_b, &nonce, b"bravo").unwrap(); - let entry_a_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_a).unwrap(); - let entry_b_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_b).unwrap(); - - let new_entry_bytes = encrypt_entry(&owner_a, &[0x99u8; 12], b"alpha v2").unwrap(); - let new_entry: Value = crate::proto::ctap2::cbor::from_slice(&new_entry_bytes).unwrap(); - - let rebuilt = - rebuild_serialized_array(&[entry_a_v, entry_b_v.clone()], &owner_a, Some(new_entry)) - .unwrap(); - - let array_bytes = strip_array_trailer(&rebuilt).unwrap(); - let parsed = parse_large_blob_array_values(array_bytes).unwrap(); - assert_eq!( - parsed.len(), - 2, - "owner_b entry kept + new owner_a entry appended" - ); - assert!(entry_decrypts_under_key(&parsed[0], &owner_b)); - assert!(entry_decrypts_under_key(&parsed[1], &owner_a)); - } - - #[test] - fn rebuild_delete_drops_only_owned() { - let owner_a = [0xa1u8; 32]; - let owner_b = [0xb2u8; 32]; - let nonce = [0x55u8; 12]; - let entry_a = encrypt_entry(&owner_a, &nonce, b"alpha").unwrap(); - let entry_b = encrypt_entry(&owner_b, &nonce, b"bravo").unwrap(); - let entry_a_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_a).unwrap(); - let entry_b_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_b).unwrap(); + /// Fixed-nonce reuse is catastrophic for AES-GCM. Two writes of the same blob + /// under the same key MUST emit distinct nonces. + #[tokio::test] + async fn each_write_uses_a_distinct_nonce() { + use crate::proto::ctap2::{ + Ctap2AuthenticatorConfigRequest, Ctap2BioEnrollmentRequest, Ctap2BioEnrollmentResponse, + Ctap2ClientPinRequest, Ctap2ClientPinResponse, Ctap2CredentialManagementRequest, + Ctap2CredentialManagementResponse, Ctap2GetAssertionRequest, Ctap2GetAssertionResponse, + Ctap2GetInfoResponse, Ctap2LargeBlobsRequest, Ctap2LargeBlobsResponse, + Ctap2MakeCredentialRequest, Ctap2MakeCredentialResponse, + }; - let rebuilt = rebuild_serialized_array(&[entry_a_v, entry_b_v], &owner_a, None).unwrap(); - let array_bytes = strip_array_trailer(&rebuilt).unwrap(); - let parsed = parse_large_blob_array_values(array_bytes).unwrap(); - assert_eq!(parsed.len(), 1); - assert!(entry_decrypts_under_key(&parsed[0], &owner_b)); - } + // Records each uploaded array; get() replays the most recent one (RMW round-trip). + struct RecordingChannel { + current: Vec, + sets: Vec>, + } - /// Delete with no matching entry is a no-op: array returns unchanged (+ valid trailer). - #[test] - fn rebuild_delete_no_match_is_noop() { - let owner_a = [0xa1u8; 32]; - let owner_b = [0xb2u8; 32]; - let nonce = [0x55u8; 12]; - let entry_b = encrypt_entry(&owner_b, &nonce, b"bravo").unwrap(); - let entry_b_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_b).unwrap(); - let rebuilt = rebuild_serialized_array(&[entry_b_v], &owner_a, None).unwrap(); - let array_bytes = strip_array_trailer(&rebuilt).unwrap(); - let parsed = parse_large_blob_array_values(array_bytes).unwrap(); - assert_eq!(parsed.len(), 1); - assert!(entry_decrypts_under_key(&parsed[0], &owner_b)); - } + #[async_trait::async_trait] + impl Ctap2 for RecordingChannel { + async fn ctap2_large_blobs( + &mut self, + request: &Ctap2LargeBlobsRequest, + _timeout: Duration, + ) -> Result { + if request.get.is_some() { + Ok(Ctap2LargeBlobsResponse { + config: Some(serde_bytes::ByteBuf::from(self.current.clone())), + }) + } else if let Some(set) = request.set.as_ref() { + self.current = set.to_vec(); + self.sets.push(set.to_vec()); + Ok(Ctap2LargeBlobsResponse::default()) + } else { + panic!("largeBlobs request was neither get nor set"); + } + } + async fn ctap2_get_info(&mut self) -> Result { + unimplemented!() + } + async fn ctap2_make_credential( + &mut self, + _r: &Ctap2MakeCredentialRequest, + _t: Duration, + ) -> Result { + unimplemented!() + } + async fn ctap2_client_pin( + &mut self, + _r: &Ctap2ClientPinRequest, + _t: Duration, + ) -> Result { + unimplemented!() + } + async fn ctap2_get_assertion( + &mut self, + _r: &Ctap2GetAssertionRequest, + _t: Duration, + ) -> Result { + unimplemented!() + } + async fn ctap2_get_next_assertion( + &mut self, + _t: Duration, + ) -> Result { + unimplemented!() + } + async fn ctap2_selection(&mut self, _t: Duration) -> Result<(), Error> { + unimplemented!() + } + async fn ctap2_authenticator_config( + &mut self, + _r: &Ctap2AuthenticatorConfigRequest, + _t: Duration, + ) -> Result<(), Error> { + unimplemented!() + } + async fn ctap2_bio_enrollment( + &mut self, + _r: &Ctap2BioEnrollmentRequest, + _t: Duration, + ) -> Result { + unimplemented!() + } + async fn ctap2_credential_management( + &mut self, + _r: &Ctap2CredentialManagementRequest, + _t: Duration, + ) -> Result { + unimplemented!() + } + } - /// Foreign entries with unknown CBOR fields must round-trip unmodified through RMW. - #[test] - fn rebuild_preserves_unknown_fields_in_foreign_entries() { - use std::collections::BTreeMap; - let owner_a = [0xa1u8; 32]; - let owner_b = [0xb2u8; 32]; + fn entry_nonce(serialized: &[u8]) -> Vec { + let array_bytes = strip_array_trailer(serialized).expect("trailer"); + let parsed = parse_large_blob_array(array_bytes).expect("parse"); + assert_eq!(parsed.len(), 1, "exactly one entry per write"); + parsed[0].nonce.clone() + } - let entry_a_bytes = encrypt_entry(&owner_a, &[0x55u8; 12], b"alpha").unwrap(); - let entry_a_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_a_bytes).unwrap(); + let key = [0x5Au8; 32]; + let blob = b"distinct-nonce blob".to_vec(); - // Construct a "future fields" entry encrypted under owner_b with an extra key 0x07. - let entry_b_base = encrypt_entry(&owner_b, &[0x66u8; 12], b"bravo").unwrap(); - let entry_b_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_b_base).unwrap(); - let Value::Map(mut map_b) = entry_b_v else { - panic!("entry_b is a map"); + let mut channel = RecordingChannel { + current: build_serialized_array(&[]), + sets: Vec::new(), }; - map_b.insert(Value::Integer(0x07), Value::Text("future field".into())); - let entry_b_v = Value::Map(map_b); - // Re-extract the unknown-field marker for later inspection. - let original_b_clone = entry_b_v.clone(); - - let new_entry_bytes = encrypt_entry(&owner_a, &[0x99u8; 12], b"alpha v2").unwrap(); - let new_entry: Value = crate::proto::ctap2::cbor::from_slice(&new_entry_bytes).unwrap(); + for _ in 0..2 { + write_authenticator_large_blob( + &mut channel, + &key, + &blob, + LARGE_BLOB_DEFAULT_FRAGMENT, + None, + Duration::from_secs(5), + ) + .await + .expect("write should succeed"); + } - let rebuilt = - rebuild_serialized_array(&[entry_a_v, entry_b_v], &owner_a, Some(new_entry)).unwrap(); - let array_bytes = strip_array_trailer(&rebuilt).unwrap(); - let parsed = parse_large_blob_array_values(array_bytes).unwrap(); - assert_eq!(parsed.len(), 2); - let kept_b = &parsed[0]; - assert_eq!( - kept_b, &original_b_clone, - "foreign entry preserved verbatim" - ); - let Value::Map(map_b) = kept_b else { - panic!("kept_b is a map"); - }; - let _ = BTreeMap::<&Value, &Value>::from_iter(map_b.iter()); - assert_eq!( - map_b.get(&Value::Integer(0x07)), - Some(&Value::Text("future field".into())), - "unknown field 0x07 preserved" + assert_eq!(channel.sets.len(), 2, "each write is a single-chunk set()"); + let nonce_a = entry_nonce(&channel.sets[0]); + let nonce_b = entry_nonce(&channel.sets[1]); + assert_eq!(nonce_a.len(), LARGE_BLOB_NONCE_LEN); + assert_eq!(nonce_b.len(), LARGE_BLOB_NONCE_LEN); + assert_ne!( + nonce_a, nonce_b, + "each write must generate a fresh AES-GCM nonce" ); } #[test] - fn rebuild_meets_minimum_17_bytes_when_empty() { - // CTAP 2.2 §6.10.2: serialized array length MUST be >= 17. - let rebuilt = rebuild_serialized_array(&[], &[0u8; 32], None).unwrap(); - assert!(rebuilt.len() >= 17); - // Empty array: 0x80 (1 byte) + 16-byte trailer = 17 bytes. - assert_eq!(rebuilt.len(), 17); - assert_eq!(rebuilt[0], 0x80); - } - - /// CTAP 2.2 §6.10 spec text: "The initial serialized large-blob array ... is the byte string - /// `h'8076be8b528d0075f7aae98d6fa57a6d3c'`". Asserting byte-for-byte locks our canonical CBOR - /// emission against future serializer drift. - #[test] - fn rebuild_empty_array_matches_spec_initial_bytes() { - let rebuilt = rebuild_serialized_array(&[], &[0u8; 32], None).unwrap(); - assert_eq!(hex::encode(&rebuilt), "8076be8b528d0075f7aae98d6fa57a6d3c"); - } - - /// `upload_serialized_array` issues set_first with the precise pinUvAuthParam derived per CTAP 2.2 §6.10.2. - #[tokio::test] - async fn upload_single_chunk_uses_set_first_with_correct_auth_param() { - use crate::pin::{PinUvAuthProtocol, PinUvAuthProtocolTwo}; - use crate::proto::ctap2::cbor::{CborRequest, CborResponse}; - use crate::proto::ctap2::{Ctap2CommandCode, Ctap2LargeBlobsResponse}; - use crate::transport::mock::channel::MockChannel; + fn large_blob_pin_uv_auth_param_protocol_one_truncates_to_16() { + use crate::pin::PinUvAuthProtocolOne; + use hmac::Mac; - let key = [0xC0u8; 32]; let token = [0x11u8; 32]; - let proto = PinUvAuthProtocolTwo::new(); - let plaintext = b"round-trip blob".to_vec(); - - let nonce = [0x07u8; 12]; - let entry_bytes = encrypt_entry(&key, &nonce, &plaintext).unwrap(); - let entry_v: Value = crate::proto::ctap2::cbor::from_slice(&entry_bytes).unwrap(); - let serialized = rebuild_serialized_array(&[], &key, Some(entry_v)).unwrap(); - assert!( - serialized.len() <= LARGE_BLOB_DEFAULT_FRAGMENT as usize, - "test fixture must fit in one chunk" - ); - - let auth_param = - large_blob_pin_uv_auth_param(&token, &proto, 0, &serialized).expect("auth_param"); - let set_req = Ctap2LargeBlobsRequest::new_set_first( - serialized.clone(), - serialized.len() as u32, - Some((auth_param, proto.version() as u32)), - ); - let mut channel = MockChannel::new(); - channel.push_command_pair( - CborRequest { - command: Ctap2CommandCode::AuthenticatorLargeBlobs, - encoded_data: crate::proto::ctap2::cbor::to_vec(&set_req).unwrap(), - }, - CborResponse::new_success_from_slice( - &crate::proto::ctap2::cbor::to_vec(&Ctap2LargeBlobsResponse { config: None }) - .unwrap(), - ), - ); - - upload_serialized_array( - &mut channel, - &serialized, - LARGE_BLOB_DEFAULT_FRAGMENT, - Some((&token, Ctap2PinUvAuthProtocol::Two)), - Duration::from_secs(5), - ) - .await - .expect("upload"); - } - - #[tokio::test] - async fn upload_chunks_when_array_exceeds_max_fragment() { - use crate::pin::{PinUvAuthProtocol, PinUvAuthProtocolTwo}; - use crate::proto::ctap2::cbor::{CborRequest, CborResponse}; - use crate::proto::ctap2::{Ctap2CommandCode, Ctap2LargeBlobsResponse}; - use crate::transport::mock::channel::MockChannel; + let chunk = b"some chunk bytes"; + let offset: u32 = 0x12345678; - let token = [0x22u8; 32]; - let proto = PinUvAuthProtocolTwo::new(); - // Small max_fragment to force chunking with a small payload. - const MF: u32 = 32; - // Build a synthetic 70-byte "serialized array" (the helpers only check length >= 17). - let serialized: Vec = (0u8..70).collect(); - assert_eq!(serialized.len(), 70); + let proto = PinUvAuthProtocolOne::new(); + let got = large_blob_pin_uv_auth_param(&token, &proto, offset, chunk).expect("auth_param"); - let mut channel = MockChannel::new(); - // Chunks: 0..32, 32..64, 64..70. Three calls. - for (offset, chunk_len) in [(0u32, 32), (32u32, 32), (64u32, 6)] { - let chunk = serialized[offset as usize..(offset as usize + chunk_len)].to_vec(); - let auth_param = - large_blob_pin_uv_auth_param(&token, &proto, offset, &chunk).expect("auth_param"); - let req = if offset == 0 { - Ctap2LargeBlobsRequest::new_set_first( - chunk, - 70, - Some((auth_param, proto.version() as u32)), - ) - } else { - Ctap2LargeBlobsRequest::new_set_continuation( - chunk, - offset, - Some((auth_param, proto.version() as u32)), - ) - }; - channel.push_command_pair( - CborRequest { - command: Ctap2CommandCode::AuthenticatorLargeBlobs, - encoded_data: crate::proto::ctap2::cbor::to_vec(&req).unwrap(), - }, - CborResponse::new_success_from_slice( - &crate::proto::ctap2::cbor::to_vec(&Ctap2LargeBlobsResponse { config: None }) - .unwrap(), - ), - ); - } + let mut expected_msg = Vec::new(); + expected_msg.extend_from_slice(&[0xff; 32]); + expected_msg.extend_from_slice(&[0x0c, 0x00]); + expected_msg.extend_from_slice(&offset.to_le_bytes()); + expected_msg.extend_from_slice(&Sha256::digest(chunk)); + let mut mac = as hmac::Mac>::new_from_slice(&token).unwrap(); + mac.update(&expected_msg); + let full = mac.finalize().into_bytes(); - upload_serialized_array( - &mut channel, - &serialized, - MF, - Some((&token, Ctap2PinUvAuthProtocol::Two)), - Duration::from_secs(5), - ) - .await - .expect("chunked upload"); + assert_eq!(got, full[..16]); + assert_eq!(got.len(), 16); } } diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index 55af71ef..75f18a03 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -24,9 +24,8 @@ use crate::ops::u2f::{RegisterRequest, SignRequest, UpgradableResponse}; use crate::ops::webauthn::{ decrypt_first_matching, delete_authenticator_large_blob, fetch_large_blob_entries, max_fragment_length, write_authenticator_large_blob, DowngradableRequest, - GetAssertionLargeBlobExtension, - GetAssertionLargeBlobExtensionOutput, GetAssertionRequest, GetAssertionResponse, - GetAssertionResponseUnsignedExtensions, UserVerificationRequirement, + GetAssertionLargeBlobExtension, GetAssertionLargeBlobExtensionOutput, GetAssertionRequest, + GetAssertionResponse, GetAssertionResponseUnsignedExtensions, UserVerificationRequirement, }; use crate::ops::webauthn::{MakeCredentialRequest, MakeCredentialResponse}; use crate::proto::ctap1::Ctap1; @@ -383,7 +382,10 @@ async fn get_assertion_fido2( }, _ => None, }; - GetAssertionLargeBlobExtensionOutput { blob, written: None } + GetAssertionLargeBlobExtensionOutput { + blob, + written: None, + } }) .collect::>() } diff --git a/libwebauthn/src/webauthn/pin_uv_auth_token.rs b/libwebauthn/src/webauthn/pin_uv_auth_token.rs index 85c68afc..4f63fbf0 100644 --- a/libwebauthn/src/webauthn/pin_uv_auth_token.rs +++ b/libwebauthn/src/webauthn/pin_uv_auth_token.rs @@ -600,8 +600,8 @@ mod test { use crate::{ ops::webauthn::{ - GetAssertionRequest, GetAssertionRequestExtensions, PrfInput, PrfInputValue, - UserVerificationRequirement, + GetAssertionLargeBlobExtension, GetAssertionRequest, GetAssertionRequestExtensions, + PrfInput, PrfInputValue, UserVerificationRequirement, }, pin::persistent_token::{ build_enc_identifier, MemoryPersistentTokenStore, PersistentTokenRecord, @@ -2074,4 +2074,136 @@ mod test { // The connected device's record is evicted after a successful PIN change. assert!(store.list().await.is_empty()); } + + #[tokio::test] + async fn pin_protected_large_blob_write_acquires_full_token_when_uv_discouraged() { + // largeBlob.write needs a full pinUvAuthToken carrying the `lbw` permission + // even when the RP sets UV=Discouraged (CTAP 2.2 §6.10.2); it must NOT be + // downgraded to OnlyForSharedSecret. Mirrors full_ceremony_using_pin. + let mut channel = MockChannel::new(); + + let mut info = create_info( + &[ + ("largeBlobs", true), + ("clientPin", true), + ("pinUvAuthToken", true), + ], + None, + ); + info.pin_auth_protos = Some(vec![1]); + + // GetInfo + let info_req = CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo); + let info_resp = CborResponse::new_success_from_slice(to_vec(&info).unwrap().as_slice()); + channel.push_command_pair(info_req, info_resp); + + // PinRetries + let pin_retries_req = CborRequest::try_from(&Ctap2ClientPinRequest::new_get_pin_retries( + Some(Ctap2PinUvAuthProtocol::One), + )) + .unwrap(); + let pin_retries_resp = CborResponse::new_success_from_slice( + to_vec(&Ctap2ClientPinResponse { + key_agreement: None, + pin_uv_auth_token: None, + pin_retries: Some(5), + power_cycle_state: None, + uv_retries: None, + }) + .unwrap() + .as_slice(), + ); + channel.push_command_pair(pin_retries_req, pin_retries_resp); + + // KeyAgreement + let key_agreement_req = CborRequest::try_from( + &Ctap2ClientPinRequest::new_get_key_agreement(Ctap2PinUvAuthProtocol::One), + ) + .unwrap(); + let key_agreement_resp = CborResponse::new_success_from_slice( + to_vec(&Ctap2ClientPinResponse { + key_agreement: Some(get_key_agreement()), + pin_uv_auth_token: None, + pin_retries: None, + power_cycle_state: None, + uv_retries: None, + }) + .unwrap() + .as_slice(), + ); + channel.push_command_pair(key_agreement_req, key_agreement_resp); + + let extensions = Some(GetAssertionRequestExtensions { + large_blob: Some(GetAssertionLargeBlobExtension::Write(vec![1, 2, 3, 4])), + ..Default::default() + }); + let mut getassertion = create_get_assertion(&info, extensions); + + // The negotiated token must request the lbw permission alongside ga. + assert!(getassertion + .permissions() + .contains(Ctap2AuthTokenPermissionRole::LARGE_BLOB_WRITE)); + + // getPinUvAuth request and response + let pin_protocol = PinUvAuthProtocolOne::new(); + let (public_key, shared_secret) = pin_protocol.encapsulate(&get_key_agreement()).unwrap(); + let pin_hash_enc = pin_protocol + .encrypt(&shared_secret, &pin_hash("1234".as_bytes())) + .unwrap(); + let pin_req = CborRequest::try_from(&Ctap2ClientPinRequest::new_get_pin_token_with_perm( + Ctap2PinUvAuthProtocol::One, + public_key, + &pin_hash_enc, + getassertion.permissions(), + getassertion.permissions_rpid(), + )) + .unwrap(); + let token = [5; 16]; + let encrypted_token = pin_protocol.encrypt(&shared_secret, &token).unwrap(); + let pin_resp = CborResponse::new_success_from_slice( + to_vec(&Ctap2ClientPinResponse { + key_agreement: None, + pin_uv_auth_token: Some(ByteBuf::from(encrypted_token)), + pin_retries: None, + power_cycle_state: None, + uv_retries: None, + }) + .unwrap() + .as_slice(), + ); + channel.push_command_pair(pin_req, pin_resp); + + let mut recv = channel.get_ux_update_receiver(); + let recv_handle = tokio::task::spawn(async move { + let req = recv.recv().await.unwrap(); + if let UvUpdate::PinRequired(update) = req { + update.send_pin("1234").unwrap(); + } else { + panic!("Wrong UxUpdate received! Expected PinRequired"); + } + recv + }); + + let resp = user_verification( + &mut channel, + UserVerificationRequirement::Discouraged, + &mut getassertion, + TIMEOUT, + ) + .await; + + // A full token via PIN, not the OnlyForSharedSecret downgrade. + assert_eq!( + resp, + Ok(UsedPinUvAuthToken::NewlyCalculated( + Ctap2UserVerificationOperation::GetPinUvAuthTokenUsingPinWithPermissions, + )) + ); + let auth_data = channel.get_auth_data().expect("auth data stored"); + assert_eq!(auth_data.pin_uv_auth_token.as_ref().unwrap(), &token); + assert_eq!(auth_data.shared_secret, shared_secret); + + let recv = recv_handle.await.expect("Failed to join update thread"); + assert!(recv.is_empty()); + } }