Skip to content

Commit ad4b514

Browse files
fix(webauthn): allow PRF inputs of any length (fix #209)
Split PRFValue into PrfInputValue (Vec<u8>) for variable-length salt inputs and PrfOutputValue ([u8; 32]) for the fixed-size hmac-secret output. Per W3C WebAuthn L3 §10.1.4, PRF salt inputs are BufferSources of any length; the existing SHA-256 prefix-hashing already produces a 32-byte salt for CTAP2 hmac-secret regardless of input length. Adds unit tests for variable-length and empty inputs, and a virtual- device test exercising 0-byte, 7-byte, and 100-byte salts.
1 parent 9099de3 commit ad4b514

8 files changed

Lines changed: 339 additions & 208 deletions

File tree

libwebauthn-tests/tests/prf.rs

Lines changed: 175 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::time::Duration;
33

44
use libwebauthn::ops::webauthn::{
55
GetAssertionRequest, GetAssertionRequestExtensions, MakeCredentialPrfInput,
6-
MakeCredentialPrfOutput, MakeCredentialsRequestExtensions, PRFValue, PrfInput,
6+
MakeCredentialPrfOutput, MakeCredentialsRequestExtensions, PrfInput, PrfInputValue,
77
};
88
use libwebauthn::pin::PinManagement;
99
use libwebauthn::proto::ctap2::{Ctap2PinUvAuthProtocol, Ctap2PublicKeyCredentialDescriptor};
@@ -189,8 +189,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
189189
let mut eval_by_credential = HashMap::new();
190190
eval_by_credential.insert(
191191
base64_url::encode(&credential.id),
192-
PRFValue {
193-
first: [1; 32],
192+
PrfInputValue {
193+
first: vec![1; 32],
194194
second: None,
195195
},
196196
);
@@ -210,16 +210,16 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
210210
.await;
211211

212212
// Test 2: eval and eval_with_credential with cred_id we got
213-
let eval = Some(PRFValue {
214-
first: [2; 32],
213+
let eval = Some(PrfInputValue {
214+
first: vec![2; 32],
215215
second: None,
216216
});
217217

218218
let mut eval_by_credential = HashMap::new();
219219
eval_by_credential.insert(
220220
base64_url::encode(&credential.id),
221-
PRFValue {
222-
first: [1; 32],
221+
PrfInputValue {
222+
first: vec![1; 32],
223223
second: None,
224224
},
225225
);
@@ -239,8 +239,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
239239
.await;
240240

241241
// Test 3: eval only
242-
let eval = Some(PRFValue {
243-
first: [1; 32],
242+
let eval = Some(PrfInputValue {
243+
first: vec![1; 32],
244244
second: None,
245245
});
246246

@@ -261,38 +261,38 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
261261
.await;
262262

263263
// Test 4: eval and a full list of eval_by_credential
264-
let eval = Some(PRFValue {
265-
first: [2; 32],
264+
let eval = Some(PrfInputValue {
265+
first: vec![2; 32],
266266
second: None,
267267
});
268268

269269
let mut eval_by_credential = HashMap::new();
270270
eval_by_credential.insert(
271271
base64_url::encode(&[5; 54]),
272-
PRFValue {
273-
first: [5; 32],
272+
PrfInputValue {
273+
first: vec![5; 32],
274274
second: None,
275275
},
276276
);
277277
eval_by_credential.insert(
278278
base64_url::encode(&[7; 54]),
279-
PRFValue {
280-
first: [7; 32],
281-
second: Some([7; 32]),
279+
PrfInputValue {
280+
first: vec![7; 32],
281+
second: Some(vec![7; 32]),
282282
},
283283
);
284284
eval_by_credential.insert(
285285
base64_url::encode(&[8; 54]),
286-
PRFValue {
287-
first: [8; 32],
288-
second: Some([8; 32]),
286+
PrfInputValue {
287+
first: vec![8; 32],
288+
second: Some(vec![8; 32]),
289289
},
290290
);
291291
eval_by_credential.insert(
292292
base64_url::encode(&credential.id),
293-
PRFValue {
294-
first: [1; 32],
295-
second: Some([7; 32]),
293+
PrfInputValue {
294+
first: vec![1; 32],
295+
second: Some(vec![7; 32]),
296296
},
297297
);
298298
let prf = PrfInput {
@@ -311,31 +311,31 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
311311
.await;
312312

313313
// Test 5: eval and non-fitting list of eval_by_credential
314-
let eval = Some(PRFValue {
315-
first: [1; 32],
314+
let eval = Some(PrfInputValue {
315+
first: vec![1; 32],
316316
second: None,
317317
});
318318

319319
let mut eval_by_credential = HashMap::new();
320320
eval_by_credential.insert(
321321
base64_url::encode(&[5; 54]),
322-
PRFValue {
323-
first: [5; 32],
322+
PrfInputValue {
323+
first: vec![5; 32],
324324
second: None,
325325
},
326326
);
327327
eval_by_credential.insert(
328328
base64_url::encode(&[7; 54]),
329-
PRFValue {
330-
first: [7; 32],
331-
second: Some([7; 32]),
329+
PrfInputValue {
330+
first: vec![7; 32],
331+
second: Some(vec![7; 32]),
332332
},
333333
);
334334
eval_by_credential.insert(
335335
base64_url::encode(&[8; 54]),
336-
PRFValue {
337-
first: [8; 32],
338-
second: Some([8; 32]),
336+
PrfInputValue {
337+
first: vec![8; 32],
338+
second: Some(vec![8; 32]),
339339
},
340340
);
341341
let prf = PrfInput {
@@ -359,23 +359,23 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
359359
let mut eval_by_credential = HashMap::new();
360360
eval_by_credential.insert(
361361
base64_url::encode(&[5; 54]),
362-
PRFValue {
363-
first: [5; 32],
362+
PrfInputValue {
363+
first: vec![5; 32],
364364
second: None,
365365
},
366366
);
367367
eval_by_credential.insert(
368368
base64_url::encode(&[7; 54]),
369-
PRFValue {
370-
first: [7; 32],
371-
second: Some([7; 32]),
369+
PrfInputValue {
370+
first: vec![7; 32],
371+
second: Some(vec![7; 32]),
372372
},
373373
);
374374
eval_by_credential.insert(
375375
base64_url::encode(&[8; 54]),
376-
PRFValue {
377-
first: [8; 32],
378-
second: Some([8; 32]),
376+
PrfInputValue {
377+
first: vec![8; 32],
378+
second: Some(vec![8; 32]),
379379
},
380380
);
381381
let prf = PrfInput {
@@ -394,16 +394,16 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
394394
.await;
395395

396396
// Test 7: Wrongly encoded credential_id
397-
let eval = Some(PRFValue {
398-
first: [2; 32],
397+
let eval = Some(PrfInputValue {
398+
first: vec![2; 32],
399399
second: None,
400400
});
401401

402402
let mut eval_by_credential = HashMap::new();
403403
eval_by_credential.insert(
404404
String::from("ÄöoLfwekldß^"),
405-
PRFValue {
406-
first: [1; 32],
405+
PrfInputValue {
406+
first: vec![1; 32],
407407
second: None,
408408
},
409409
);
@@ -426,8 +426,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
426426
let mut eval_by_credential = HashMap::new();
427427
eval_by_credential.insert(
428428
String::new(),
429-
PRFValue {
430-
first: [1; 32],
429+
PrfInputValue {
430+
first: vec![1; 32],
431431
second: None,
432432
},
433433
);
@@ -450,8 +450,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
450450
let mut eval_by_credential = HashMap::new();
451451
eval_by_credential.insert(
452452
String::new(),
453-
PRFValue {
454-
first: [1; 32],
453+
PrfInputValue {
454+
first: vec![1; 32],
455455
second: None,
456456
},
457457
);
@@ -581,3 +581,130 @@ async fn run_failed_test(
581581
assert_eq!(response, Err(expected_error), "{printoutput}:");
582582
println!("Success for test: {printoutput}")
583583
}
584+
585+
/// W3C WebAuthn L3 §10.1.4: PRF salt inputs are `BufferSource`s of any length.
586+
/// Regression test for #209: end-to-end PRF assertion succeeds for empty,
587+
/// sub-32-byte, and super-32-byte salts, and is deterministic.
588+
#[test(tokio::test)]
589+
async fn test_webauthn_prf_variable_length_input() {
590+
let mut device = get_virtual_device();
591+
let mut channel = device.channel().await.unwrap();
592+
593+
let user_id: [u8; 32] = thread_rng().gen();
594+
let challenge: [u8; 32] = thread_rng().gen();
595+
596+
let make_credentials_request = MakeCredentialRequest {
597+
origin: "example.org".to_owned(),
598+
challenge: Vec::from(challenge),
599+
relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"),
600+
user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "mario.rossi", "Mario Rossi"),
601+
resident_key: Some(ResidentKeyRequirement::Discouraged),
602+
user_verification: UserVerificationRequirement::Preferred,
603+
algorithms: vec![Ctap2CredentialType::default()],
604+
exclude: None,
605+
extensions: Some(MakeCredentialsRequestExtensions {
606+
prf: Some(MakeCredentialPrfInput { _eval: None }),
607+
..Default::default()
608+
}),
609+
timeout: TIMEOUT,
610+
top_origin: None,
611+
};
612+
613+
let state_recv = channel.get_ux_update_receiver();
614+
let expected_updates = vec![
615+
UvUpdateShim::PresenceRequired, // MakeCredential
616+
UvUpdateShim::PresenceRequired, // assert empty
617+
UvUpdateShim::PresenceRequired, // assert 7 bytes
618+
UvUpdateShim::PresenceRequired, // assert 100 bytes
619+
UvUpdateShim::PresenceRequired, // determinism re-check (same 7 bytes)
620+
];
621+
let uv_handle = tokio::spawn(handle_updates(state_recv, expected_updates));
622+
623+
let response = channel
624+
.webauthn_make_credential(&make_credentials_request)
625+
.await
626+
.expect("Failed to register credential");
627+
let credential: Ctap2PublicKeyCredentialDescriptor =
628+
(&response.authenticator_data).try_into().unwrap();
629+
630+
async fn assert_prf(
631+
channel: &mut HidChannel<'_>,
632+
credential: &Ctap2PublicKeyCredentialDescriptor,
633+
challenge: &[u8; 32],
634+
first: Vec<u8>,
635+
label: &str,
636+
) -> [u8; 32] {
637+
let get_assertion = GetAssertionRequest {
638+
relying_party_id: "example.org".to_owned(),
639+
origin: "example.org".to_owned(),
640+
challenge: Vec::from(challenge.as_slice()),
641+
allow: vec![credential.clone()],
642+
user_verification: UserVerificationRequirement::Preferred,
643+
extensions: Some(GetAssertionRequestExtensions {
644+
prf: Some(PrfInput {
645+
eval: Some(PrfInputValue {
646+
first,
647+
second: None,
648+
}),
649+
eval_by_credential: HashMap::new(),
650+
}),
651+
..Default::default()
652+
}),
653+
timeout: TIMEOUT,
654+
top_origin: None,
655+
};
656+
let response = channel
657+
.webauthn_get_assertion(&get_assertion)
658+
.await
659+
.unwrap_or_else(|_| panic!("get_assertion failed: {label}"));
660+
let results = response.assertions[0]
661+
.unsigned_extensions_output
662+
.as_ref()
663+
.unwrap_or_else(|| panic!("no unsigned ext: {label}"))
664+
.prf
665+
.as_ref()
666+
.unwrap_or_else(|| panic!("no prf: {label}"))
667+
.results
668+
.as_ref()
669+
.unwrap_or_else(|| panic!("no results: {label}"));
670+
assert_ne!(results.first, [0u8; 32], "{label}");
671+
assert!(results.second.is_none(), "{label}");
672+
results.first
673+
}
674+
675+
let empty = assert_prf(&mut channel, &credential, &challenge, vec![], "empty").await;
676+
let short = assert_prf(
677+
&mut channel,
678+
&credential,
679+
&challenge,
680+
vec![0xAB; 7],
681+
"7 bytes",
682+
)
683+
.await;
684+
let long = assert_prf(
685+
&mut channel,
686+
&credential,
687+
&challenge,
688+
vec![0xCD; 100],
689+
"100 bytes",
690+
)
691+
.await;
692+
let short_again = assert_prf(
693+
&mut channel,
694+
&credential,
695+
&challenge,
696+
vec![0xAB; 7],
697+
"7 bytes (repeat)",
698+
)
699+
.await;
700+
701+
// Different inputs hash to different salts and therefore yield distinct outputs.
702+
assert_ne!(empty, short);
703+
assert_ne!(short, long);
704+
assert_ne!(empty, long);
705+
// Same input → same output: PRF is deterministic per (credential, salt).
706+
assert_eq!(short, short_again);
707+
708+
let mut state_recv = uv_handle.await.unwrap();
709+
assert_eq!(state_recv.try_recv(), Err(TryRecvError::Empty));
710+
}

0 commit comments

Comments
 (0)