Skip to content

Commit 6799ed6

Browse files
fix(webauthn): address largeBlob write/delete review findings
Preserve foreign largeBlobArray entries by their exact bytes on read-modify-write rather than re-serialising them, which also drops a redundant re-encode of the new entry. Treat a valid-trailer but unparseable array as a fail-safe error rather than clobbering it. Satisfy the production clippy::indexing_slicing denies in the write path. Refuse the U2F downgrade for largeBlob write and delete. Adds tests for nonce uniqueness across writes, the pinUvAuthParam protocol-1 path, the JSON write input, the PIN-protected write token ceremony under UV=Discouraged, foreign-blob survival across write and delete, the single-allowCredential guard, and shrink-on-replace.
1 parent d9575ee commit 6799ed6

5 files changed

Lines changed: 1104 additions & 408 deletions

File tree

libwebauthn-tests/tests/large_blob.rs

Lines changed: 242 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use libwebauthn::proto::ctap2::{
1313
Ctap2PublicKeyCredentialUserEntity,
1414
};
1515
use libwebauthn::transport::{Channel, ChannelSettings, Device};
16-
use libwebauthn::webauthn::WebAuthn;
16+
use libwebauthn::webauthn::{Error, PlatformError, WebAuthn};
1717
use libwebauthn::UvUpdate;
1818
use libwebauthn_tests::virt::get_virtual_device;
1919
use rand::{thread_rng, Rng};
@@ -301,7 +301,7 @@ fn ga_request(
301301
#[test(tokio::test)]
302302
async fn test_webauthn_large_blob_write_then_read_returns_blob() {
303303
let mut device = get_virtual_device();
304-
let mut channel = device.channel().await.unwrap();
304+
let mut channel = device.channel(ChannelSettings::default()).await.unwrap();
305305
let challenge: [u8; 32] = thread_rng().gen();
306306

307307
let state_recv = channel.get_ux_update_receiver();
@@ -350,7 +350,7 @@ async fn test_webauthn_large_blob_write_then_read_returns_blob() {
350350
#[test(tokio::test)]
351351
async fn test_webauthn_large_blob_write_replaces_existing_entry() {
352352
let mut device = get_virtual_device();
353-
let mut channel = device.channel().await.unwrap();
353+
let mut channel = device.channel(ChannelSettings::default()).await.unwrap();
354354
let challenge: [u8; 32] = thread_rng().gen();
355355

356356
let state_recv = channel.get_ux_update_receiver();
@@ -403,7 +403,7 @@ async fn test_webauthn_large_blob_write_replaces_existing_entry() {
403403
#[test(tokio::test)]
404404
async fn test_webauthn_large_blob_delete_without_existing_entry_reports_false() {
405405
let mut device = get_virtual_device();
406-
let mut channel = device.channel().await.unwrap();
406+
let mut channel = device.channel(ChannelSettings::default()).await.unwrap();
407407
let challenge: [u8; 32] = thread_rng().gen();
408408

409409
let state_recv = channel.get_ux_update_receiver();
@@ -436,7 +436,7 @@ async fn test_webauthn_large_blob_delete_without_existing_entry_reports_false()
436436
#[test(tokio::test)]
437437
async fn test_webauthn_large_blob_delete_removes_entry() {
438438
let mut device = get_virtual_device();
439-
let mut channel = device.channel().await.unwrap();
439+
let mut channel = device.channel(ChannelSettings::default()).await.unwrap();
440440
let challenge: [u8; 32] = thread_rng().gen();
441441

442442
let state_recv = channel.get_ux_update_receiver();
@@ -489,3 +489,240 @@ async fn test_webauthn_large_blob_delete_removes_entry() {
489489

490490
update_handle.await.unwrap();
491491
}
492+
493+
async fn write_large_blob(
494+
channel: &mut libwebauthn::transport::hid::channel::HidChannel<'_>,
495+
credential: &Ctap2PublicKeyCredentialDescriptor,
496+
challenge: &[u8; 32],
497+
blob: Vec<u8>,
498+
) {
499+
let resp = channel
500+
.webauthn_get_assertion(&ga_request(
501+
credential,
502+
challenge,
503+
GetAssertionLargeBlobExtension::Write(blob),
504+
))
505+
.await
506+
.expect("write assertion should succeed");
507+
assert_eq!(
508+
resp.assertions[0]
509+
.unsigned_extensions_output
510+
.as_ref()
511+
.and_then(|u| u.large_blob.as_ref())
512+
.and_then(|lb| lb.written),
513+
Some(true),
514+
"largeBlob.written should be true"
515+
);
516+
}
517+
518+
async fn read_large_blob(
519+
channel: &mut libwebauthn::transport::hid::channel::HidChannel<'_>,
520+
credential: &Ctap2PublicKeyCredentialDescriptor,
521+
challenge: &[u8; 32],
522+
) -> Option<Vec<u8>> {
523+
let resp = channel
524+
.webauthn_get_assertion(&ga_request(
525+
credential,
526+
challenge,
527+
GetAssertionLargeBlobExtension::Read,
528+
))
529+
.await
530+
.expect("read assertion should succeed");
531+
resp.assertions[0]
532+
.unsigned_extensions_output
533+
.as_ref()
534+
.and_then(|u| u.large_blob.as_ref())
535+
.and_then(|lb| lb.blob.clone())
536+
}
537+
538+
/// Two largeBlob-capable credentials coexist on one authenticator. A's write
539+
/// (replace) and delete must leave B's foreign entry intact (CTAP 2.2 §6.10.6).
540+
#[test(tokio::test)]
541+
async fn test_webauthn_large_blob_foreign_entry_survives_write_and_delete() {
542+
let mut device = get_virtual_device();
543+
let mut channel = device.channel(ChannelSettings::default()).await.unwrap();
544+
let challenge: [u8; 32] = thread_rng().gen();
545+
546+
let state_recv = channel.get_ux_update_receiver();
547+
// 2 registrations + 3 writes + 4 reads + 1 delete = 10 ceremonies.
548+
let update_handle = tokio::spawn(handle_updates_n(state_recv, 10));
549+
550+
let cred_a = register_with_large_blob(&mut channel, "alice", &challenge).await;
551+
let cred_b = register_with_large_blob(&mut channel, "bob", &challenge).await;
552+
553+
let blob_a = b"alice's blob".to_vec();
554+
let blob_b = b"bob's blob must survive".to_vec();
555+
556+
write_large_blob(&mut channel, &cred_a, &challenge, blob_a.clone()).await;
557+
write_large_blob(&mut channel, &cred_b, &challenge, blob_b.clone()).await;
558+
559+
assert_eq!(
560+
read_large_blob(&mut channel, &cred_a, &challenge)
561+
.await
562+
.as_deref(),
563+
Some(blob_a.as_slice()),
564+
"A reads back its own blob"
565+
);
566+
assert_eq!(
567+
read_large_blob(&mut channel, &cred_b, &challenge)
568+
.await
569+
.as_deref(),
570+
Some(blob_b.as_slice()),
571+
"B reads back its own blob"
572+
);
573+
574+
let blob_a2 = b"alice's replacement blob, longer than the first".to_vec();
575+
write_large_blob(&mut channel, &cred_a, &challenge, blob_a2.clone()).await;
576+
assert_eq!(
577+
read_large_blob(&mut channel, &cred_b, &challenge)
578+
.await
579+
.as_deref(),
580+
Some(blob_b.as_slice()),
581+
"B's blob survives A's replace"
582+
);
583+
584+
let del_resp = channel
585+
.webauthn_get_assertion(&ga_request(
586+
&cred_a,
587+
&challenge,
588+
GetAssertionLargeBlobExtension::Delete,
589+
))
590+
.await
591+
.expect("delete A should succeed");
592+
assert_eq!(
593+
del_resp.assertions[0]
594+
.unsigned_extensions_output
595+
.as_ref()
596+
.and_then(|u| u.large_blob.as_ref())
597+
.and_then(|lb| lb.written),
598+
Some(true),
599+
"delete A reports written=true"
600+
);
601+
assert_eq!(
602+
read_large_blob(&mut channel, &cred_b, &challenge)
603+
.await
604+
.as_deref(),
605+
Some(blob_b.as_slice()),
606+
"B's blob survives A's delete"
607+
);
608+
609+
update_handle.await.unwrap();
610+
}
611+
612+
/// WebAuthn L3 §10.1.5: largeBlob.write/delete requires exactly one allowCredentials
613+
/// entry. The platform guard in get_assertion_fido2 rejects a write with two (or zero)
614+
/// allowed credentials as NotSupported, before any CTAP traffic.
615+
#[test(tokio::test)]
616+
async fn test_webauthn_large_blob_write_requires_single_allow_credential() {
617+
let mut device = get_virtual_device();
618+
let mut channel = device.channel(ChannelSettings::default()).await.unwrap();
619+
let challenge: [u8; 32] = thread_rng().gen();
620+
621+
let state_recv = channel.get_ux_update_receiver();
622+
// Two registrations, one PresenceRequired each. The rejected writes emit none.
623+
let update_handle = tokio::spawn(handle_updates_n(state_recv, 2));
624+
625+
let cred_a = register_with_large_blob(&mut channel, "alice", &challenge).await;
626+
let cred_b = register_with_large_blob(&mut channel, "bob", &challenge).await;
627+
628+
let mut two = ga_request(
629+
&cred_a,
630+
&challenge,
631+
GetAssertionLargeBlobExtension::Write(b"blob".to_vec()),
632+
);
633+
two.allow = vec![cred_a.clone(), cred_b];
634+
let err = channel
635+
.webauthn_get_assertion(&two)
636+
.await
637+
.expect_err("write with two allowCredentials must be rejected");
638+
assert_eq!(err, Error::Platform(PlatformError::NotSupported));
639+
640+
let mut none = ga_request(
641+
&cred_a,
642+
&challenge,
643+
GetAssertionLargeBlobExtension::Write(b"blob".to_vec()),
644+
);
645+
none.allow = vec![];
646+
let err = channel
647+
.webauthn_get_assertion(&none)
648+
.await
649+
.expect_err("write with empty allowCredentials must be rejected");
650+
assert_eq!(err, Error::Platform(PlatformError::NotSupported));
651+
652+
update_handle.await.unwrap();
653+
}
654+
655+
/// Replacing a blob with a strictly smaller one shrinks the stored array: the
656+
/// read-modify-write rebuild must drop the larger entry entirely so the read
657+
/// returns exactly the smaller blob (no stale trailing bytes).
658+
#[test(tokio::test)]
659+
async fn test_webauthn_large_blob_write_replaces_with_smaller_blob() {
660+
let mut device = get_virtual_device();
661+
let mut channel = device.channel(ChannelSettings::default()).await.unwrap();
662+
let challenge: [u8; 32] = thread_rng().gen();
663+
664+
let state_recv = channel.get_ux_update_receiver();
665+
// MakeCredential + write(large) + read + write(small) + read = 5 PresenceRequired updates.
666+
let update_handle = tokio::spawn(handle_updates_n(state_recv, 5));
667+
668+
let credential = register_with_large_blob(&mut channel, "erin", &challenge).await;
669+
670+
let large = b"compressible largeBlob payload ".repeat(20);
671+
let small = b"tiny".to_vec();
672+
673+
channel
674+
.webauthn_get_assertion(&ga_request(
675+
&credential,
676+
&challenge,
677+
GetAssertionLargeBlobExtension::Write(large.clone()),
678+
))
679+
.await
680+
.expect("large write");
681+
682+
let read_large = channel
683+
.webauthn_get_assertion(&ga_request(
684+
&credential,
685+
&challenge,
686+
GetAssertionLargeBlobExtension::Read,
687+
))
688+
.await
689+
.expect("read after large write");
690+
let blob_large = read_large.assertions[0]
691+
.unsigned_extensions_output
692+
.as_ref()
693+
.and_then(|u| u.large_blob.as_ref())
694+
.and_then(|lb| lb.blob.as_ref())
695+
.expect("blob present after large write");
696+
assert_eq!(blob_large.as_slice(), large.as_slice());
697+
698+
channel
699+
.webauthn_get_assertion(&ga_request(
700+
&credential,
701+
&challenge,
702+
GetAssertionLargeBlobExtension::Write(small.clone()),
703+
))
704+
.await
705+
.expect("small write");
706+
707+
let read_small = channel
708+
.webauthn_get_assertion(&ga_request(
709+
&credential,
710+
&challenge,
711+
GetAssertionLargeBlobExtension::Read,
712+
))
713+
.await
714+
.expect("read after small write");
715+
let blob_small = read_small.assertions[0]
716+
.unsigned_extensions_output
717+
.as_ref()
718+
.and_then(|u| u.large_blob.as_ref())
719+
.and_then(|lb| lb.blob.as_ref())
720+
.expect("blob present after small write");
721+
assert_eq!(
722+
blob_small.as_slice(),
723+
small.as_slice(),
724+
"smaller write replaced larger"
725+
);
726+
727+
update_handle.await.unwrap();
728+
}

libwebauthn/src/ops/webauthn/get_assertion.rs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ impl FromIdlModel<PublicKeyCredentialRequestOptionsJSON> for GetAssertionRequest
214214
.and_then(|e| e.large_blob.as_ref())
215215
{
216216
// L3 §10.1.5: largeBlob without read=true or support is a no-op, not an error.
217-
Some(lb) if lb.support.is_none() && lb.read != Some(true) => None,
217+
Some(lb) if lb.support.is_none() && lb.read != Some(true) && lb.write.is_none() => None,
218218
Some(lb) => Some(GetAssertionLargeBlobExtension::try_from(lb.clone())?),
219219
None => None,
220220
};
@@ -353,8 +353,8 @@ impl TryFrom<LargeBlobInputJson> for GetAssertionLargeBlobExtension {
353353
"largeBlob.support is only valid at registration".to_string(),
354354
));
355355
}
356-
// WebAuthn L3 §10.1.5: read and write are mutually exclusive.
357-
if value.read == Some(true) && value.write.is_some() {
356+
// WebAuthn L3 §10.1.5: read and write present together is an error.
357+
if value.read.is_some() && value.write.is_some() {
358358
return Err(GetAssertionPrepareError::NotSupported(
359359
"largeBlob.read and largeBlob.write are mutually exclusive".to_string(),
360360
));
@@ -604,6 +604,15 @@ impl DowngradableRequest<Vec<SignRequest>> for GetAssertionRequest {
604604
return false;
605605
}
606606

607+
if matches!(
608+
self.extensions.as_ref().and_then(|e| e.large_blob.as_ref()),
609+
Some(GetAssertionLargeBlobExtension::Write(_))
610+
| Some(GetAssertionLargeBlobExtension::Delete)
611+
) {
612+
debug!("Not downgradable: largeBlob write/delete requires FIDO2");
613+
return false;
614+
}
615+
607616
true
608617
}
609618

@@ -1409,6 +1418,7 @@ mod tests {
14091418
GetAssertionLargeBlobExtension::try_from(LargeBlobInputJson {
14101419
support: None,
14111420
read: Some(true),
1421+
write: None,
14121422
})
14131423
.unwrap(),
14141424
GetAssertionLargeBlobExtension::Read
@@ -1417,6 +1427,7 @@ mod tests {
14171427
GetAssertionLargeBlobExtension::try_from(LargeBlobInputJson {
14181428
support: Some("required".to_string()),
14191429
read: Some(true),
1430+
write: None,
14201431
}),
14211432
Err(GetAssertionPrepareError::NotSupported(_))
14221433
));
@@ -1453,4 +1464,28 @@ mod tests {
14531464
.expect("largeBlob.read=false must be a no-op, not an error");
14541465
assert!(req.extensions.and_then(|e| e.large_blob).is_none());
14551466
}
1467+
1468+
#[test]
1469+
fn large_blob_json_write_input_and_mutual_exclusion() {
1470+
use crate::ops::webauthn::idl::get::LargeBlobInputJson;
1471+
1472+
let blob = b"blob to write".to_vec();
1473+
assert_eq!(
1474+
GetAssertionLargeBlobExtension::try_from(LargeBlobInputJson {
1475+
support: None,
1476+
read: None,
1477+
write: Some(Base64UrlString::from(blob.clone())),
1478+
})
1479+
.unwrap(),
1480+
GetAssertionLargeBlobExtension::Write(blob)
1481+
);
1482+
assert!(matches!(
1483+
GetAssertionLargeBlobExtension::try_from(LargeBlobInputJson {
1484+
support: None,
1485+
read: Some(true),
1486+
write: Some(Base64UrlString::from(b"x".to_vec())),
1487+
}),
1488+
Err(GetAssertionPrepareError::NotSupported(_))
1489+
));
1490+
}
14561491
}

0 commit comments

Comments
 (0)