Skip to content

Commit 60d8d95

Browse files
feat(webauthn): largeBlob write and delete (#261)
Adds the WebAuthn L3 largeBlob write extension and a libwebauthn-side delete operation on top of CTAP 2.2 authenticatorLargeBlobs. After an assertion yields the per-credential key, the platform fetches the on-authenticator array, replaces or erases the credential's own entry, and re-uploads it in chunks while preserving other credentials' blobs. Write and delete failures are non-fatal and surface as written=false.
1 parent 14895c1 commit 60d8d95

10 files changed

Lines changed: 1872 additions & 88 deletions

File tree

libwebauthn-tests/tests/large_blob.rs

Lines changed: 506 additions & 3 deletions
Large diffs are not rendered by default.

libwebauthn/src/ops/webauthn/get_assertion.rs

Lines changed: 55 additions & 12 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
};
@@ -336,9 +336,12 @@ impl TryFrom<HmacGetSecretInputJson> for HMACGetSecretInput {
336336

337337
#[derive(Debug, Clone, PartialEq, Eq)]
338338
pub enum GetAssertionLargeBlobExtension {
339+
/// Per WebAuthn L3 §10.1.5 (read=true): fetch the credential's blob.
339340
Read,
340-
// Not yet supported
341-
// Write(Vec<u8>),
341+
/// Per WebAuthn L3 §10.1.5 (write=ArrayBuffer): store this blob against the credential.
342+
Write(Vec<u8>),
343+
/// CTAP 2.2 §6.10.6 erase branch. Not exposed via WebAuthn L3 JSON IDL.
344+
Delete,
342345
}
343346

344347
impl TryFrom<LargeBlobInputJson> for GetAssertionLargeBlobExtension {
@@ -350,13 +353,19 @@ impl TryFrom<LargeBlobInputJson> for GetAssertionLargeBlobExtension {
350353
"largeBlob.support is only valid at registration".to_string(),
351354
));
352355
}
356+
// WebAuthn L3 §10.1.5: read and write present together is an error.
357+
if value.read.is_some() && value.write.is_some() {
358+
return Err(GetAssertionPrepareError::NotSupported(
359+
"largeBlob.read and largeBlob.write are mutually exclusive".to_string(),
360+
));
361+
}
362+
if let Some(write) = value.write {
363+
return Ok(GetAssertionLargeBlobExtension::Write(write.to_vec()));
364+
}
353365
match value.read {
354366
Some(true) => Ok(GetAssertionLargeBlobExtension::Read),
355-
Some(false) => Err(GetAssertionPrepareError::NotSupported(
356-
"largeBlob writes not supported".to_string(),
357-
)),
358-
None => Err(GetAssertionPrepareError::NotSupported(
359-
"largeBlob read not requested".to_string(),
367+
_ => Err(GetAssertionPrepareError::NotSupported(
368+
"largeBlob input must set read=true or write".to_string(),
360369
)),
361370
}
362371
}
@@ -366,9 +375,8 @@ impl TryFrom<LargeBlobInputJson> for GetAssertionLargeBlobExtension {
366375
pub struct GetAssertionLargeBlobExtensionOutput {
367376
#[serde(skip_serializing_if = "Option::is_none")]
368377
pub blob: Option<Vec<u8>>,
369-
// Not yet supported
370-
// #[serde(skip_serializing_if = "Option::is_none")]
371-
// pub written: Option<bool>,
378+
#[serde(skip_serializing_if = "Option::is_none")]
379+
pub written: Option<bool>,
372380
}
373381

374382
#[derive(Debug, Default, Clone, PartialEq)]
@@ -543,7 +551,7 @@ impl Assertion {
543551
.blob
544552
.as_ref()
545553
.map(|b| Base64UrlString::from(b.as_slice())),
546-
written: None, // Write not yet supported
554+
written: large_blob.written,
547555
});
548556
}
549557

@@ -596,6 +604,15 @@ impl DowngradableRequest<Vec<SignRequest>> for GetAssertionRequest {
596604
return false;
597605
}
598606

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+
599616
true
600617
}
601618

@@ -1401,6 +1418,7 @@ mod tests {
14011418
GetAssertionLargeBlobExtension::try_from(LargeBlobInputJson {
14021419
support: None,
14031420
read: Some(true),
1421+
write: None,
14041422
})
14051423
.unwrap(),
14061424
GetAssertionLargeBlobExtension::Read
@@ -1409,6 +1427,7 @@ mod tests {
14091427
GetAssertionLargeBlobExtension::try_from(LargeBlobInputJson {
14101428
support: Some("required".to_string()),
14111429
read: Some(true),
1430+
write: None,
14121431
}),
14131432
Err(GetAssertionPrepareError::NotSupported(_))
14141433
));
@@ -1445,4 +1464,28 @@ mod tests {
14451464
.expect("largeBlob.read=false must be a no-op, not an error");
14461465
assert!(req.extensions.and_then(|e| e.large_blob).is_none());
14471466
}
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+
}
14481491
}

libwebauthn/src/ops/webauthn/idl/get.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ pub struct GetAssertionRequestExtensionsJSON {
6363
pub struct LargeBlobInputJson {
6464
pub support: Option<String>,
6565
pub read: Option<bool>,
66+
pub write: Option<Base64UrlString>,
6667
}
6768

6869
#[derive(Debug, Clone, Deserialize)]

0 commit comments

Comments
 (0)