Skip to content

Commit 14895c1

Browse files
feat(webauthn): largeBlob read via authenticatorLargeBlobs (#206)
Implements the read half of the WebAuthn L3 `largeBlob` extension end to end. Until now the library could request a per-credential key from the authenticator but had no way to actually fetch and decrypt the stored blob, so the read output was never populated. After an assertion succeeds and yields a per-credential AES key, the platform paginates `authenticatorLargeBlobs(get)`, AES-256-GCM-authenticates each on-device entry under that key, and RFC 1951 raw-deflate decompresses the plaintext into the assertion response. Read failures are non-fatal and surface as an absent blob, per WebAuthn L3 §10.1.5. ## Spec compliance - chunk size honours `maxFragmentLength = maxMsgSize - 64` from GetInfo (CTAP 2.1 §6.10.2) - `origSize` is capped at 1 MiB to bound platform allocation - per-entry structural problems are skipped, not propagated, since the on-device array is shared across credentials ## Tests - unit tests cover AES-256-GCM round-trip, wrong-key skip, multi-entry selection, corrupted-trailer rejection, malformed-entry skip, and oversized-`origSize` skip - a mock-channel end-to-end test exercises the full read path - a new integration test against the virt fido-authenticator registers a credential, plants an encrypted blob via the CTAP set, and asserts the platform fetches and decrypts on the next assertion ## Follow-ups The write half lands in the next PR: chunked CTAP upload with `pinUvAuthParam`, fetch-modify-write of the on-device array, and the WebAuthn `largeBlob.write` extension wiring. ## Spec references - WebAuthn L3 §10.1.5: https://www.w3.org/TR/webauthn-3/#sctn-large-blob-extension - CTAP 2.1 §6.10: https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#authenticatorLargeBlobs - RFC 1951 (DEFLATE): https://www.rfc-editor.org/rfc/rfc1951
1 parent 14afd1f commit 14895c1

15 files changed

Lines changed: 1670 additions & 14 deletions

File tree

Cargo.lock

Lines changed: 49 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

libwebauthn-tests/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ cosey = "0.3.2"
1212
ctaphid = { version = "0.3.1", default-features = false }
1313
ctaphid-dispatch = "0.3"
1414
delog = { version = "0.1", features = ["std-log"] }
15-
fido-authenticator = { git = "https://github.com/Nitrokey/fido-authenticator.git", tag = "v0.1.1-nitrokey.27", features = ["dispatch", "log-all"] }
15+
fido-authenticator = { git = "https://github.com/Nitrokey/fido-authenticator.git", tag = "v0.1.1-nitrokey.27", features = ["chunked", "dispatch", "log-all"] }
1616
interchange = "0.3.0"
1717
littlefs2 = "0.6.0"
1818
num_enum = "0.7.1"
@@ -22,8 +22,12 @@ trussed = { version = "0.1", features = ["virt"] }
2222
trussed-staging = { version = "0.3.0", features = ["chunked", "hkdf", "virt", "fs-info"] }
2323

2424
[dev-dependencies]
25+
aes-gcm = "0.10"
2526
base64-url = "3.0.0"
27+
flate2 = "1.0"
2628
serde_bytes = "0.11.5"
29+
serde_cbor_2 = "0.13"
30+
sha2 = "0.10"
2731
tempfile = "3.21"
2832
test-log = "0.2"
2933
tokio = { version = "1.45", features = ["full"] }

libwebauthn-tests/src/virt/device.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,13 @@ where
6666
client,
6767
Conforming {},
6868
Config {
69-
max_msg_size: 0,
69+
max_msg_size: 1200,
7070
skip_up_timeout: None,
7171
max_resident_credential_count: options.max_resident_credential_count,
72-
large_blobs: None,
72+
large_blobs: Some(fido_authenticator::LargeBlobsConfig {
73+
location: trussed::types::Location::Internal,
74+
max_size: 4096,
75+
}),
7376
nfc_transport: false,
7477
},
7578
);
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
//! End-to-end test of WebAuthn largeBlob.read against the virt authenticator.
2+
3+
use std::time::Duration;
4+
5+
use libwebauthn::ops::webauthn::{
6+
GetAssertionLargeBlobExtension, GetAssertionRequest, GetAssertionRequestExtensions,
7+
MakeCredentialLargeBlobExtension, MakeCredentialLargeBlobExtensionInput, MakeCredentialRequest,
8+
MakeCredentialsRequestExtensions, ResidentKeyRequirement, UserVerificationRequirement,
9+
};
10+
use libwebauthn::proto::ctap2::{
11+
Ctap2, Ctap2CredentialType, Ctap2GetAssertionRequest, Ctap2LargeBlobsRequest,
12+
Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity,
13+
Ctap2PublicKeyCredentialUserEntity,
14+
};
15+
use libwebauthn::transport::{Channel, ChannelSettings, Device};
16+
use libwebauthn::webauthn::WebAuthn;
17+
use libwebauthn::UvUpdate;
18+
use libwebauthn_tests::virt::get_virtual_device;
19+
use rand::{thread_rng, Rng};
20+
use test_log::test;
21+
use tokio::sync::broadcast::Receiver;
22+
23+
const TIMEOUT: Duration = Duration::from_secs(10);
24+
const RP: &str = "example.org";
25+
26+
async fn handle_updates(mut state_recv: Receiver<UvUpdate>) {
27+
// MakeCredential update
28+
assert_eq!(state_recv.recv().await, Ok(UvUpdate::PresenceRequired));
29+
// GetAssertion update
30+
assert_eq!(state_recv.recv().await, Ok(UvUpdate::PresenceRequired));
31+
}
32+
33+
#[test(tokio::test)]
34+
async fn test_webauthn_large_blob_read_returns_planted_blob() {
35+
let mut device = get_virtual_device();
36+
let mut channel = device.channel(ChannelSettings::default()).await.unwrap();
37+
38+
let user_id: [u8; 32] = thread_rng().gen();
39+
let challenge: [u8; 32] = thread_rng().gen();
40+
41+
let make = MakeCredentialRequest {
42+
origin: RP.into(),
43+
challenge: challenge.to_vec(),
44+
relying_party: Ctap2PublicKeyCredentialRpEntity::new(RP, RP),
45+
user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "alice", "Alice"),
46+
resident_key: Some(ResidentKeyRequirement::Required),
47+
user_verification: UserVerificationRequirement::Discouraged,
48+
algorithms: vec![Ctap2CredentialType::default()],
49+
exclude: None,
50+
extensions: Some(MakeCredentialsRequestExtensions {
51+
large_blob: Some(MakeCredentialLargeBlobExtensionInput {
52+
support: MakeCredentialLargeBlobExtension::Required,
53+
}),
54+
..Default::default()
55+
}),
56+
timeout: TIMEOUT,
57+
top_origin: None,
58+
};
59+
60+
let state_recv = channel.get_ux_update_receiver();
61+
let update_handle = tokio::spawn(handle_updates(state_recv));
62+
63+
let response = channel
64+
.webauthn_make_credential(&make)
65+
.await
66+
.expect("MakeCredential should succeed");
67+
assert_eq!(
68+
response
69+
.unsigned_extensions_output
70+
.large_blob
71+
.as_ref()
72+
.and_then(|lb| lb.supported),
73+
Some(true),
74+
"device must report largeBlob.supported=true"
75+
);
76+
let credential: Ctap2PublicKeyCredentialDescriptor =
77+
(&response.authenticator_data).try_into().unwrap();
78+
79+
let key = capture_large_blob_key(&mut channel, &credential, &challenge).await;
80+
let plaintext = b"hello, planted largeBlob".to_vec();
81+
let nonce: [u8; 12] = thread_rng().gen();
82+
let serialized = encode_serialized_array(&[encode_entry(&key, &nonce, &plaintext)]);
83+
plant_large_blob_array(&mut channel, serialized).await;
84+
85+
let ga = GetAssertionRequest {
86+
relying_party_id: RP.into(),
87+
origin: RP.into(),
88+
challenge: challenge.to_vec(),
89+
allow: vec![credential],
90+
user_verification: UserVerificationRequirement::Discouraged,
91+
extensions: Some(GetAssertionRequestExtensions {
92+
appid: None,
93+
cred_blob: false,
94+
prf: None,
95+
large_blob: Some(GetAssertionLargeBlobExtension::Read),
96+
}),
97+
timeout: TIMEOUT,
98+
top_origin: None,
99+
};
100+
let ga_response = channel
101+
.webauthn_get_assertion(&ga)
102+
.await
103+
.expect("GetAssertion should succeed");
104+
let blob = ga_response.assertions[0]
105+
.unsigned_extensions_output
106+
.as_ref()
107+
.and_then(|u| u.large_blob.as_ref())
108+
.and_then(|lb| lb.blob.as_ref())
109+
.expect("largeBlob.blob populated");
110+
assert_eq!(blob.as_slice(), plaintext.as_slice());
111+
112+
update_handle.await.unwrap();
113+
}
114+
115+
/// Capture the per-credential AES key via a direct CTAP GetAssertion.
116+
async fn capture_large_blob_key(
117+
channel: &mut libwebauthn::transport::hid::channel::HidChannel<'_>,
118+
credential: &Ctap2PublicKeyCredentialDescriptor,
119+
challenge: &[u8; 32],
120+
) -> [u8; 32] {
121+
let ga_for_key = GetAssertionRequest {
122+
relying_party_id: RP.into(),
123+
origin: RP.into(),
124+
challenge: challenge.to_vec(),
125+
allow: vec![credential.clone()],
126+
user_verification: UserVerificationRequirement::Discouraged,
127+
extensions: Some(GetAssertionRequestExtensions {
128+
appid: None,
129+
cred_blob: false,
130+
prf: None,
131+
large_blob: Some(GetAssertionLargeBlobExtension::Read),
132+
}),
133+
timeout: TIMEOUT,
134+
top_origin: None,
135+
};
136+
let ctap_req: Ctap2GetAssertionRequest = ga_for_key.into();
137+
let ctap_resp = channel
138+
.ctap2_get_assertion(&ctap_req, TIMEOUT)
139+
.await
140+
.expect("CTAP get_assertion succeeds");
141+
let key_buf = ctap_resp
142+
.large_blob_key
143+
.expect("device returns largeBlobKey when extension requested");
144+
key_buf
145+
.as_slice()
146+
.try_into()
147+
.expect("largeBlobKey is 32 bytes")
148+
}
149+
150+
/// Plant a serialized largeBlobArray via direct CTAP set (no-PIN path).
151+
async fn plant_large_blob_array(
152+
channel: &mut libwebauthn::transport::hid::channel::HidChannel<'_>,
153+
serialized: Vec<u8>,
154+
) {
155+
let length = serialized.len() as u32;
156+
let req = Ctap2LargeBlobsRequest {
157+
get: None,
158+
set: Some(serde_bytes::ByteBuf::from(serialized)),
159+
offset: 0,
160+
length: Some(length),
161+
pin_uv_auth_param: None,
162+
pin_uv_auth_protocol: None,
163+
};
164+
channel
165+
.ctap2_large_blobs(&req, TIMEOUT)
166+
.await
167+
.expect("authenticatorLargeBlobs(set) succeeds without PIN");
168+
}
169+
170+
/// Encode one largeBlobMap entry per CTAP 2.1 §6.10.3.
171+
fn encode_entry(key: &[u8; 32], nonce: &[u8; 12], plaintext: &[u8]) -> Vec<u8> {
172+
use aes_gcm::aead::Aead;
173+
use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
174+
use flate2::write::DeflateEncoder;
175+
use flate2::Compression;
176+
use serde_cbor_2::value::Value as CborVal;
177+
use std::collections::BTreeMap;
178+
use std::io::Write;
179+
180+
let mut compressed = Vec::new();
181+
{
182+
let mut enc = DeflateEncoder::new(&mut compressed, Compression::default());
183+
enc.write_all(plaintext).unwrap();
184+
enc.finish().unwrap();
185+
}
186+
let mut ad = b"blob".to_vec();
187+
ad.extend_from_slice(&(plaintext.len() as u64).to_le_bytes());
188+
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
189+
let ciphertext = cipher
190+
.encrypt(
191+
Nonce::from_slice(nonce),
192+
aes_gcm::aead::Payload {
193+
msg: &compressed,
194+
aad: &ad,
195+
},
196+
)
197+
.unwrap();
198+
199+
let mut map = BTreeMap::new();
200+
map.insert(CborVal::Integer(1), CborVal::Bytes(ciphertext));
201+
map.insert(CborVal::Integer(2), CborVal::Bytes(nonce.to_vec()));
202+
map.insert(
203+
CborVal::Integer(3),
204+
CborVal::Integer(plaintext.len() as i128),
205+
);
206+
let mut buf = Vec::new();
207+
serde_cbor_2::to_writer(&mut buf, &CborVal::Map(map)).unwrap();
208+
buf
209+
}
210+
211+
/// Wrap entries in a CBOR array + 16-byte left-SHA-256 trailer (CTAP 2.1 §6.10.3).
212+
fn encode_serialized_array(entries: &[Vec<u8>]) -> Vec<u8> {
213+
use sha2::{Digest, Sha256};
214+
assert!(
215+
entries.len() <= 23,
216+
"test fixture uses short-form CBOR array"
217+
);
218+
let mut out = vec![0x80 | entries.len() as u8];
219+
for e in entries {
220+
out.extend_from_slice(e);
221+
}
222+
let h = Sha256::digest(&out);
223+
out.extend_from_slice(&h[..16]);
224+
out
225+
}

0 commit comments

Comments
 (0)