From bdb1328f8346fdadbf877b0d841278dbd902fa23 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 10 May 2026 20:58:22 +0100 Subject: [PATCH 1/2] fix(webauthn): largeBlob.read no longer leaks largeBlobKey to RP When the RP requests `largeBlob: { read: true }`, libwebauthn was populating the WebAuthn response's `blob` field with the per-credential `largeBlobKey` (a 32-byte AES-256-GCM key) instead of the decrypted blob payload. The CTAP 2.1 `authenticatorLargeBlobs` command is not yet implemented; until it is, the safe behaviour is to drop the key from the WebAuthn response. The CTAP-level `Ctap2GetAssertionResponse.large_blob_key` field is unchanged so the next PR can wire up the proper flow. Refs: WebAuthn L3 sec. 10.1.5, CTAP 2.1 sec. 6.10. --- .../src/proto/ctap2/model/get_assertion.rs | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index 2d911eb..d88be15 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -521,7 +521,7 @@ impl Ctap2GetAssertionResponseExtensions { pub(crate) fn to_unsigned_extensions( &self, request: &GetAssertionRequest, - response: &Ctap2GetAssertionResponse, + _response: &Ctap2GetAssertionResponse, auth_data: Option<&AuthTokenData>, ) -> GetAssertionResponseUnsignedExtensions { let decrypted_hmac = self.hmac_secret.as_ref().and_then(|x| { @@ -548,19 +548,14 @@ impl Ctap2GetAssertionResponseExtensions { }) }); - // LargeBlobs was requested + // `blob` stays `None` until `authenticatorLargeBlobs` is wired up; returning + // the raw `largeBlobKey` here would disclose the per-credential AES key to + // the RP instead of the decrypted blob payload. let large_blob = request .extensions .as_ref() .and_then(|ext| ext.large_blob.as_ref()) - .map(|_| GetAssertionLargeBlobExtensionOutput { - blob: response - .large_blob_key - .as_ref() - .map(|x| x.clone().into_vec()), - // Not yet supported - // written: None, - }); + .map(|_| GetAssertionLargeBlobExtensionOutput { blob: None }); GetAssertionResponseUnsignedExtensions { hmac_get_secret: None, @@ -658,4 +653,36 @@ mod tests { let assertion = response.into_assertion_output(&request, None); assert_eq!(assertion.credential_id, None); } + + #[test] + fn large_blob_read_does_not_leak_key_into_webauthn_response() { + let cred = make_credential(b"cred-1"); + let device_returned_key = vec![0xAAu8; 32]; + let mut response = make_response(Some(cred.clone())); + response.large_blob_key = Some(ByteBuf::from(device_returned_key.clone())); + response.authenticator_data.extensions = Some(Ctap2GetAssertionResponseExtensions { + cred_blob: None, + hmac_secret: None, + }); + + let mut request = make_request(vec![cred]); + request.extensions = Some(GetAssertionRequestExtensions { + cred_blob: false, + prf: None, + large_blob: Some(GetAssertionLargeBlobExtension::Read), + }); + + let assertion = response.into_assertion_output(&request, None); + let large_blob = assertion + .unsigned_extensions_output + .expect("unsigned extensions present") + .large_blob + .expect("largeBlob extension output present"); + + assert!(large_blob.blob.is_none()); + assert_eq!( + assertion.large_blob_key.as_deref(), + Some(&device_returned_key[..]) + ); + } } From 5388273f291904b4eb7ebce3f96b5dee3555c572 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 18:45:13 +0100 Subject: [PATCH 2/2] fix(webauthn): drop large_blob_key from public Assertion model Per review on #198: keep the per-credential largeBlobKey only on the CTAP-level Ctap2GetAssertionResponse. Surfacing it on the public Assertion struct gives callers a foot-gun to forward straight to the RP, which is exactly the disclosure this PR is meant to prevent. The follow-up authenticatorLargeBlobs PR (#206) can read the key directly off the CTAP response. --- libwebauthn/src/ops/webauthn/get_assertion.rs | 2 -- libwebauthn/src/proto/ctap2/model/get_assertion.rs | 5 ----- 2 files changed, 7 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 3c58bac..df55859 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -405,7 +405,6 @@ pub struct Assertion { pub user: Option, pub credentials_count: Option, pub user_selected: Option, - pub large_blob_key: Option>, pub unsigned_extensions_output: Option, pub enterprise_attestation: Option, pub attestation_statement: Option, @@ -815,7 +814,6 @@ mod tests { user: None, credentials_count: None, user_selected: None, - large_blob_key: None, unsigned_extensions_output: None, enterprise_attestation: None, attestation_statement: None, diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index d88be15..dd61a2a 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -493,7 +493,6 @@ impl Ctap2GetAssertionResponse { user: self.user, credentials_count: self.credentials_count, user_selected: self.user_selected, - large_blob_key: self.large_blob_key.map(ByteBuf::into_vec), unsigned_extensions_output, enterprise_attestation: self.enterprise_attestation, attestation_statement: self.attestation_statement, @@ -680,9 +679,5 @@ mod tests { .expect("largeBlob extension output present"); assert!(large_blob.blob.is_none()); - assert_eq!( - assertion.large_blob_key.as_deref(), - Some(&device_returned_key[..]) - ); } }