From fb735a70ff3ab308675e5fc3527f800a698f80ae Mon Sep 17 00:00:00 2001 From: maan2003 Date: Tue, 29 Sep 2026 15:06:12 +0000 Subject: [PATCH] webauthn: carry hmacGetSecret from the IDL JSON to the authenticator and back `AuthenticationExtensionsClientInputsJSON.hmacGetSecret` was parsed and then dropped: GetAssertionRequestExtensions had no field for it, so a caller of GetAssertionRequest::prepare could only ask for hmac-secret through `prf`, which the platform upgrades to userVerification=required (webauthn#2337). The pre-PRF client extension asks for the raw salts under the request's own userVerification, as browsers served it. It now reaches the CTAP request as GetAssertionHmacOrPrfInput::HmacGetSecret (prf wins when both are present) and the decrypted output comes back as `clientExtensionResults.hmacGetSecret`, not `prf`. --- libwebauthn-tests/tests/large_blob.rs | 3 + libwebauthn-tests/tests/prf.rs | 4 ++ libwebauthn/examples/features/prf_replay.rs | 1 + .../features/webauthn_extensions_hid.rs | 1 + .../examples/features/webauthn_prf_cable.rs | 1 + .../examples/features/webauthn_prf_hid.rs | 2 + libwebauthn/src/ops/webauthn/get_assertion.rs | 60 ++++++++++++++++- libwebauthn/src/ops/webauthn/large_blob.rs | 3 + .../src/proto/ctap2/model/get_assertion.rs | 64 +++++++++++++++---- libwebauthn/src/webauthn/pin_uv_auth_token.rs | 7 ++ 10 files changed, 132 insertions(+), 14 deletions(-) diff --git a/libwebauthn-tests/tests/large_blob.rs b/libwebauthn-tests/tests/large_blob.rs index ad1e0c9d..0c391a49 100644 --- a/libwebauthn-tests/tests/large_blob.rs +++ b/libwebauthn-tests/tests/large_blob.rs @@ -100,6 +100,7 @@ async fn test_webauthn_large_blob_read_returns_planted_blob() { allow: vec![credential], user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, appid: None, cred_blob: false, prf: None, @@ -137,6 +138,7 @@ async fn capture_large_blob_key( allow: vec![credential.clone()], user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, appid: None, cred_blob: false, prf: None, @@ -294,6 +296,7 @@ fn ga_request( allow: vec![credential.clone()], user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, appid: None, cred_blob: false, prf: None, diff --git a/libwebauthn-tests/tests/prf.rs b/libwebauthn-tests/tests/prf.rs index b2fb8c55..826859e6 100644 --- a/libwebauthn-tests/tests/prf.rs +++ b/libwebauthn-tests/tests/prf.rs @@ -570,6 +570,7 @@ async fn run_success_test( allow: vec![credential.clone()], user_verification: UserVerificationRequirement::Preferred, extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(prf), ..Default::default() }), @@ -638,6 +639,7 @@ async fn run_failed_test( allow: credential.map(|x| vec![x.clone()]).unwrap_or_default(), user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(prf), ..Default::default() }), @@ -732,6 +734,7 @@ async fn test_webauthn_prf_variable_length_input() { allow: vec![credential.clone()], user_verification: UserVerificationRequirement::Preferred, extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(PrfInput { eval: Some(PrfInputValue { first, @@ -952,6 +955,7 @@ async fn test_webauthn_prf_upgrades_uv_at_assertion() { allow: vec![credential], user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(prf), ..Default::default() }), diff --git a/libwebauthn/examples/features/prf_replay.rs b/libwebauthn/examples/features/prf_replay.rs index 3e589ccf..b4c5ff23 100644 --- a/libwebauthn/examples/features/prf_replay.rs +++ b/libwebauthn/examples/features/prf_replay.rs @@ -92,6 +92,7 @@ async fn run_success_test( allow: vec![credential.clone()], user_verification: UserVerificationRequirement::Preferred, extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(prf), ..Default::default() }), diff --git a/libwebauthn/examples/features/webauthn_extensions_hid.rs b/libwebauthn/examples/features/webauthn_extensions_hid.rs index c5ae0245..f855b502 100644 --- a/libwebauthn/examples/features/webauthn_extensions_hid.rs +++ b/libwebauthn/examples/features/webauthn_extensions_hid.rs @@ -90,6 +90,7 @@ pub async fn main() -> Result<(), Box> { allow: vec![credential], user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, cred_blob: true, prf: Some(PrfInput { eval: Some(PrfInputValue { diff --git a/libwebauthn/examples/features/webauthn_prf_cable.rs b/libwebauthn/examples/features/webauthn_prf_cable.rs index 6d5f6f24..78f111a0 100644 --- a/libwebauthn/examples/features/webauthn_prf_cable.rs +++ b/libwebauthn/examples/features/webauthn_prf_cable.rs @@ -179,6 +179,7 @@ async fn get(credential_id: Option<&str>) -> Result<(), Box> { allow, user_verification: UserVerificationRequirement::Preferred, extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(PrfInput { eval: Some(PrfInputValue { first: GET_EVAL_FIRST.to_vec(), diff --git a/libwebauthn/examples/features/webauthn_prf_hid.rs b/libwebauthn/examples/features/webauthn_prf_hid.rs index 0e098f63..830be894 100644 --- a/libwebauthn/examples/features/webauthn_prf_hid.rs +++ b/libwebauthn/examples/features/webauthn_prf_hid.rs @@ -343,6 +343,7 @@ async fn run_success_test( allow: vec![credential.clone()], user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(prf), ..Default::default() }), @@ -375,6 +376,7 @@ async fn run_failed_test( allow: credential.map(|x| vec![x.clone()]).unwrap_or_default(), user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(prf), ..Default::default() }), diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 593e8e81..aa8929b4 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -211,6 +211,15 @@ impl FromIdlModel for GetAssertionRequest None => None, }; + let hmac_get_secret = match inner + .extensions + .as_ref() + .and_then(|e| e.hmac_get_secret.as_ref()) + { + Some(hmac_json) => Some(HMACGetSecretInput::try_from(hmac_json.clone())?), + None => None, + }; + let large_blob = match inner .extensions .as_ref() @@ -230,6 +239,7 @@ impl FromIdlModel for GetAssertionRequest cred_blob: extensions_opt.cred_blob.unwrap_or(false), large_blob: large_blob.clone(), prf: prf.clone(), + hmac_get_secret: hmac_get_secret.clone(), appid: appid.clone(), }); @@ -321,14 +331,14 @@ impl TryFrom for HMACGetSecretInput { fn try_from(value: HmacGetSecretInputJson) -> Result { let salt1 = value.salt1.as_slice().try_into().map_err(|_| { GetAssertionPrepareError::UnexpectedLengthError( - "extensions.hmacCreateSecret.salt1".to_string(), + "extensions.hmacGetSecret.salt1".to_string(), value.salt1.as_slice().len(), ) })?; let salt2 = match value.salt2 { Some(s) => Some(s.as_slice().try_into().map_err(|_| { GetAssertionPrepareError::UnexpectedLengthError( - "extensions.hmacCreateSecret.salt2".to_string(), + "extensions.hmacGetSecret.salt2".to_string(), s.as_slice().len(), ) })?), @@ -388,6 +398,9 @@ pub struct GetAssertionRequestExtensions { pub cred_blob: bool, /// PRF extension input. At the CTAP level, this is converted to HMAC secret. pub prf: Option, + /// hmacGetSecret extension input (the pre-PRF hmac-secret client extension): the raw + /// salts, sent as they are. Ignored when `prf` is also present. + pub hmac_get_secret: Option, pub large_blob: Option, /// FIDO AppID extension (WebAuthn L3 §10.1.1). When the relying party has /// existing U2F credentials registered under a legacy AppID, this URL is @@ -1408,6 +1421,7 @@ mod tests { let mut req = request_base(); req.extensions = Some(GetAssertionRequestExtensions { + hmac_get_secret: None, cred_blob: false, prf: None, large_blob: None, @@ -1464,6 +1478,48 @@ mod tests { .expect("prf extension") } + #[tokio::test] + async fn test_request_from_json_hmac_get_secret_extension() { + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let salt = base64_url::encode(&[0x5Au8; 32]); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "extensions", + &format!(r#"{{"hmacGetSecret":{{"salt1":"{salt}"}}}}"#), + ); + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .expect("request should parse"); + let ext = req.extensions.expect("extensions"); + assert!(ext.prf.is_none()); + assert_eq!( + ext.hmac_get_secret, + Some(HMACGetSecretInput { + salt1: [0x5A; 32], + salt2: None, + }) + ); + // A salt that is not 32 bytes is refused. + let req_json = json_field_add( + REQUEST_BASE_JSON, + "extensions", + r#"{"hmacGetSecret":{"salt1":"AQID"}}"#, + ); + assert!(from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .is_err()); + } + #[tokio::test] async fn test_request_from_json_prf_extension() { // Non-32-byte inputs must now parse (W3C WebAuthn L3 §10.1.4). "AQID" diff --git a/libwebauthn/src/ops/webauthn/large_blob.rs b/libwebauthn/src/ops/webauthn/large_blob.rs index 009cfc31..aa64ab06 100644 --- a/libwebauthn/src/ops/webauthn/large_blob.rs +++ b/libwebauthn/src/ops/webauthn/large_blob.rs @@ -1011,6 +1011,7 @@ mod tests { top_origin: None, allow: vec![], extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, appid: None, cred_blob: false, prf: None, @@ -1051,6 +1052,7 @@ mod tests { top_origin: None, allow: vec![], extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, appid: None, cred_blob: false, prf: None, @@ -1152,6 +1154,7 @@ mod tests { top_origin: None, allow: vec![], extensions: Some(GetAssertionRequestExtensions { + hmac_get_secret: None, appid: None, cred_blob: false, prf: None, diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index 1da4ee06..8b91c08f 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -239,7 +239,11 @@ impl From for Ctap2GetAssertionRequestExtensions prf: None, // Set by convert_prf_to_native when the device advertises `prf` cred_blob: other.cred_blob, hmac_secret: None, // Gets calculated later - hmac_or_prf: other.prf.map(GetAssertionHmacOrPrfInput::Prf), + hmac_or_prf: other.prf.map(GetAssertionHmacOrPrfInput::Prf).or_else(|| { + other + .hmac_get_secret + .map(GetAssertionHmacOrPrfInput::HmacGetSecret) + }), large_blob_key: if needs_key { Some(true) } else { None }, large_blob_write: is_write, } @@ -720,20 +724,23 @@ impl Ctap2GetAssertionResponseExtensions { } }); - let prf = decrypted_hmac.and_then(|decrypted| { - // At WebAuthn level, we only support PRF (not raw HMAC). - // The PRF input was converted to HMAC internally. - request - .extensions - .as_ref() - .and_then(|ext| ext.prf.as_ref()) - .map(|_| GetAssertionPrfOutput { + // The answer goes back under the extension that asked: prf (whose input was + // converted to hmac-secret internally) or, failing that, hmacGetSecret. + let (prf, hmac_get_secret) = match (decrypted_hmac, request.extensions.as_ref()) { + (Some(decrypted), Some(ext)) if ext.prf.is_some() => ( + Some(GetAssertionPrfOutput { results: Some(PrfOutputValue { first: decrypted.output1, second: decrypted.output2, }), - }) - }); + }), + None, + ), + (Some(decrypted), Some(ext)) if ext.hmac_get_secret.is_some() => { + (None, Some(decrypted)) + } + _ => (None, None), + }; // `blob` stays `None` until `authenticatorLargeBlobs` is wired up; returning // the raw `largeBlobKey` here would disclose the per-credential AES key to @@ -758,7 +765,7 @@ impl Ctap2GetAssertionResponseExtensions { .map(|_| false); GetAssertionResponseUnsignedExtensions { - hmac_get_secret: None, + hmac_get_secret, large_blob, prf, appid, @@ -770,6 +777,37 @@ impl Ctap2GetAssertionResponseExtensions { #[cfg(test)] mod tests { use super::*; + + #[test] + fn hmac_get_secret_input_is_sent_raw_and_answered_as_itself() { + let input = crate::ops::webauthn::HMACGetSecretInput { + salt1: [0x5A; 32], + salt2: None, + }; + let ext = GetAssertionRequestExtensions { + hmac_get_secret: Some(input.clone()), + ..Default::default() + }; + let ctap = Ctap2GetAssertionRequestExtensions::from(ext); + assert_eq!( + ctap.hmac_or_prf, + Some(GetAssertionHmacOrPrfInput::HmacGetSecret(input)) + ); + // prf wins when both are asked: the authenticator answers one hmac-secret. + let both = GetAssertionRequestExtensions { + hmac_get_secret: Some(crate::ops::webauthn::HMACGetSecretInput::default()), + prf: Some(crate::ops::webauthn::PrfInput { + eval: None, + eval_by_credential: Default::default(), + }), + ..Default::default() + }; + assert!(matches!( + Ctap2GetAssertionRequestExtensions::from(both).hmac_or_prf, + Some(GetAssertionHmacOrPrfInput::Prf(_)) + )); + } + use super::*; use crate::fido::AuthenticatorDataFlags; use crate::proto::ctap2::Ctap2PublicKeyCredentialType; use std::time::Duration; @@ -870,6 +908,7 @@ mod tests { let mut request = make_request(vec![cred]); request.extensions = Some(GetAssertionRequestExtensions { + hmac_get_secret: None, cred_blob: false, prf: None, large_blob: Some(GetAssertionLargeBlobExtension::Read), @@ -909,6 +948,7 @@ mod tests { ) -> GetAssertionRequest { let mut request = make_request(allow); request.extensions = Some(GetAssertionRequestExtensions { + hmac_get_secret: None, cred_blob: false, prf: Some(crate::ops::webauthn::PrfInput { eval, diff --git a/libwebauthn/src/webauthn/pin_uv_auth_token.rs b/libwebauthn/src/webauthn/pin_uv_auth_token.rs index b6fd76a4..53f688f6 100644 --- a/libwebauthn/src/webauthn/pin_uv_auth_token.rs +++ b/libwebauthn/src/webauthn/pin_uv_auth_token.rs @@ -1107,6 +1107,7 @@ mod test { info_extensions.as_deref(), UserVerificationRequirement::Discouraged, Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(PrfInput { eval: Some(PrfInputValue { first: vec![0; 32], @@ -1153,6 +1154,7 @@ mod test { Some(&["hmac-secret"]), UserVerificationRequirement::Preferred, Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(PrfInput { eval: Some(PrfInputValue { first: vec![0; 32], @@ -1225,6 +1227,7 @@ mod test { for (info_options, uv_requirement) in testcases { let extensions = Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(PrfInput { eval: Some(PrfInputValue { first: vec![0; 32], @@ -1298,6 +1301,7 @@ mod test { channel.push_command_pair(info_req, info_resp); let extensions = Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(PrfInput { eval: Some(PrfInputValue { first: vec![0; 32], @@ -1348,6 +1352,7 @@ mod test { for (info_options, uv_requirement) in testcases { let extensions = Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(PrfInput { eval: Some(PrfInputValue { first: vec![0; 32], @@ -1468,6 +1473,7 @@ mod test { for (info_options, uv_requirement) in testcases { let extensions = Some(GetAssertionRequestExtensions { + hmac_get_secret: None, prf: Some(PrfInput { eval: Some(PrfInputValue { first: vec![0; 32], @@ -2214,6 +2220,7 @@ mod test { channel.push_command_pair(key_agreement_req, key_agreement_resp); let extensions = Some(GetAssertionRequestExtensions { + hmac_get_secret: None, large_blob: Some(GetAssertionLargeBlobExtension::Write(vec![1, 2, 3, 4])), ..Default::default() });