From ad4b514be33c68050a128a3276535b62199e95bb Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:21:48 +0100 Subject: [PATCH 1/2] fix(webauthn): allow PRF inputs of any length (fix #209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split PRFValue into PrfInputValue (Vec) 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. --- libwebauthn-tests/tests/prf.rs | 223 ++++++++++++++---- libwebauthn/examples/features/prf_replay.rs | 17 +- .../features/webauthn_extensions_hid.rs | 6 +- .../examples/features/webauthn_prf_hid.rs | 94 ++++---- libwebauthn/src/ops/webauthn/get_assertion.rs | 169 ++++++------- libwebauthn/src/ops/webauthn/mod.rs | 2 +- .../src/proto/ctap2/model/get_assertion.rs | 14 +- libwebauthn/src/webauthn/pin_uv_auth_token.rs | 22 +- 8 files changed, 339 insertions(+), 208 deletions(-) diff --git a/libwebauthn-tests/tests/prf.rs b/libwebauthn-tests/tests/prf.rs index 2223283..4f4af13 100644 --- a/libwebauthn-tests/tests/prf.rs +++ b/libwebauthn-tests/tests/prf.rs @@ -3,7 +3,7 @@ use std::time::Duration; use libwebauthn::ops::webauthn::{ GetAssertionRequest, GetAssertionRequestExtensions, MakeCredentialPrfInput, - MakeCredentialPrfOutput, MakeCredentialsRequestExtensions, PRFValue, PrfInput, + MakeCredentialPrfOutput, MakeCredentialsRequestExtensions, PrfInput, PrfInputValue, }; use libwebauthn::pin::PinManagement; use libwebauthn::proto::ctap2::{Ctap2PinUvAuthProtocol, Ctap2PublicKeyCredentialDescriptor}; @@ -189,8 +189,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( base64_url::encode(&credential.id), - PRFValue { - first: [1; 32], + PrfInputValue { + first: vec![1; 32], second: None, }, ); @@ -210,16 +210,16 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { .await; // Test 2: eval and eval_with_credential with cred_id we got - let eval = Some(PRFValue { - first: [2; 32], + let eval = Some(PrfInputValue { + first: vec![2; 32], second: None, }); let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( base64_url::encode(&credential.id), - PRFValue { - first: [1; 32], + PrfInputValue { + first: vec![1; 32], second: None, }, ); @@ -239,8 +239,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { .await; // Test 3: eval only - let eval = Some(PRFValue { - first: [1; 32], + let eval = Some(PrfInputValue { + first: vec![1; 32], second: None, }); @@ -261,38 +261,38 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { .await; // Test 4: eval and a full list of eval_by_credential - let eval = Some(PRFValue { - first: [2; 32], + let eval = Some(PrfInputValue { + first: vec![2; 32], second: None, }); let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( base64_url::encode(&[5; 54]), - PRFValue { - first: [5; 32], + PrfInputValue { + first: vec![5; 32], second: None, }, ); eval_by_credential.insert( base64_url::encode(&[7; 54]), - PRFValue { - first: [7; 32], - second: Some([7; 32]), + PrfInputValue { + first: vec![7; 32], + second: Some(vec![7; 32]), }, ); eval_by_credential.insert( base64_url::encode(&[8; 54]), - PRFValue { - first: [8; 32], - second: Some([8; 32]), + PrfInputValue { + first: vec![8; 32], + second: Some(vec![8; 32]), }, ); eval_by_credential.insert( base64_url::encode(&credential.id), - PRFValue { - first: [1; 32], - second: Some([7; 32]), + PrfInputValue { + first: vec![1; 32], + second: Some(vec![7; 32]), }, ); let prf = PrfInput { @@ -311,31 +311,31 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { .await; // Test 5: eval and non-fitting list of eval_by_credential - let eval = Some(PRFValue { - first: [1; 32], + let eval = Some(PrfInputValue { + first: vec![1; 32], second: None, }); let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( base64_url::encode(&[5; 54]), - PRFValue { - first: [5; 32], + PrfInputValue { + first: vec![5; 32], second: None, }, ); eval_by_credential.insert( base64_url::encode(&[7; 54]), - PRFValue { - first: [7; 32], - second: Some([7; 32]), + PrfInputValue { + first: vec![7; 32], + second: Some(vec![7; 32]), }, ); eval_by_credential.insert( base64_url::encode(&[8; 54]), - PRFValue { - first: [8; 32], - second: Some([8; 32]), + PrfInputValue { + first: vec![8; 32], + second: Some(vec![8; 32]), }, ); let prf = PrfInput { @@ -359,23 +359,23 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( base64_url::encode(&[5; 54]), - PRFValue { - first: [5; 32], + PrfInputValue { + first: vec![5; 32], second: None, }, ); eval_by_credential.insert( base64_url::encode(&[7; 54]), - PRFValue { - first: [7; 32], - second: Some([7; 32]), + PrfInputValue { + first: vec![7; 32], + second: Some(vec![7; 32]), }, ); eval_by_credential.insert( base64_url::encode(&[8; 54]), - PRFValue { - first: [8; 32], - second: Some([8; 32]), + PrfInputValue { + first: vec![8; 32], + second: Some(vec![8; 32]), }, ); let prf = PrfInput { @@ -394,16 +394,16 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { .await; // Test 7: Wrongly encoded credential_id - let eval = Some(PRFValue { - first: [2; 32], + let eval = Some(PrfInputValue { + first: vec![2; 32], second: None, }); let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( String::from("ÄöoLfwekldß^"), - PRFValue { - first: [1; 32], + PrfInputValue { + first: vec![1; 32], second: None, }, ); @@ -426,8 +426,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( String::new(), - PRFValue { - first: [1; 32], + PrfInputValue { + first: vec![1; 32], second: None, }, ); @@ -450,8 +450,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( String::new(), - PRFValue { - first: [1; 32], + PrfInputValue { + first: vec![1; 32], second: None, }, ); @@ -581,3 +581,130 @@ async fn run_failed_test( assert_eq!(response, Err(expected_error), "{printoutput}:"); println!("Success for test: {printoutput}") } + +/// W3C WebAuthn L3 §10.1.4: PRF salt inputs are `BufferSource`s of any length. +/// Regression test for #209: end-to-end PRF assertion succeeds for empty, +/// sub-32-byte, and super-32-byte salts, and is deterministic. +#[test(tokio::test)] +async fn test_webauthn_prf_variable_length_input() { + let mut device = get_virtual_device(); + let mut channel = device.channel().await.unwrap(); + + let user_id: [u8; 32] = thread_rng().gen(); + let challenge: [u8; 32] = thread_rng().gen(); + + let make_credentials_request = MakeCredentialRequest { + origin: "example.org".to_owned(), + challenge: Vec::from(challenge), + relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), + user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "mario.rossi", "Mario Rossi"), + resident_key: Some(ResidentKeyRequirement::Discouraged), + user_verification: UserVerificationRequirement::Preferred, + algorithms: vec![Ctap2CredentialType::default()], + exclude: None, + extensions: Some(MakeCredentialsRequestExtensions { + prf: Some(MakeCredentialPrfInput { _eval: None }), + ..Default::default() + }), + timeout: TIMEOUT, + top_origin: None, + }; + + let state_recv = channel.get_ux_update_receiver(); + let expected_updates = vec![ + UvUpdateShim::PresenceRequired, // MakeCredential + UvUpdateShim::PresenceRequired, // assert empty + UvUpdateShim::PresenceRequired, // assert 7 bytes + UvUpdateShim::PresenceRequired, // assert 100 bytes + UvUpdateShim::PresenceRequired, // determinism re-check (same 7 bytes) + ]; + let uv_handle = tokio::spawn(handle_updates(state_recv, expected_updates)); + + let response = channel + .webauthn_make_credential(&make_credentials_request) + .await + .expect("Failed to register credential"); + let credential: Ctap2PublicKeyCredentialDescriptor = + (&response.authenticator_data).try_into().unwrap(); + + async fn assert_prf( + channel: &mut HidChannel<'_>, + credential: &Ctap2PublicKeyCredentialDescriptor, + challenge: &[u8; 32], + first: Vec, + label: &str, + ) -> [u8; 32] { + let get_assertion = GetAssertionRequest { + relying_party_id: "example.org".to_owned(), + origin: "example.org".to_owned(), + challenge: Vec::from(challenge.as_slice()), + allow: vec![credential.clone()], + user_verification: UserVerificationRequirement::Preferred, + extensions: Some(GetAssertionRequestExtensions { + prf: Some(PrfInput { + eval: Some(PrfInputValue { + first, + second: None, + }), + eval_by_credential: HashMap::new(), + }), + ..Default::default() + }), + timeout: TIMEOUT, + top_origin: None, + }; + let response = channel + .webauthn_get_assertion(&get_assertion) + .await + .unwrap_or_else(|_| panic!("get_assertion failed: {label}")); + let results = response.assertions[0] + .unsigned_extensions_output + .as_ref() + .unwrap_or_else(|| panic!("no unsigned ext: {label}")) + .prf + .as_ref() + .unwrap_or_else(|| panic!("no prf: {label}")) + .results + .as_ref() + .unwrap_or_else(|| panic!("no results: {label}")); + assert_ne!(results.first, [0u8; 32], "{label}"); + assert!(results.second.is_none(), "{label}"); + results.first + } + + let empty = assert_prf(&mut channel, &credential, &challenge, vec![], "empty").await; + let short = assert_prf( + &mut channel, + &credential, + &challenge, + vec![0xAB; 7], + "7 bytes", + ) + .await; + let long = assert_prf( + &mut channel, + &credential, + &challenge, + vec![0xCD; 100], + "100 bytes", + ) + .await; + let short_again = assert_prf( + &mut channel, + &credential, + &challenge, + vec![0xAB; 7], + "7 bytes (repeat)", + ) + .await; + + // Different inputs hash to different salts and therefore yield distinct outputs. + assert_ne!(empty, short); + assert_ne!(short, long); + assert_ne!(empty, long); + // Same input → same output: PRF is deterministic per (credential, salt). + assert_eq!(short, short_again); + + let mut state_recv = uv_handle.await.unwrap(); + assert_eq!(state_recv.try_recv(), Err(TryRecvError::Empty)); +} diff --git a/libwebauthn/examples/features/prf_replay.rs b/libwebauthn/examples/features/prf_replay.rs index 8a14699..58ba267 100644 --- a/libwebauthn/examples/features/prf_replay.rs +++ b/libwebauthn/examples/features/prf_replay.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::convert::TryInto; use std::error::Error; use std::time::Duration; @@ -8,7 +7,7 @@ use rand::{thread_rng, Rng}; use serde_bytes::ByteBuf; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, GetAssertionRequestExtensions, PRFValue, PrfInput, + GetAssertionRequest, GetAssertionRequestExtensions, PrfInput, PrfInputValue, UserVerificationRequirement, }; use libwebauthn::proto::ctap2::{Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialType}; @@ -30,9 +29,7 @@ pub async fn main() -> Result<(), Box> { println!("Usage: cargo run --example prf_replay -- CREDENTIAL_ID FIRST_PRF_INPUT"); println!(); println!("CREDENTIAL_ID: Credential ID to be used to sign against, as a hexstring (like 5830c80ae90f7865c631626573f1fdc7..)"); - println!( - "FIRST_PRF_INPUT: PRF input to be used as a hexstring. Needs to be 32 bytes long!" - ); + println!("FIRST_PRF_INPUT: PRF input as a hexstring (any length, per WebAuthn L3 §10.1.4)"); println!(); println!("How to use:"); println!("1. Go to https://demo.yubico.com/webauthn-developers"); @@ -44,10 +41,8 @@ pub async fn main() -> Result<(), Box> { } let credential_id = hex::decode(argv[1].clone()).expect("CREDENTIAL_ID is not a valid hex code"); - let first_prf_input = hex::decode(argv[2].clone()) - .expect("FIRST_PRF_INPUT is not a valid hex code") - .try_into() - .expect("FIRST_PRF_INPUT is not exactly 32 bytes long"); + let first_prf_input = + hex::decode(argv[2].clone()).expect("FIRST_PRF_INPUT is not a valid hex code"); let devices = list_devices().await.unwrap(); println!("Devices found: {:?}", devices); @@ -69,8 +64,8 @@ pub async fn main() -> Result<(), Box> { }; let prf = PrfInput { - eval: Some(PRFValue { - first: first_prf_input, + eval: Some(PrfInputValue { + first: first_prf_input.clone(), second: None, }), eval_by_credential: HashMap::new(), diff --git a/libwebauthn/examples/features/webauthn_extensions_hid.rs b/libwebauthn/examples/features/webauthn_extensions_hid.rs index f3e4d58..90dbd88 100644 --- a/libwebauthn/examples/features/webauthn_extensions_hid.rs +++ b/libwebauthn/examples/features/webauthn_extensions_hid.rs @@ -7,7 +7,7 @@ use rand::{thread_rng, Rng}; use libwebauthn::ops::webauthn::{ CredentialProtectionExtension, CredentialProtectionPolicy, GetAssertionRequest, GetAssertionRequestExtensions, MakeCredentialRequest, MakeCredentialsRequestExtensions, - PRFValue, PrfInput, ResidentKeyRequirement, UserVerificationRequirement, + PrfInput, PrfInputValue, ResidentKeyRequirement, UserVerificationRequirement, }; use libwebauthn::proto::ctap2::{ Ctap2CredentialType, Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity, @@ -87,8 +87,8 @@ pub async fn main() -> Result<(), Box> { extensions: Some(GetAssertionRequestExtensions { cred_blob: true, prf: Some(PrfInput { - eval: Some(PRFValue { - first: [1; 32], + eval: Some(PrfInputValue { + first: vec![1; 32], second: None, }), eval_by_credential: std::collections::HashMap::new(), diff --git a/libwebauthn/examples/features/webauthn_prf_hid.rs b/libwebauthn/examples/features/webauthn_prf_hid.rs index 0cda6e4..909e779 100644 --- a/libwebauthn/examples/features/webauthn_prf_hid.rs +++ b/libwebauthn/examples/features/webauthn_prf_hid.rs @@ -8,7 +8,7 @@ use rand::{thread_rng, Rng}; use libwebauthn::ops::webauthn::{ GetAssertionRequest, GetAssertionRequestExtensions, MakeCredentialPrfInput, - MakeCredentialRequest, MakeCredentialsRequestExtensions, PRFValue, PrfInput, + MakeCredentialRequest, MakeCredentialsRequestExtensions, PrfInput, PrfInputValue, ResidentKeyRequirement, UserVerificationRequirement, }; use libwebauthn::proto::ctap2::{ @@ -77,8 +77,8 @@ pub async fn main() -> Result<(), Box> { let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( base64_url::encode(&credential.id), - PRFValue { - first: [1; 32], + PrfInputValue { + first: vec![1; 32], second: None, }, ); @@ -98,8 +98,8 @@ pub async fn main() -> Result<(), Box> { let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( base64_url::encode(&credential.id), - PRFValue { - first: [1; 32], + PrfInputValue { + first: vec![1; 32], second: None, }, ); @@ -108,8 +108,8 @@ pub async fn main() -> Result<(), Box> { &credential, &challenge, PrfInput { - eval: Some(PRFValue { - first: [2; 32], + eval: Some(PrfInputValue { + first: vec![2; 32], second: None, }), eval_by_credential, @@ -124,8 +124,8 @@ pub async fn main() -> Result<(), Box> { &credential, &challenge, PrfInput { - eval: Some(PRFValue { - first: [1; 32], + eval: Some(PrfInputValue { + first: vec![1; 32], second: None, }), eval_by_credential: HashMap::new(), @@ -138,29 +138,29 @@ pub async fn main() -> Result<(), Box> { let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( base64_url::encode(&[5; 54]), - PRFValue { - first: [5; 32], + PrfInputValue { + first: vec![5; 32], second: None, }, ); eval_by_credential.insert( base64_url::encode(&[7; 54]), - PRFValue { - first: [7; 32], - second: Some([7; 32]), + PrfInputValue { + first: vec![7; 32], + second: Some(vec![7; 32]), }, ); eval_by_credential.insert( base64_url::encode(&[8; 54]), - PRFValue { - first: [8; 32], - second: Some([8; 32]), + PrfInputValue { + first: vec![8; 32], + second: Some(vec![8; 32]), }, ); eval_by_credential.insert( base64_url::encode(&credential.id), - PRFValue { - first: [1; 32], + PrfInputValue { + first: vec![1; 32], second: None, }, ); @@ -169,8 +169,8 @@ pub async fn main() -> Result<(), Box> { &credential, &challenge, PrfInput { - eval: Some(PRFValue { - first: [2; 32], + eval: Some(PrfInputValue { + first: vec![2; 32], second: None, }), eval_by_credential, @@ -183,23 +183,23 @@ pub async fn main() -> Result<(), Box> { let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( base64_url::encode(&[5; 54]), - PRFValue { - first: [5; 32], + PrfInputValue { + first: vec![5; 32], second: None, }, ); eval_by_credential.insert( base64_url::encode(&[7; 54]), - PRFValue { - first: [7; 32], - second: Some([7; 32]), + PrfInputValue { + first: vec![7; 32], + second: Some(vec![7; 32]), }, ); eval_by_credential.insert( base64_url::encode(&[8; 54]), - PRFValue { - first: [8; 32], - second: Some([8; 32]), + PrfInputValue { + first: vec![8; 32], + second: Some(vec![8; 32]), }, ); run_success_test( @@ -207,8 +207,8 @@ pub async fn main() -> Result<(), Box> { &credential, &challenge, PrfInput { - eval: Some(PRFValue { - first: [1; 32], + eval: Some(PrfInputValue { + first: vec![1; 32], second: None, }), eval_by_credential, @@ -221,23 +221,23 @@ pub async fn main() -> Result<(), Box> { let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( base64_url::encode(&[5; 54]), - PRFValue { - first: [5; 32], + PrfInputValue { + first: vec![5; 32], second: None, }, ); eval_by_credential.insert( base64_url::encode(&[7; 54]), - PRFValue { - first: [7; 32], - second: Some([7; 32]), + PrfInputValue { + first: vec![7; 32], + second: Some(vec![7; 32]), }, ); eval_by_credential.insert( base64_url::encode(&[8; 54]), - PRFValue { - first: [8; 32], - second: Some([8; 32]), + PrfInputValue { + first: vec![8; 32], + second: Some(vec![8; 32]), }, ); run_success_test( @@ -256,8 +256,8 @@ pub async fn main() -> Result<(), Box> { let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( String::from("ÄöoLfwekldß^"), - PRFValue { - first: [1; 32], + PrfInputValue { + first: vec![1; 32], second: None, }, ); @@ -266,8 +266,8 @@ pub async fn main() -> Result<(), Box> { Some(&credential), &challenge, PrfInput { - eval: Some(PRFValue { - first: [2; 32], + eval: Some(PrfInputValue { + first: vec![2; 32], second: None, }), eval_by_credential, @@ -281,8 +281,8 @@ pub async fn main() -> Result<(), Box> { let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( String::new(), - PRFValue { - first: [1; 32], + PrfInputValue { + first: vec![1; 32], second: None, }, ); @@ -303,8 +303,8 @@ pub async fn main() -> Result<(), Box> { let mut eval_by_credential = HashMap::new(); eval_by_credential.insert( String::new(), - PRFValue { - first: [1; 32], + PrfInputValue { + first: vec![1; 32], second: None, }, ); diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index df55859..44fcaea 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -37,8 +37,20 @@ use super::{ DowngradableRequest, RelyingPartyId, RequestOrigin, SignRequest, UserVerificationRequirement, }; -#[derive(Debug, Default, Clone, Serialize, PartialEq)] -pub struct PRFValue { +/// PRF extension input salts. Per W3C WebAuthn L3 §10.1.4, these are +/// `BufferSource`s "of any length"; the client hashes them via +/// `SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || input)` before feeding the +/// resulting 32 bytes to CTAP2 hmac-secret. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct PrfInputValue { + pub first: Vec, + pub second: Option>, +} + +/// PRF extension output values. Per CTAP2.1 §6.5.6 hmac-secret, each slot is +/// exactly 32 bytes. +#[derive(Debug, Default, Clone, Serialize, PartialEq, Eq)] +pub struct PrfOutputValue { #[serde(with = "serde_bytes")] pub first: [u8; 32], #[serde(skip_serializing_if = "Option::is_none", with = "serde_bytes")] @@ -189,64 +201,36 @@ pub enum GetAssertionHmacOrPrfInput { Prf(PrfInput), } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct PrfInput { - pub eval: Option, - pub eval_by_credential: HashMap, + pub eval: Option, + pub eval_by_credential: HashMap, } impl TryFrom for PrfInput { type Error = GetAssertionRequestParsingError; fn try_from(value: PrfInputJson) -> Result { - let eval = match value.eval { - Some(value) => Some(PRFValue { - first: value.first.as_slice().try_into().map_err(|_| { - GetAssertionRequestParsingError::UnexpectedLengthError( - "extensions.prf.eval.first".to_string(), - value.first.as_slice().len(), - ) - })?, - second: match value.second { - Some(s) => Some(s.as_slice().try_into().map_err(|_| { - GetAssertionRequestParsingError::UnexpectedLengthError( - "extensions.prf.eval.second".to_string(), - s.as_slice().len(), - ) - })?), - None => None, - }, - }), - None => None, - }; - let eval_by_credential = match value.eval_by_credential { - Some(map) => map - .into_iter() - .map(|(k, v)| { - Ok(( - k, - PRFValue { - first: v.first.as_slice().try_into().map_err(|_| { - GetAssertionRequestParsingError::UnexpectedLengthError( - "extensions.prf.eval_by_credential[i].first".to_string(), - v.first.as_slice().len(), - ) - })?, - second: match v.second { - Some(s) => Some(s.as_slice().try_into().map_err(|_| { - GetAssertionRequestParsingError::UnexpectedLengthError( - "extensions.prf.eval_by_credential[i].second".to_string(), - s.as_slice().len(), - ) - })?), - None => None, + let eval = value.eval.map(|v| PrfInputValue { + first: v.first.into(), + second: v.second.map(Into::into), + }); + let eval_by_credential = value + .eval_by_credential + .map(|map| { + map.into_iter() + .map(|(k, v)| { + ( + k, + PrfInputValue { + first: v.first.into(), + second: v.second.map(Into::into), }, - }, - )) - }) - .collect::, GetAssertionRequestParsingError>>()?, - None => HashMap::new(), - }; + ) + }) + .collect() + }) + .unwrap_or_default(); Ok(PrfInput { eval, @@ -258,7 +242,7 @@ impl TryFrom for PrfInput { #[derive(Debug, Default, Clone, Serialize)] pub struct GetAssertionPrfOutput { #[serde(skip_serializing_if = "Option::is_none")] - pub results: Option, + pub results: Option, } #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -758,38 +742,63 @@ mod tests { ); } + fn parse_prf(extensions_json: &str) -> PrfInput { + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", extensions_json); + let req = GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .expect("request should parse"); + req.extensions + .expect("extensions") + .prf + .expect("prf extension") + } + #[test] - #[ignore] // FIXME(#134) allow arbitrary size input fn test_request_from_json_prf_extension() { - let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); - let req_json = json_field_add( - REQUEST_BASE_JSON, - "extensions", - r#"{"prf":{"eval":{"first": "second"}}}"#, - ); + // Non-32-byte inputs must now parse (W3C WebAuthn L3 §10.1.4). "AQID" + // decodes to 0x010203, "BAUG" to 0x040506. + let prf = parse_prf(r#"{"prf":{"eval":{"first":"AQID","second":"BAUG"}}}"#); + let eval = prf.eval.expect("eval"); + assert_eq!(eval.first, vec![0x01, 0x02, 0x03]); + assert_eq!(eval.second.as_deref(), Some(&[0x04u8, 0x05, 0x06][..])); + } - let req: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); - if let Some(GetAssertionRequestExtensions { - prf: - Some(PrfInput { - eval: Some(ref prf_value), - .. - }), - .. - }) = &req.extensions - { - assert_eq!(&prf_value.first[..], b"first"); - assert_eq!( - prf_value.second.as_ref().map(|s| &s[..]), - Some(&b"second"[..]) - ); - } else { - panic!("Expected PRF extension with correct values"); + #[test] + fn test_prf_input_variable_length() { + // W3C WebAuthn L3 §10.1.4: PRF inputs are BufferSources of any length. + for len in [1usize, 16, 31, 33, 64, 256] { + let bytes = vec![0xABu8; len]; + let b64 = base64_url::encode(&bytes); + let prf = parse_prf(&format!(r#"{{"prf":{{"eval":{{"first":"{b64}"}}}}}}"#)); + let eval = prf.eval.unwrap(); + assert_eq!(eval.first.len(), len, "len {len}"); + assert_eq!(eval.first, bytes, "len {len}"); + assert!(eval.second.is_none()); } } + #[test] + fn test_prf_input_empty_allowed() { + // §10.1.4 says "of any length" with no lower bound; empty must parse. + let prf = parse_prf(r#"{"prf":{"eval":{"first":""}}}"#); + let eval = prf.eval.unwrap(); + assert!(eval.first.is_empty()); + assert!(eval.second.is_none()); + } + + #[test] + fn test_prf_eval_by_credential_variable_length() { + // NOTE: the IDL field is currently deserialized as `eval_by_credential` + // rather than the spec name `evalByCredential` — separate concern from + // #209. Use the field name the deserializer accepts. + let prf = parse_prf( + r#"{"prf":{"eval_by_credential":{"Y3JlZDE":{"first":"AQ","second":"AgIC"}}}}"#, + ); + let v = prf.eval_by_credential.get("Y3JlZDE").expect("entry"); + assert_eq!(v.first, vec![0x01]); + assert_eq!(v.second.as_deref(), Some(&[0x02u8, 0x02, 0x02][..])); + } + // Tests for response JSON serialization fn create_test_assertion() -> Assertion { @@ -904,7 +913,7 @@ mod tests { hmac_get_secret: None, large_blob: None, prf: Some(GetAssertionPrfOutput { - results: Some(PRFValue { + results: Some(PrfOutputValue { first: [0x01u8; 32], second: None, }), diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 2a57e67..f93c34d 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -13,7 +13,7 @@ pub use get_assertion::{ GetAssertionLargeBlobExtension, GetAssertionLargeBlobExtensionOutput, GetAssertionPrfOutput, GetAssertionRequest, GetAssertionRequestExtensions, GetAssertionResponse, GetAssertionResponseExtensions, GetAssertionResponseUnsignedExtensions, HMACGetSecretInput, - HMACGetSecretOutput, PRFValue, PrfInput, + HMACGetSecretOutput, PrfInput, PrfInputValue, PrfOutputValue, }; pub use idl::{ origin::{HostParseError, Origin, OriginHost, OriginParseError, RequestOrigin, Scheme}, diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index dd61a2a..06f8712 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -4,7 +4,7 @@ use crate::{ Assertion, Ctap2HMACGetSecretOutput, GetAssertionHmacOrPrfInput, GetAssertionLargeBlobExtension, GetAssertionLargeBlobExtensionOutput, GetAssertionPrfOutput, GetAssertionRequest, GetAssertionRequestExtensions, - GetAssertionResponseUnsignedExtensions, HMACGetSecretInput, PRFValue, + GetAssertionResponseUnsignedExtensions, HMACGetSecretInput, PrfInputValue, PrfOutputValue, }, pin::PinUvAuthProtocol, proto::ctap2::cbor::Value, @@ -285,8 +285,8 @@ impl Ctap2GetAssertionRequestExtensions { } fn prf_to_hmac_input( - eval: &Option, - eval_by_credential: &HashMap, + eval: &Option, + eval_by_credential: &HashMap, allow_list: &[Ctap2PublicKeyCredentialDescriptor], ) -> Result, Error> { // https://w3c.github.io/webauthn/#prf @@ -328,7 +328,7 @@ impl Ctap2GetAssertionRequestExtensions { let mut input = HMACGetSecretInput::default(); // 5.1 Let salt1 be the value of SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.first). let mut salt1_input = prefix.clone(); - salt1_input.extend(ev.first); + salt1_input.extend_from_slice(&ev.first); let mut hasher = Sha256::default(); hasher.update(salt1_input); @@ -336,9 +336,9 @@ impl Ctap2GetAssertionRequestExtensions { input.salt1.copy_from_slice(&salt1_hash[..32]); // 5.2 If ev.second is present, let salt2 be the value of SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.second). - if let Some(second) = ev.second { + if let Some(second) = ev.second.as_ref() { let mut salt2_input = prefix.clone(); - salt2_input.extend(second); + salt2_input.extend_from_slice(second); let mut hasher = Sha256::default(); hasher.update(salt2_input); let salt2_hash = hasher.finalize().to_vec(); @@ -540,7 +540,7 @@ impl Ctap2GetAssertionResponseExtensions { .as_ref() .and_then(|ext| ext.prf.as_ref()) .map(|_| GetAssertionPrfOutput { - results: Some(PRFValue { + results: Some(PrfOutputValue { first: decrypted.output1, second: decrypted.output2, }), diff --git a/libwebauthn/src/webauthn/pin_uv_auth_token.rs b/libwebauthn/src/webauthn/pin_uv_auth_token.rs index 5748dd1..a90db21 100644 --- a/libwebauthn/src/webauthn/pin_uv_auth_token.rs +++ b/libwebauthn/src/webauthn/pin_uv_auth_token.rs @@ -532,7 +532,7 @@ mod test { use crate::{ ops::webauthn::{ - GetAssertionRequest, GetAssertionRequestExtensions, PRFValue, PrfInput, + GetAssertionRequest, GetAssertionRequestExtensions, PrfInput, PrfInputValue, UserVerificationRequirement, }, pin::{pin_hash, PinNotSetReason, PinUvAuthProtocol, PinUvAuthProtocolOne}, @@ -939,8 +939,8 @@ mod test { UserVerificationRequirement::Discouraged, Some(GetAssertionRequestExtensions { prf: Some(PrfInput { - eval: Some(PRFValue { - first: [0; 32], + eval: Some(PrfInputValue { + first: vec![0; 32], second: None, }), eval_by_credential: HashMap::new(), @@ -985,8 +985,8 @@ mod test { UserVerificationRequirement::Preferred, Some(GetAssertionRequestExtensions { prf: Some(PrfInput { - eval: Some(PRFValue { - first: [0; 32], + eval: Some(PrfInputValue { + first: vec![0; 32], second: None, }), eval_by_credential: HashMap::new(), @@ -1057,8 +1057,8 @@ mod test { for (info_options, uv_requirement) in testcases { let extensions = Some(GetAssertionRequestExtensions { prf: Some(PrfInput { - eval: Some(PRFValue { - first: [0; 32], + eval: Some(PrfInputValue { + first: vec![0; 32], second: None, }), eval_by_credential: HashMap::new(), @@ -1128,8 +1128,8 @@ mod test { for (info_options, uv_requirement) in testcases { let extensions = Some(GetAssertionRequestExtensions { prf: Some(PrfInput { - eval: Some(PRFValue { - first: [0; 32], + eval: Some(PrfInputValue { + first: vec![0; 32], second: None, }), eval_by_credential: HashMap::new(), @@ -1248,8 +1248,8 @@ mod test { for (info_options, uv_requirement) in testcases { let extensions = Some(GetAssertionRequestExtensions { prf: Some(PrfInput { - eval: Some(PRFValue { - first: [0; 32], + eval: Some(PrfInputValue { + first: vec![0; 32], second: None, }), eval_by_credential: HashMap::new(), From de69252c81cc4c3f8be4ceecd9bdf284e661c51a Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:30:52 +0100 Subject: [PATCH 2/2] test(webauthn): add explicit short-input JSON parse test for PRF --- libwebauthn/src/ops/webauthn/get_assertion.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 44fcaea..da3d9d0 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -777,6 +777,17 @@ mod tests { } } + #[test] + fn test_prf_input_short_via_json() { + // Regression test for #209: a sub-32-byte salt encoded in the JSON IDL + // must round-trip through GetAssertionRequest::from_json into a + // PrfInputValue with the expected bytes. + let prf = parse_prf(r#"{"prf":{"eval":{"first":"aGk"}}}"#); // base64url "aGk" -> b"hi" + let eval = prf.eval.expect("eval"); + assert_eq!(eval.first, b"hi"); + assert!(eval.second.is_none()); + } + #[test] fn test_prf_input_empty_allowed() { // §10.1.4 says "of any length" with no lower bound; empty must parse.