Skip to content

Commit 4f484fb

Browse files
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. Adds a regression test asserting the WebAuthn-facing `Assertion.unsigned_extensions_output.large_blob.blob` is `None` even when the device returned a `largeBlobKey`. Refs: WebAuthn L3 §10.5, CTAP 2.2 §6.10 / §11.4.
1 parent 1f05a58 commit 4f484fb

1 file changed

Lines changed: 73 additions & 6 deletions

File tree

libwebauthn/src/proto/ctap2/model/get_assertion.rs

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,11 @@ impl Ctap2GetAssertionResponseExtensions {
521521
pub(crate) fn to_unsigned_extensions(
522522
&self,
523523
request: &GetAssertionRequest,
524-
response: &Ctap2GetAssertionResponse,
524+
// Not used while authenticatorLargeBlobs is unimplemented; see comment below
525+
// (the `large_blob_key` was previously routed to the WebAuthn response, which
526+
// is a key-disclosure bug). The follow-up PR that implements
527+
// `authenticatorLargeBlobs` will use this field again.
528+
_response: &Ctap2GetAssertionResponse,
525529
auth_data: Option<&AuthTokenData>,
526530
) -> GetAssertionResponseUnsignedExtensions {
527531
let decrypted_hmac = self.hmac_secret.as_ref().and_then(|x| {
@@ -548,16 +552,25 @@ impl Ctap2GetAssertionResponseExtensions {
548552
})
549553
});
550554

551-
// LargeBlobs was requested
555+
// LargeBlobs was requested.
556+
//
557+
// The CTAP-level `largeBlobKey` (32-byte AES-256-GCM key) MUST NOT be returned
558+
// to the RP as the WebAuthn `large_blob.blob` value. Returning it would both
559+
// (a) fail to deliver the actual blob (the RP expects the decrypted payload),
560+
// and (b) disclose key material that is meant to stay platform-side.
561+
//
562+
// The proper flow is for the platform to invoke CTAP 2.1
563+
// `authenticatorLargeBlobs(get)`, locate the credential's entry in the
564+
// largeBlobArray, and decrypt it under `largeBlobKey`. That flow is not yet
565+
// implemented; until it is, we expose no blob to the RP. The
566+
// `Ctap2GetAssertionResponse.large_blob_key` field is preserved so the
567+
// upcoming implementation can use it.
552568
let large_blob = request
553569
.extensions
554570
.as_ref()
555571
.and_then(|ext| ext.large_blob.as_ref())
556572
.map(|_| GetAssertionLargeBlobExtensionOutput {
557-
blob: response
558-
.large_blob_key
559-
.as_ref()
560-
.map(|x| x.clone().into_vec()),
573+
blob: None,
561574
// Not yet supported
562575
// written: None,
563576
});
@@ -658,4 +671,58 @@ mod tests {
658671
let assertion = response.into_assertion_output(&request, None);
659672
assert_eq!(assertion.credential_id, None);
660673
}
674+
675+
/// Regression test for the largeBlob key-disclosure bug.
676+
///
677+
/// When the RP requests `largeBlob: { read: true }`, libwebauthn must NOT
678+
/// populate the WebAuthn `large_blob.blob` field with the CTAP-level
679+
/// `largeBlobKey` (a 32-byte AES-256-GCM key). The proper CTAP 2.1
680+
/// `authenticatorLargeBlobs(get)` flow is not yet implemented; the safe
681+
/// behaviour is to drop the key from the WebAuthn response.
682+
///
683+
/// The CTAP-level `large_blob_key` field on the response is preserved so
684+
/// the upcoming implementation can use it internally.
685+
#[test]
686+
fn large_blob_read_does_not_leak_key_into_webauthn_response() {
687+
let cred = make_credential(b"cred-1");
688+
// Simulate an authenticator that returns a (32-byte) largeBlobKey.
689+
let device_returned_key = vec![0xAAu8; 32];
690+
let mut response = make_response(Some(cred.clone()));
691+
response.large_blob_key = Some(ByteBuf::from(device_returned_key.clone()));
692+
// Attach extension data so `to_unsigned_extensions` is invoked.
693+
response.authenticator_data.extensions = Some(Ctap2GetAssertionResponseExtensions {
694+
cred_blob: None,
695+
hmac_secret: None,
696+
});
697+
698+
// The RP requested largeBlob.read.
699+
let mut request = make_request(vec![cred]);
700+
request.extensions = Some(GetAssertionRequestExtensions {
701+
cred_blob: false,
702+
prf: None,
703+
large_blob: Some(GetAssertionLargeBlobExtension::Read),
704+
});
705+
706+
let assertion = response.into_assertion_output(&request, None);
707+
let unsigned = assertion
708+
.unsigned_extensions_output
709+
.expect("unsigned extensions should be present when the response has extensions");
710+
let large_blob = unsigned
711+
.large_blob
712+
.expect("largeBlob extension output should be present when requested");
713+
714+
// The WebAuthn-facing blob MUST NOT contain the largeBlobKey.
715+
assert!(
716+
large_blob.blob.is_none(),
717+
"WebAuthn large_blob.blob must not be populated until authenticatorLargeBlobs is implemented; got {:?}",
718+
large_blob.blob
719+
);
720+
721+
// The CTAP-level largeBlobKey is still preserved on the Assertion model
722+
// for use by the upcoming implementation.
723+
assert_eq!(
724+
assertion.large_blob_key.as_deref(),
725+
Some(&device_returned_key[..])
726+
);
727+
}
661728
}

0 commit comments

Comments
 (0)