Skip to content

Commit 5a50879

Browse files
feat(webauthn): perform largeBlob.read via authenticatorLargeBlobs
When the WebAuthn `largeBlob: { read: true }` extension is requested and the authenticator returns a per-credential `largeBlobKey`, libwebauthn now runs `authenticatorLargeBlobs(get)` to fetch the on-device serialized array, decrypts the matching entry, and exposes the plaintext via the WebAuthn response's `unsigned_extensions_output.large_blob.blob` field. The read flow uses a per-assertion `AuthenticatorLargeBlobStorage` handle (introduced in the previous commit), so each credential is read against its own `largeBlobKey`. Failures are non-fatal: per WebAuthn L3 §10.5 the `blob` output is optional on success. Combined with the earlier fix that removed the key-disclosure bug, this completes the read half of the WebAuthn `largeBlob` extension. Refs: WebAuthn L3 §10.5, CTAP 2.2 §6.10.
1 parent e277457 commit 5a50879

2 files changed

Lines changed: 190 additions & 1 deletion

File tree

libwebauthn/src/ops/webauthn/large_blob.rs

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,4 +685,138 @@ mod tests {
685685
let got = storage.read(b"cred-A").await.expect("read");
686686
assert!(got.is_none());
687687
}
688+
689+
/// End-to-end check of the read path through `webauthn_get_assertion`:
690+
/// drives the CTAP exchange, array parsing, AES-256-GCM decrypt, deflate
691+
/// decompress, and surfaces the plaintext as the WebAuthn JSON output.
692+
#[tokio::test]
693+
async fn webauthn_get_assertion_returns_decrypted_large_blob() {
694+
use crate::ops::webauthn::{
695+
GetAssertionLargeBlobExtension, GetAssertionRequest, GetAssertionRequestExtensions,
696+
UserVerificationRequirement,
697+
};
698+
use crate::proto::ctap2::cbor::{to_vec, CborRequest, CborResponse, Value};
699+
use crate::proto::ctap2::{
700+
Ctap2CommandCode, Ctap2GetInfoResponse, Ctap2LargeBlobsResponse,
701+
};
702+
use crate::transport::mock::channel::MockChannel;
703+
use crate::webauthn::WebAuthn;
704+
use std::collections::{BTreeMap, HashMap};
705+
706+
let large_blob_key = [0x77u8; 32];
707+
let nonce = [0x22u8; 12];
708+
let plaintext = b"webauthn end-to-end largeBlob".to_vec();
709+
let entry = encrypt_entry(&large_blob_key, &nonce, &plaintext).unwrap();
710+
let serialized_array = build_serialized_array(&[entry]);
711+
712+
// Build the assertion-response CBOR by hand so we can populate field
713+
// 0x07 (largeBlobKey) without adding a Serialize impl to the response
714+
// model.
715+
let credential_id = b"cred-id".to_vec();
716+
let mut auth_data = vec![0u8; 37];
717+
auth_data[32] = 0x01; // USER_PRESENT flag
718+
let mut cred_id_map = BTreeMap::new();
719+
cred_id_map.insert(Value::Text("type".into()), Value::Text("public-key".into()));
720+
cred_id_map.insert(
721+
Value::Text("id".into()),
722+
Value::Bytes(credential_id.clone()),
723+
);
724+
let mut response_map = BTreeMap::new();
725+
response_map.insert(Value::Integer(1), Value::Map(cred_id_map));
726+
response_map.insert(Value::Integer(2), Value::Bytes(auth_data));
727+
response_map.insert(Value::Integer(3), Value::Bytes(vec![0u8; 32]));
728+
response_map.insert(Value::Integer(7), Value::Bytes(large_blob_key.to_vec()));
729+
let assertion_resp_cbor = to_vec(&Value::Map(response_map)).unwrap();
730+
731+
// GetInfo response advertising support for largeBlobs.
732+
let mut info = Ctap2GetInfoResponse {
733+
versions: vec!["FIDO_2_1".into()],
734+
..Default::default()
735+
};
736+
let mut options = HashMap::new();
737+
options.insert("largeBlobs".into(), true);
738+
info.options = Some(options);
739+
let info_cbor = to_vec(&info).unwrap();
740+
741+
let mut channel = MockChannel::new();
742+
743+
// 1. _webauthn_get_assertion_fido2 calls ctap2_get_info().
744+
channel.push_command_pair(
745+
CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo),
746+
CborResponse::new_success_from_slice(&info_cbor),
747+
);
748+
// 2. user_verification calls ctap2_get_info() again.
749+
channel.push_command_pair(
750+
CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo),
751+
CborResponse::new_success_from_slice(&info_cbor),
752+
);
753+
// 3. ctap2_get_assertion. The default From<GetAssertionRequest>
754+
// impl produces options { up: true, uv: false }, matching the
755+
// Discouraged UV path; that's what we exercise here.
756+
let req = crate::proto::ctap2::Ctap2GetAssertionRequest::from(GetAssertionRequest {
757+
relying_party_id: "example.com".into(),
758+
challenge: vec![0u8; 32],
759+
origin: "example.com".into(),
760+
cross_origin: None,
761+
allow: vec![],
762+
extensions: Some(GetAssertionRequestExtensions {
763+
cred_blob: false,
764+
prf: None,
765+
large_blob: Some(GetAssertionLargeBlobExtension::Read),
766+
}),
767+
user_verification: UserVerificationRequirement::Discouraged,
768+
timeout: Duration::from_secs(5),
769+
});
770+
let assertion_req_cbor = crate::proto::ctap2::cbor::to_vec(&req).unwrap();
771+
channel.push_command_pair(
772+
CborRequest {
773+
command: Ctap2CommandCode::AuthenticatorGetAssertion,
774+
encoded_data: assertion_req_cbor,
775+
},
776+
CborResponse::new_success_from_slice(&assertion_resp_cbor),
777+
);
778+
// 4. authenticatorLargeBlobs(get).
779+
let blobs_req = Ctap2LargeBlobsRequest::new_get(0, LARGE_BLOB_DEFAULT_CHUNK);
780+
let blobs_resp = Ctap2LargeBlobsResponse {
781+
config: Some(serde_bytes::ByteBuf::from(serialized_array)),
782+
};
783+
channel.push_command_pair(
784+
CborRequest {
785+
command: Ctap2CommandCode::AuthenticatorLargeBlobs,
786+
encoded_data: crate::proto::ctap2::cbor::to_vec(&blobs_req).unwrap(),
787+
},
788+
CborResponse::new_success_from_slice(
789+
&crate::proto::ctap2::cbor::to_vec(&blobs_resp).unwrap(),
790+
),
791+
);
792+
793+
let request = GetAssertionRequest {
794+
relying_party_id: "example.com".into(),
795+
challenge: vec![0u8; 32],
796+
origin: "example.com".into(),
797+
cross_origin: None,
798+
allow: vec![],
799+
extensions: Some(GetAssertionRequestExtensions {
800+
cred_blob: false,
801+
prf: None,
802+
large_blob: Some(GetAssertionLargeBlobExtension::Read),
803+
}),
804+
user_verification: UserVerificationRequirement::Discouraged,
805+
timeout: Duration::from_secs(5),
806+
};
807+
808+
let response = channel
809+
.webauthn_get_assertion(&request)
810+
.await
811+
.expect("webauthn_get_assertion should succeed");
812+
assert_eq!(response.assertions.len(), 1);
813+
let large_blob = response.assertions[0]
814+
.unsigned_extensions_output
815+
.as_ref()
816+
.expect("unsigned extensions present")
817+
.large_blob
818+
.as_ref()
819+
.expect("largeBlob extension output present");
820+
assert_eq!(large_blob.blob.as_deref(), Some(plaintext.as_slice()));
821+
}
688822
}

