Skip to content

Commit c70b88a

Browse files
test(libwebauthn-tests): integration test for largeBlob read
Enables largeBlobs storage on the virt fido-authenticator and adds an end-to-end test: registers a credential, plants an encrypted entry directly via authenticatorLargeBlobs(set), then asserts webauthn_get_assertion with largeBlob.read surfaces the plaintext. Requires the chunked feature on fido-authenticator to lift the 1024-byte single-message cap on the device-wide array, and a non-zero max_msg_size in the virt config so the per-chunk length check passes.
1 parent f37fb47 commit c70b88a

3 files changed

Lines changed: 233 additions & 3 deletions

File tree

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

0 commit comments

Comments
 (0)