Skip to content

Commit d9575ee

Browse files
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
1 parent 637ca93 commit d9575ee

1 file changed

Lines changed: 268 additions & 2 deletions

File tree

libwebauthn-tests/tests/large_blob.rs

Lines changed: 268 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ async fn handle_updates(mut state_recv: Receiver<UvUpdate>) {
3030
assert_eq!(state_recv.recv().await, Ok(UvUpdate::PresenceRequired));
3131
}
3232

33+
/// Drain `n` `PresenceRequired` updates. One per high-level WebAuthn ceremony.
34+
async fn handle_updates_n(mut state_recv: Receiver<UvUpdate>, n: usize) {
35+
for _ in 0..n {
36+
assert_eq!(state_recv.recv().await, Ok(UvUpdate::PresenceRequired));
37+
}
38+
}
39+
3340
#[test(tokio::test)]
3441
async fn test_webauthn_large_blob_read_returns_planted_blob() {
3542
let mut device = get_virtual_device();
@@ -167,7 +174,7 @@ async fn plant_large_blob_array(
167174
.expect("authenticatorLargeBlobs(set) succeeds without PIN");
168175
}
169176

170-
/// Encode one largeBlobMap entry per CTAP 2.1 §6.10.3.
177+
/// Encode one largeBlobMap entry per CTAP 2.2 §6.10.3.
171178
fn encode_entry(key: &[u8; 32], nonce: &[u8; 12], plaintext: &[u8]) -> Vec<u8> {
172179
use aes_gcm::aead::Aead;
173180
use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
@@ -208,7 +215,7 @@ fn encode_entry(key: &[u8; 32], nonce: &[u8; 12], plaintext: &[u8]) -> Vec<u8> {
208215
buf
209216
}
210217

211-
/// Wrap entries in a CBOR array + 16-byte left-SHA-256 trailer (CTAP 2.1 §6.10.3).
218+
/// Wrap entries in a CBOR array + 16-byte left-SHA-256 trailer (CTAP 2.2 §6.10.2).
212219
fn encode_serialized_array(entries: &[Vec<u8>]) -> Vec<u8> {
213220
use sha2::{Digest, Sha256};
214221
assert!(
@@ -223,3 +230,262 @@ fn encode_serialized_array(entries: &[Vec<u8>]) -> Vec<u8> {
223230
out.extend_from_slice(&h[..16]);
224231
out
225232
}
233+
234+
async fn register_with_large_blob(
235+
channel: &mut libwebauthn::transport::hid::channel::HidChannel<'_>,
236+
user_handle: &str,
237+
challenge: &[u8; 32],
238+
) -> Ctap2PublicKeyCredentialDescriptor {
239+
let user_id: [u8; 32] = thread_rng().gen();
240+
let make = MakeCredentialRequest {
241+
origin: RP.into(),
242+
challenge: challenge.to_vec(),
243+
relying_party: Ctap2PublicKeyCredentialRpEntity::new(RP, RP),
244+
user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, user_handle, user_handle),
245+
resident_key: Some(ResidentKeyRequirement::Required),
246+
user_verification: UserVerificationRequirement::Discouraged,
247+
algorithms: vec![Ctap2CredentialType::default()],
248+
exclude: None,
249+
extensions: Some(MakeCredentialsRequestExtensions {
250+
large_blob: Some(MakeCredentialLargeBlobExtensionInput {
251+
support: MakeCredentialLargeBlobExtension::Required,
252+
}),
253+
..Default::default()
254+
}),
255+
timeout: TIMEOUT,
256+
top_origin: None,
257+
};
258+
let response = channel
259+
.webauthn_make_credential(&make)
260+
.await
261+
.expect("MakeCredential should succeed");
262+
assert_eq!(
263+
response
264+
.unsigned_extensions_output
265+
.large_blob
266+
.as_ref()
267+
.and_then(|lb| lb.supported),
268+
Some(true),
269+
"device must report largeBlob.supported=true"
270+
);
271+
(&response.authenticator_data)
272+
.try_into()
273+
.expect("credential descriptor")
274+
}
275+
276+
fn ga_request(
277+
credential: &Ctap2PublicKeyCredentialDescriptor,
278+
challenge: &[u8; 32],
279+
ext: GetAssertionLargeBlobExtension,
280+
) -> GetAssertionRequest {
281+
GetAssertionRequest {
282+
relying_party_id: RP.into(),
283+
origin: RP.into(),
284+
challenge: challenge.to_vec(),
285+
allow: vec![credential.clone()],
286+
user_verification: UserVerificationRequirement::Discouraged,
287+
extensions: Some(GetAssertionRequestExtensions {
288+
appid: None,
289+
cred_blob: false,
290+
prf: None,
291+
large_blob: Some(ext),
292+
}),
293+
timeout: TIMEOUT,
294+
top_origin: None,
295+
}
296+
}
297+
298+
/// End-to-end round trip via the production write+read paths. Drives WebAuthn
299+
/// `largeBlob.write` → `largeBlob.read` against the virt authenticator and
300+
/// asserts that the read returns exactly the bytes written.
301+
#[test(tokio::test)]
302+
async fn test_webauthn_large_blob_write_then_read_returns_blob() {
303+
let mut device = get_virtual_device();
304+
let mut channel = device.channel().await.unwrap();
305+
let challenge: [u8; 32] = thread_rng().gen();
306+
307+
let state_recv = channel.get_ux_update_receiver();
308+
// MakeCredential + GetAssertion(write) + GetAssertion(read) = 3 PresenceRequired updates.
309+
let update_handle = tokio::spawn(handle_updates_n(state_recv, 3));
310+
311+
let credential = register_with_large_blob(&mut channel, "alice", &challenge).await;
312+
let plaintext = b"webauthn largeBlob via WebAuthn API".to_vec();
313+
314+
let write_resp = channel
315+
.webauthn_get_assertion(&ga_request(
316+
&credential,
317+
&challenge,
318+
GetAssertionLargeBlobExtension::Write(plaintext.clone()),
319+
))
320+
.await
321+
.expect("Write assertion should succeed");
322+
let written = write_resp.assertions[0]
323+
.unsigned_extensions_output
324+
.as_ref()
325+
.and_then(|u| u.large_blob.as_ref())
326+
.and_then(|lb| lb.written);
327+
assert_eq!(written, Some(true), "largeBlob.written should be true");
328+
329+
let read_resp = channel
330+
.webauthn_get_assertion(&ga_request(
331+
&credential,
332+
&challenge,
333+
GetAssertionLargeBlobExtension::Read,
334+
))
335+
.await
336+
.expect("Read assertion should succeed");
337+
let blob = read_resp.assertions[0]
338+
.unsigned_extensions_output
339+
.as_ref()
340+
.and_then(|u| u.large_blob.as_ref())
341+
.and_then(|lb| lb.blob.as_ref())
342+
.expect("blob present after write");
343+
assert_eq!(blob.as_slice(), plaintext.as_slice());
344+
345+
update_handle.await.unwrap();
346+
}
347+
348+
/// `largeBlob.write` followed by a second `largeBlob.write` of different bytes:
349+
/// per CTAP 2.2 §6.10.6 the second write replaces (not appends to) the first.
350+
#[test(tokio::test)]
351+
async fn test_webauthn_large_blob_write_replaces_existing_entry() {
352+
let mut device = get_virtual_device();
353+
let mut channel = device.channel().await.unwrap();
354+
let challenge: [u8; 32] = thread_rng().gen();
355+
356+
let state_recv = channel.get_ux_update_receiver();
357+
let update_handle = tokio::spawn(handle_updates_n(state_recv, 4));
358+
359+
let credential = register_with_large_blob(&mut channel, "bob", &challenge).await;
360+
361+
let first = b"first blob payload".to_vec();
362+
let second = b"second, longer blob payload that supersedes the first".to_vec();
363+
364+
channel
365+
.webauthn_get_assertion(&ga_request(
366+
&credential,
367+
&challenge,
368+
GetAssertionLargeBlobExtension::Write(first.clone()),
369+
))
370+
.await
371+
.expect("first write");
372+
373+
channel
374+
.webauthn_get_assertion(&ga_request(
375+
&credential,
376+
&challenge,
377+
GetAssertionLargeBlobExtension::Write(second.clone()),
378+
))
379+
.await
380+
.expect("second write");
381+
382+
let read_resp = channel
383+
.webauthn_get_assertion(&ga_request(
384+
&credential,
385+
&challenge,
386+
GetAssertionLargeBlobExtension::Read,
387+
))
388+
.await
389+
.expect("read");
390+
let blob = read_resp.assertions[0]
391+
.unsigned_extensions_output
392+
.as_ref()
393+
.and_then(|u| u.large_blob.as_ref())
394+
.and_then(|lb| lb.blob.as_ref())
395+
.expect("blob present after second write");
396+
assert_eq!(blob.as_slice(), second.as_slice(), "second write replaced");
397+
398+
update_handle.await.unwrap();
399+
}
400+
401+
/// Delete on a credential with no prior largeBlob returns written=false per the strict
402+
/// CTAP 2.2 §6.10.6 "Return an error" branch (line 303).
403+
#[test(tokio::test)]
404+
async fn test_webauthn_large_blob_delete_without_existing_entry_reports_false() {
405+
let mut device = get_virtual_device();
406+
let mut channel = device.channel().await.unwrap();
407+
let challenge: [u8; 32] = thread_rng().gen();
408+
409+
let state_recv = channel.get_ux_update_receiver();
410+
let update_handle = tokio::spawn(handle_updates_n(state_recv, 2));
411+
412+
let credential = register_with_large_blob(&mut channel, "dave", &challenge).await;
413+
414+
let del_resp = channel
415+
.webauthn_get_assertion(&ga_request(
416+
&credential,
417+
&challenge,
418+
GetAssertionLargeBlobExtension::Delete,
419+
))
420+
.await
421+
.expect("Delete assertion should still return the assertion");
422+
assert_eq!(
423+
del_resp.assertions[0]
424+
.unsigned_extensions_output
425+
.as_ref()
426+
.and_then(|u| u.large_blob.as_ref())
427+
.and_then(|lb| lb.written),
428+
Some(false),
429+
"delete with no existing entry reports written=false"
430+
);
431+
432+
update_handle.await.unwrap();
433+
}
434+
435+
/// Delete after write erases the entry; subsequent read returns no blob.
436+
#[test(tokio::test)]
437+
async fn test_webauthn_large_blob_delete_removes_entry() {
438+
let mut device = get_virtual_device();
439+
let mut channel = device.channel().await.unwrap();
440+
let challenge: [u8; 32] = thread_rng().gen();
441+
442+
let state_recv = channel.get_ux_update_receiver();
443+
let update_handle = tokio::spawn(handle_updates_n(state_recv, 4));
444+
445+
let credential = register_with_large_blob(&mut channel, "carol", &challenge).await;
446+
let payload = b"to be deleted".to_vec();
447+
448+
channel
449+
.webauthn_get_assertion(&ga_request(
450+
&credential,
451+
&challenge,
452+
GetAssertionLargeBlobExtension::Write(payload),
453+
))
454+
.await
455+
.expect("write");
456+
457+
let del_resp = channel
458+
.webauthn_get_assertion(&ga_request(
459+
&credential,
460+
&challenge,
461+
GetAssertionLargeBlobExtension::Delete,
462+
))
463+
.await
464+
.expect("delete");
465+
assert_eq!(
466+
del_resp.assertions[0]
467+
.unsigned_extensions_output
468+
.as_ref()
469+
.and_then(|u| u.large_blob.as_ref())
470+
.and_then(|lb| lb.written),
471+
Some(true),
472+
"delete reports written=true"
473+
);
474+
475+
let read_resp = channel
476+
.webauthn_get_assertion(&ga_request(
477+
&credential,
478+
&challenge,
479+
GetAssertionLargeBlobExtension::Read,
480+
))
481+
.await
482+
.expect("read after delete");
483+
let blob_after = read_resp.assertions[0]
484+
.unsigned_extensions_output
485+
.as_ref()
486+
.and_then(|u| u.large_blob.as_ref())
487+
.and_then(|lb| lb.blob.as_ref());
488+
assert!(blob_after.is_none(), "blob absent after delete");
489+
490+
update_handle.await.unwrap();
491+
}

0 commit comments

Comments
 (0)