libwebauthn/src/webauthn.rs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ use tracing::{debug, error, info, instrument, trace, warn};
66

77
use crate::fido::FidoProtocol;
88
use crate::ops::u2f::{RegisterRequest, SignRequest, UpgradableResponse};
9-
use crate::ops::webauthn::{DowngradableRequest, GetAssertionRequest, GetAssertionResponse};
9+
use crate::ops::webauthn::{
10+
AuthenticatorLargeBlobStorage, DowngradableRequest, GetAssertionLargeBlobExtension,
11+
GetAssertionLargeBlobExtensionOutput, GetAssertionRequest, GetAssertionResponse,
12+
GetAssertionResponseUnsignedExtensions, LargeBlobStorage,
13+
};
1014
use crate::ops::webauthn::{MakeCredentialRequest, MakeCredentialResponse};
1115
use crate::proto::ctap1::Ctap1;
1216
use crate::proto::ctap2::preflight::ctap2_preflight;
@@ -235,6 +239,57 @@ where
235239
let response = self.ctap2_get_next_assertion(op.timeout).await?;
236240
assertions.push(response.into_assertion_output(op, self.get_auth_data()));
237241
}
242+
243+
// largeBlob.read: fetch and decrypt the on-device blob via
244+
// authenticatorLargeBlobs(get). Failures are non-fatal; per WebAuthn
245+
// L3 §10.1.5 the `blob` field is absent when the read cannot complete.
246+
let large_blob_read_requested = op.extensions.as_ref().and_then(|e| e.large_blob.as_ref())
247+
== Some(&GetAssertionLargeBlobExtension::Read);
248+
if large_blob_read_requested {
249+
for assertion in assertions.iter_mut() {
250+
let Some(key_vec) = assertion.large_blob_key.as_ref() else {
251+
continue;
252+
};
253+
let Ok(key) = <[u8; 32]>::try_from(key_vec.as_slice()) else {
254+
warn!(
255+
len = key_vec.len(),
256+
"largeBlobKey has unexpected length (expected 32); skipping fetch"
257+
);
258+
continue;
259+
};
260+
let credential_id = assertion
261+
.credential_id
262+
.as_ref()
263+
.map(|c| c.id.to_vec())
264+
.unwrap_or_default();
265+
let storage = AuthenticatorLargeBlobStorage::new(
266+
self,
267+
credential_id.clone(),
268+
key,
269+
op.timeout,
270+
);
271+
let blob = match storage.read(&credential_id).await {
272+
Ok(b) => b,
273+
Err(e) => {
274+
warn!(?e, "authenticatorLargeBlobs(get) failed; no blob returned");
275+
None
276+
}
277+
};
278+
let entry = GetAssertionLargeBlobExtensionOutput { blob };
279+
match assertion.unsigned_extensions_output.as_mut() {
280+
Some(unsigned) => unsigned.large_blob = Some(entry),
281+
None => {
282+
assertion.unsigned_extensions_output =
283+
Some(GetAssertionResponseUnsignedExtensions {
284+
hmac_get_secret: None,
285+
large_blob: Some(entry),
286+
prf: None,
287+
});
288+
}
289+
}
290+
}
291+
}
292+
238293
Ok(assertions.as_slice().into())
239294
}
240295

0 commit comments

Comments
 (0)