@@ -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) ]
3441async 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.
171178fn 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 ).
212219fn 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