From 1489b3d18b3fc79fb200c600ea045e3d82d51a72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:27:06 +0000 Subject: [PATCH 1/5] Initial plan From 8e590f86f6e0aff1ef593b71ebcf3ce83c8c3e04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:39:44 +0000 Subject: [PATCH 2/5] Add hmac-secret-mc extension support for MakeCredential with PRF eval Co-authored-by: AlfioEmanueleFresta <621062+AlfioEmanueleFresta@users.noreply.github.com> --- libwebauthn/examples/webauthn_prf_hid.rs | 2 +- libwebauthn/src/ops/u2f.rs | 2 +- .../src/ops/webauthn/make_credential.rs | 31 +++- .../src/proto/ctap2/model/make_credential.rs | 138 +++++++++++++++++- libwebauthn/src/tests/prf.rs | 5 +- libwebauthn/src/webauthn.rs | 9 +- 6 files changed, 173 insertions(+), 14 deletions(-) diff --git a/libwebauthn/examples/webauthn_prf_hid.rs b/libwebauthn/examples/webauthn_prf_hid.rs index 3e50fd8..75d1d21 100644 --- a/libwebauthn/examples/webauthn_prf_hid.rs +++ b/libwebauthn/examples/webauthn_prf_hid.rs @@ -85,7 +85,7 @@ pub async fn main() -> Result<(), Box> { let challenge: [u8; 32] = thread_rng().gen(); let extensions = MakeCredentialsRequestExtensions { - prf: Some(MakeCredentialPrfInput { _eval: None }), + prf: Some(MakeCredentialPrfInput { _eval: None, eval: None }), ..Default::default() }; diff --git a/libwebauthn/src/ops/u2f.rs b/libwebauthn/src/ops/u2f.rs index f21dd6a..725b429 100644 --- a/libwebauthn/src/ops/u2f.rs +++ b/libwebauthn/src/ops/u2f.rs @@ -148,7 +148,7 @@ impl UpgradableResponse for Regis enterprise_attestation: None, large_blob_key: None, }; - Ok(resp.into_make_credential_output(request, None)) + Ok(resp.into_make_credential_output(request, None, None)) } } diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 5ba789b..4645819 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -68,6 +68,7 @@ impl MakeCredentialsResponseUnsignedExtensions { signed_extensions: &Option, request: &MakeCredentialRequest, info: Option<&Ctap2GetInfoResponse>, + auth_data: Option<&crate::transport::AuthTokenData>, ) -> MakeCredentialsResponseUnsignedExtensions { let mut hmac_create_secret = None; let mut prf = None; @@ -79,8 +80,26 @@ impl MakeCredentialsResponseUnsignedExtensions { hmac_create_secret = signed_extensions.hmac_secret; } if incoming_ext.prf.is_some() { + // Decrypt hmac-secret-mc output if available + let mc_results = signed_extensions.hmac_secret_mc.as_ref().and_then(|x| { + if let Some(auth_data) = auth_data { + let uv_proto = auth_data.protocol_version.create_protocol_object(); + x.decrypt_output(&auth_data.shared_secret, &uv_proto) + } else { + None + } + }); + + let results = mc_results.map(|decrypted| { + super::PRFValue { + first: decrypted.output1, + second: decrypted.output2, + } + }); + prf = Some(MakeCredentialPrfOutput { enabled: signed_extensions.hmac_secret, + results, }); } } @@ -280,17 +299,23 @@ impl WebAuthnIDL for MakeCredentialRequest { #[derive(Debug, Clone, Deserialize, PartialEq)] pub struct MakeCredentialPrfInput { - /// The `eval` field is parsed but not used during credential creation. - /// PRF evaluation only occurs during assertion (getAssertion), not registration. - /// We parse it here to accept valid WebAuthn JSON input without errors. + /// The `eval` field was previously not used during credential creation. + /// With hmac-secret-mc (CTAP 2.2), PRF evaluation can occur at registration time. + /// We still accept the raw JSON value for backward compatibility. #[serde(rename = "eval")] pub _eval: Option, + /// Parsed eval values for use with hmac-secret-mc. + /// Populated when constructing from Rust code directly (not from JSON). + #[serde(skip)] + pub eval: Option, } #[derive(Debug, Default, Clone, Serialize, PartialEq)] pub struct MakeCredentialPrfOutput { #[serde(skip_serializing_if = "Option::is_none")] pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub results: Option, } #[derive(Debug, Clone, Deserialize, PartialEq)] diff --git a/libwebauthn/src/proto/ctap2/model/make_credential.rs b/libwebauthn/src/proto/ctap2/model/make_credential.rs index 739dc9f..3734846 100644 --- a/libwebauthn/src/proto/ctap2/model/make_credential.rs +++ b/libwebauthn/src/proto/ctap2/model/make_credential.rs @@ -7,19 +7,23 @@ use super::{ use crate::{ fido::AuthenticatorData, ops::webauthn::{ - CredentialProtectionPolicy, MakeCredentialLargeBlobExtension, MakeCredentialRequest, - MakeCredentialResponse, MakeCredentialsRequestExtensions, - MakeCredentialsResponseUnsignedExtensions, ResidentKeyRequirement, + CredentialProtectionPolicy, Ctap2HMACGetSecretOutput, HMACGetSecretInput, + MakeCredentialLargeBlobExtension, MakeCredentialRequest, MakeCredentialResponse, + MakeCredentialsRequestExtensions, MakeCredentialsResponseUnsignedExtensions, + ResidentKeyRequirement, }, pin::PinUvAuthProtocol, proto::CtapError, + transport::AuthTokenData, webauthn::Error, }; +use super::get_assertion::CalculatedHMACGetSecretInput; use ctap_types::ctap2::credential_management::CredentialProtectionPolicy as Ctap2CredentialProtectionPolicy; use serde::{Deserialize, Serialize}; use serde_bytes::ByteBuf; use serde_indexed::{DeserializeIndexed, SerializeIndexed}; -use tracing::warn; +use sha2::{Digest, Sha256}; +use tracing::{error, warn}; #[derive(Debug, Default, Clone, Copy, Serialize)] pub struct Ctap2MakeCredentialOptions { @@ -189,6 +193,12 @@ pub struct Ctap2MakeCredentialsRequestExtensions { // Thanks, FIDO-spec for this consistent naming scheme... #[serde(rename = "hmac-secret", skip_serializing_if = "Option::is_none")] pub hmac_secret: Option, + // CTAP 2.2 hmac-secret-mc: allows sending salts at MakeCredential time + #[serde(rename = "hmac-secret-mc", skip_serializing_if = "Option::is_none")] + pub hmac_secret_mc: Option, + // Internal: stores PRF eval input for hmac-secret-mc calculation + #[serde(skip)] + pub(crate) prf_input: Option, } impl Ctap2MakeCredentialsRequestExtensions { @@ -198,6 +208,68 @@ impl Ctap2MakeCredentialsRequestExtensions { && self.large_blob_key.is_none() && self.min_pin_length.is_none() && self.hmac_secret.is_none() + && self.hmac_secret_mc.is_none() + } + + pub fn calculate_hmac_secret_mc( + &mut self, + auth_data: &AuthTokenData, + ) { + let input = match self.prf_input.take() { + None => return, + Some(i) => i, + }; + + let uv_proto = auth_data.protocol_version.create_protocol_object(); + let public_key = auth_data.key_agreement.clone(); + + let mut salts = input.salt1.to_vec(); + if let Some(salt2) = input.salt2 { + salts.extend(salt2); + } + let salt_enc = if let Ok(res) = uv_proto.encrypt(&auth_data.shared_secret, &salts) { + ByteBuf::from(res) + } else { + error!("Failed to encrypt HMAC salts with shared secret! Skipping hmac-secret-mc"); + return; + }; + + let salt_auth = ByteBuf::from(uv_proto.authenticate(&auth_data.shared_secret, &salt_enc)); + + self.hmac_secret_mc = Some(CalculatedHMACGetSecretInput { + public_key, + salt_enc, + salt_auth, + pin_auth_proto: Some(auth_data.protocol_version as u32), + }); + } + + fn prf_eval_to_hmac_input( + eval: &crate::ops::webauthn::PRFValue, + ) -> HMACGetSecretInput { + let mut prefix = String::from("WebAuthn PRF").into_bytes(); + prefix.push(0x00); + + let mut salt1_input = prefix.clone(); + salt1_input.extend(eval.first); + let mut hasher = Sha256::default(); + hasher.update(salt1_input); + let salt1_hash = hasher.finalize().to_vec(); + let mut salt1 = [0u8; 32]; + salt1.copy_from_slice(&salt1_hash[..32]); + + let salt2 = eval.second.map(|second| { + let mut salt2_input = prefix.clone(); + salt2_input.extend(second); + let mut hasher = Sha256::default(); + hasher.update(salt2_input); + let salt2_hash = hasher.finalize().to_vec(); + let mut salt2 = [0u8; 32]; + salt2.copy_from_slice(&salt2_hash[..32]); + salt2 + }); + + HMACGetSecretInput { salt1, salt2 } } } @@ -260,12 +332,47 @@ impl Ctap2MakeCredentialsRequestExtensions { None }; + // hmac-secret-mc: If the authenticator supports hmac-secret-mc and PRF eval is provided, + // prepare the salts for encryption (will be calculated later when shared secret is available). + let hmac_secret_mc_supported = info + .extensions + .as_ref() + .map(|e| e.contains(&String::from("hmac-secret-mc"))) + .unwrap_or_default(); + + let prf_input = if hmac_secret_mc_supported { + if let Some(prf) = requested_extensions.prf.as_ref() { + // Try the parsed `eval` field first (Rust API path) + if let Some(eval) = &prf.eval { + Some(Self::prf_eval_to_hmac_input(eval)) + } + // Otherwise try parsing from the raw `_eval` JSON value (IDL/JSON path) + else if let Some(eval_json) = &prf._eval { + serde_json::from_value::(eval_json.clone()) + .ok() + .and_then(|prf_values| { + let first: [u8; 32] = prf_values.first.as_slice().try_into().ok()?; + let second = prf_values.second.and_then(|s| s.as_slice().try_into().ok()); + Some(Self::prf_eval_to_hmac_input(&crate::ops::webauthn::PRFValue { first, second })) + }) + } else { + None + } + } else { + None + } + } else { + None + }; + Ok(Ctap2MakeCredentialsRequestExtensions { cred_blob: requested_extensions .cred_blob .as_ref() .map(|inner| inner.0.clone()), hmac_secret, + hmac_secret_mc: None, // Calculated later when shared secret is available + prf_input, cred_protect: requested_extensions .cred_protect .as_ref() @@ -301,12 +408,14 @@ impl Ctap2MakeCredentialResponse { self, request: &MakeCredentialRequest, info: Option<&Ctap2GetInfoResponse>, + auth_data: Option<&AuthTokenData>, ) -> MakeCredentialResponse { let unsigned_extensions_output = MakeCredentialsResponseUnsignedExtensions::from_signed_extensions( &self.authenticator_data.extensions, request, info, + auth_data, ); MakeCredentialResponse { format: self.format, @@ -358,8 +467,18 @@ impl Ctap2UserVerifiableRequest for Ctap2MakeCredentialRequest { // No-op } - fn needs_shared_secret(&self, _get_info_response: &Ctap2GetInfoResponse) -> bool { - false + fn needs_shared_secret(&self, get_info_response: &Ctap2GetInfoResponse) -> bool { + let hmac_secret_mc_supported = get_info_response + .extensions + .as_ref() + .map(|e| e.contains(&String::from("hmac-secret-mc"))) + .unwrap_or_default(); + let hmac_secret_mc_requested = self + .extensions + .as_ref() + .map(|e| e.prf_input.is_some()) + .unwrap_or_default(); + hmac_secret_mc_requested && hmac_secret_mc_supported } } @@ -378,6 +497,13 @@ pub struct Ctap2MakeCredentialsResponseExtensions { skip_serializing_if = "Option::is_none" )] pub hmac_secret: Option, + // CTAP 2.2 hmac-secret-mc: encrypted HMAC output from MakeCredential + #[serde( + rename = "hmac-secret-mc", + default, + skip_serializing_if = "Option::is_none" + )] + pub hmac_secret_mc: Option, // Current min PIN lenght #[serde(default, skip_serializing_if = "Option::is_none")] pub min_pin_length: Option, diff --git a/libwebauthn/src/tests/prf.rs b/libwebauthn/src/tests/prf.rs index b638864..e850780 100644 --- a/libwebauthn/src/tests/prf.rs +++ b/libwebauthn/src/tests/prf.rs @@ -100,7 +100,7 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { let challenge: [u8; 32] = thread_rng().gen(); let extensions = MakeCredentialsRequestExtensions { - prf: Some(MakeCredentialPrfInput { _eval: None }), + prf: Some(MakeCredentialPrfInput { _eval: None, eval: None }), ..Default::default() }; @@ -160,7 +160,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { assert_eq!( response.unsigned_extensions_output.prf, Some(MakeCredentialPrfOutput { - enabled: Some(true) + enabled: Some(true), + results: None, }) ); diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index 134802a..d30fea5 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -118,6 +118,13 @@ where user_verification(self, op.user_verification, &mut ctap2_request, op.timeout) .await?; + // Calculate hmac-secret-mc if we have a shared secret and PRF input + if let Some(auth_data) = self.get_auth_data() { + if let Some(e) = ctap2_request.extensions.as_mut() { + e.calculate_hmac_secret_mc(auth_data); + } + } + // We've already sent out this update, in case we used builtin UV // but in all other cases, we need to touch the device now. if uv_auth_used @@ -134,7 +141,7 @@ where op.timeout ) }?; - let make_cred = response.into_make_credential_output(op, Some(&get_info_response)); + let make_cred = response.into_make_credential_output(op, Some(&get_info_response), self.get_auth_data()); Ok(make_cred) } From 2ea16918c9c4b05ed3539763e5e1f0fc1d5dc7f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:31:28 +0000 Subject: [PATCH 3/5] =?UTF-8?q?Refactor:=20share=20PRF=E2=86=92HMAC=20logi?= =?UTF-8?q?c=20between=20GA=20and=20MC,=20remove=20=5Feval=20from=20MakeCr?= =?UTF-8?q?edentialPrfInput?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: AlfioEmanueleFresta <621062+AlfioEmanueleFresta@users.noreply.github.com> --- libwebauthn/examples/webauthn_prf_hid.rs | 2 +- libwebauthn/src/ops/webauthn/get_assertion.rs | 2 +- .../src/ops/webauthn/make_credential.rs | 24 +++---- .../src/proto/ctap2/model/get_assertion.rs | 63 +++++++++++-------- .../src/proto/ctap2/model/make_credential.rs | 56 ++--------------- libwebauthn/src/tests/prf.rs | 2 +- 6 files changed, 58 insertions(+), 91 deletions(-) diff --git a/libwebauthn/examples/webauthn_prf_hid.rs b/libwebauthn/examples/webauthn_prf_hid.rs index 75d1d21..261666e 100644 --- a/libwebauthn/examples/webauthn_prf_hid.rs +++ b/libwebauthn/examples/webauthn_prf_hid.rs @@ -85,7 +85,7 @@ pub async fn main() -> Result<(), Box> { let challenge: [u8; 32] = thread_rng().gen(); let extensions = MakeCredentialsRequestExtensions { - prf: Some(MakeCredentialPrfInput { _eval: None, eval: None }), + prf: Some(MakeCredentialPrfInput { eval: None }), ..Default::default() }; diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index bc129de..6f1841f 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -28,7 +28,7 @@ use crate::{ use super::timeout::DEFAULT_TIMEOUT; use super::{DowngradableRequest, RelyingPartyId, SignRequest, UserVerificationRequirement}; -#[derive(Debug, Default, Clone, Serialize, PartialEq)] +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] pub struct PRFValue { #[serde(with = "serde_bytes")] pub first: [u8; 32], diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 4645819..2247393 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -2,7 +2,6 @@ use std::time::Duration; use ctap_types::ctap2::credential_management::CredentialProtectionPolicy as Ctap2CredentialProtectionPolicy; use serde::{Deserialize, Serialize}; -use serde_json::{self, Value as JsonValue}; use sha2::{Digest, Sha256}; use tracing::{debug, instrument, trace}; @@ -297,19 +296,22 @@ impl WebAuthnIDL for MakeCredentialRequest { type InnerModel = PublicKeyCredentialCreationOptionsJSON; } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] pub struct MakeCredentialPrfInput { - /// The `eval` field was previously not used during credential creation. - /// With hmac-secret-mc (CTAP 2.2), PRF evaluation can occur at registration time. - /// We still accept the raw JSON value for backward compatibility. - #[serde(rename = "eval")] - pub _eval: Option, - /// Parsed eval values for use with hmac-secret-mc. - /// Populated when constructing from Rust code directly (not from JSON). - #[serde(skip)] + /// PRF eval values for hmac-secret-mc (CTAP 2.2). + /// At MC time, only a single eval value is supported (no eval_by_credential). + /// Accepts both base64url-encoded (JSON IDL) and raw PRFValue (Rust API). + #[serde(default)] pub eval: Option, } +impl MakeCredentialPrfInput { + /// Create a new MakeCredentialPrfInput with the given eval value. + pub fn new(eval: Option) -> Self { + Self { eval } + } +} + #[derive(Debug, Default, Clone, Serialize, PartialEq)] pub struct MakeCredentialPrfOutput { #[serde(skip_serializing_if = "Option::is_none")] @@ -630,7 +632,7 @@ mod tests { let req_json = json_field_add( REQUEST_BASE_JSON, "extensions", - r#"{"prf": {"eval": {"first": "second"}}}"#, + r#"{"prf": {}}"#, ); let req: MakeCredentialRequest = diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index 078e9ab..819bb94 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -319,33 +319,7 @@ impl Ctap2GetAssertionRequestExtensions { // 5. If ev is not null: if let Some(ev) = ev { - // SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.first). - let mut prefix = String::from("WebAuthn PRF").into_bytes(); - prefix.push(0x00); - - 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); - - let mut hasher = Sha256::default(); - hasher.update(salt1_input); - let salt1_hash = hasher.finalize().to_vec(); - 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 { - let mut salt2_input = prefix.clone(); - salt2_input.extend(second); - let mut hasher = Sha256::default(); - hasher.update(salt2_input); - let salt2_hash = hasher.finalize().to_vec(); - let mut salt2 = [0u8; 32]; - salt2.copy_from_slice(&salt2_hash[..32]); - input.salt2 = Some(salt2); - }; - - Ok(Some(input)) + Ok(Some(prf_value_to_hmac_input(ev))) } else { // We don't have a usable PRF, so we don't do any HMAC Ok(None) @@ -353,6 +327,41 @@ impl Ctap2GetAssertionRequestExtensions { } } +/// Converts a PRFValue to HMACGetSecretInput by hashing the PRF values with the +/// "WebAuthn PRF" prefix as specified in the WebAuthn PRF extension. +/// https://w3c.github.io/webauthn/#prf +/// +/// Shared between GetAssertion (hmac-secret) and MakeCredential (hmac-secret-mc). +pub(crate) fn prf_value_to_hmac_input(ev: &PRFValue) -> HMACGetSecretInput { + // SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.first). + let mut prefix = String::from("WebAuthn PRF").into_bytes(); + prefix.push(0x00); + + 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); + + let mut hasher = Sha256::default(); + hasher.update(salt1_input); + let salt1_hash = hasher.finalize().to_vec(); + 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 { + let mut salt2_input = prefix.clone(); + salt2_input.extend(second); + let mut hasher = Sha256::default(); + hasher.update(salt2_input); + let salt2_hash = hasher.finalize().to_vec(); + let mut salt2 = [0u8; 32]; + salt2.copy_from_slice(&salt2_hash[..32]); + input.salt2 = Some(salt2); + }; + + input +} + #[derive(Debug, Clone, SerializeIndexed)] pub struct CalculatedHMACGetSecretInput { // keyAgreement(0x01): public key of platform key-agreement key. diff --git a/libwebauthn/src/proto/ctap2/model/make_credential.rs b/libwebauthn/src/proto/ctap2/model/make_credential.rs index 3734846..08e7101 100644 --- a/libwebauthn/src/proto/ctap2/model/make_credential.rs +++ b/libwebauthn/src/proto/ctap2/model/make_credential.rs @@ -17,12 +17,11 @@ use crate::{ transport::AuthTokenData, webauthn::Error, }; -use super::get_assertion::CalculatedHMACGetSecretInput; +use super::get_assertion::{prf_value_to_hmac_input, CalculatedHMACGetSecretInput}; use ctap_types::ctap2::credential_management::CredentialProtectionPolicy as Ctap2CredentialProtectionPolicy; use serde::{Deserialize, Serialize}; use serde_bytes::ByteBuf; use serde_indexed::{DeserializeIndexed, SerializeIndexed}; -use sha2::{Digest, Sha256}; use tracing::{error, warn}; #[derive(Debug, Default, Clone, Copy, Serialize)] @@ -243,34 +242,6 @@ impl Ctap2MakeCredentialsRequestExtensions { pin_auth_proto: Some(auth_data.protocol_version as u32), }); } - - fn prf_eval_to_hmac_input( - eval: &crate::ops::webauthn::PRFValue, - ) -> HMACGetSecretInput { - let mut prefix = String::from("WebAuthn PRF").into_bytes(); - prefix.push(0x00); - - let mut salt1_input = prefix.clone(); - salt1_input.extend(eval.first); - let mut hasher = Sha256::default(); - hasher.update(salt1_input); - let salt1_hash = hasher.finalize().to_vec(); - let mut salt1 = [0u8; 32]; - salt1.copy_from_slice(&salt1_hash[..32]); - - let salt2 = eval.second.map(|second| { - let mut salt2_input = prefix.clone(); - salt2_input.extend(second); - let mut hasher = Sha256::default(); - hasher.update(salt2_input); - let salt2_hash = hasher.finalize().to_vec(); - let mut salt2 = [0u8; 32]; - salt2.copy_from_slice(&salt2_hash[..32]); - salt2 - }); - - HMACGetSecretInput { salt1, salt2 } - } } impl Ctap2MakeCredentialsRequestExtensions { @@ -341,26 +312,11 @@ impl Ctap2MakeCredentialsRequestExtensions { .unwrap_or_default(); let prf_input = if hmac_secret_mc_supported { - if let Some(prf) = requested_extensions.prf.as_ref() { - // Try the parsed `eval` field first (Rust API path) - if let Some(eval) = &prf.eval { - Some(Self::prf_eval_to_hmac_input(eval)) - } - // Otherwise try parsing from the raw `_eval` JSON value (IDL/JSON path) - else if let Some(eval_json) = &prf._eval { - serde_json::from_value::(eval_json.clone()) - .ok() - .and_then(|prf_values| { - let first: [u8; 32] = prf_values.first.as_slice().try_into().ok()?; - let second = prf_values.second.and_then(|s| s.as_slice().try_into().ok()); - Some(Self::prf_eval_to_hmac_input(&crate::ops::webauthn::PRFValue { first, second })) - }) - } else { - None - } - } else { - None - } + requested_extensions + .prf + .as_ref() + .and_then(|prf| prf.eval.as_ref()) + .map(prf_value_to_hmac_input) } else { None }; diff --git a/libwebauthn/src/tests/prf.rs b/libwebauthn/src/tests/prf.rs index e850780..5760cb9 100644 --- a/libwebauthn/src/tests/prf.rs +++ b/libwebauthn/src/tests/prf.rs @@ -100,7 +100,7 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { let challenge: [u8; 32] = thread_rng().gen(); let extensions = MakeCredentialsRequestExtensions { - prf: Some(MakeCredentialPrfInput { _eval: None, eval: None }), + prf: Some(MakeCredentialPrfInput { eval: None }), ..Default::default() }; From 1d0e0258f0338dc1ca3ac29eb5d0603a936b0a7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:33:15 +0000 Subject: [PATCH 4/5] Add test for PRF eval JSON deserialization, fix PRFValue serde defaults Co-authored-by: AlfioEmanueleFresta <621062+AlfioEmanueleFresta@users.noreply.github.com> --- libwebauthn/src/ops/webauthn/get_assertion.rs | 6 ++++- .../src/ops/webauthn/make_credential.rs | 25 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 6f1841f..f6edf83 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -32,7 +32,11 @@ use super::{DowngradableRequest, RelyingPartyId, SignRequest, UserVerificationRe pub struct PRFValue { #[serde(with = "serde_bytes")] pub first: [u8; 32], - #[serde(skip_serializing_if = "Option::is_none", with = "serde_bytes")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_bytes" + )] pub second: Option<[u8; 32]>, } diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 2247393..a8914a4 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -639,10 +639,33 @@ mod tests { MakeCredentialRequest::from_json(&rpid, &req_json).unwrap(); assert!(matches!( req.extensions, - Some(MakeCredentialsRequestExtensions { prf: Some(_), .. }) + Some(MakeCredentialsRequestExtensions { + prf: Some(MakeCredentialPrfInput { eval: None }), + .. + }) )); } + #[test] + fn test_request_from_json_prf_extension_with_eval() { + let rpid = RelyingPartyId::try_from("example.org").unwrap(); + // PRF eval values as JSON arrays of bytes (serde_bytes format) + let first_bytes = (1..=32) + .map(|i| i.to_string()) + .collect::>() + .join(","); + let eval_json = format!(r#"{{"prf": {{"eval": {{"first": [{}]}}}}}}"#, first_bytes); + let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &eval_json); + + let req: MakeCredentialRequest = + MakeCredentialRequest::from_json(&rpid, &req_json).unwrap(); + let prf = req.extensions.unwrap().prf.unwrap(); + assert!(prf.eval.is_some()); + let eval = prf.eval.unwrap(); + assert_eq!(eval.first, (1..=32).collect::>().as_slice()); + assert!(eval.second.is_none()); + } + #[test] fn test_request_from_json_unknown_pub_key_cred_params() { let rpid = RelyingPartyId::try_from("example.org").unwrap(); From 8395abe63ddbe8d9f75c74224a53c85ba8b84e47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:22:06 +0000 Subject: [PATCH 5/5] Move PRF/HMAC shared code to proto::extensions::prf, add eval to test, remove constructor Co-authored-by: AlfioEmanueleFresta <621062+AlfioEmanueleFresta@users.noreply.github.com> --- .../src/ops/webauthn/make_credential.rs | 8 --- .../src/proto/ctap2/model/get_assertion.rs | 59 ++----------------- .../src/proto/ctap2/model/make_credential.rs | 6 +- libwebauthn/src/proto/extensions/mod.rs | 1 + libwebauthn/src/proto/extensions/prf.rs | 58 ++++++++++++++++++ libwebauthn/src/proto/mod.rs | 1 + libwebauthn/src/tests/prf.rs | 7 ++- 7 files changed, 74 insertions(+), 66 deletions(-) create mode 100644 libwebauthn/src/proto/extensions/mod.rs create mode 100644 libwebauthn/src/proto/extensions/prf.rs diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index a8914a4..2bf1656 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -300,18 +300,10 @@ impl WebAuthnIDL for MakeCredentialRequest { pub struct MakeCredentialPrfInput { /// PRF eval values for hmac-secret-mc (CTAP 2.2). /// At MC time, only a single eval value is supported (no eval_by_credential). - /// Accepts both base64url-encoded (JSON IDL) and raw PRFValue (Rust API). #[serde(default)] pub eval: Option, } -impl MakeCredentialPrfInput { - /// Create a new MakeCredentialPrfInput with the given eval value. - pub fn new(eval: Option) -> Self { - Self { eval } - } -} - #[derive(Debug, Default, Clone, Serialize, PartialEq)] pub struct MakeCredentialPrfOutput { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index 819bb94..cfeba0d 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -7,7 +7,10 @@ use crate::{ GetAssertionResponseUnsignedExtensions, HMACGetSecretInput, PRFValue, }, pin::PinUvAuthProtocol, - proto::ctap2::cbor::Value, + proto::{ + ctap2::cbor::Value, + extensions::prf::{prf_value_to_hmac_input, CalculatedHMACGetSecretInput}, + }, transport::AuthTokenData, webauthn::{Error, PlatformError}, }; @@ -17,11 +20,9 @@ use super::{ Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialUserEntity, Ctap2UserVerifiableRequest, }; -use cosey::PublicKey; use serde::{Deserialize, Serialize}; use serde_bytes::ByteBuf; use serde_indexed::{DeserializeIndexed, SerializeIndexed}; -use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashMap}; use tracing::error; @@ -327,58 +328,6 @@ impl Ctap2GetAssertionRequestExtensions { } } -/// Converts a PRFValue to HMACGetSecretInput by hashing the PRF values with the -/// "WebAuthn PRF" prefix as specified in the WebAuthn PRF extension. -/// https://w3c.github.io/webauthn/#prf -/// -/// Shared between GetAssertion (hmac-secret) and MakeCredential (hmac-secret-mc). -pub(crate) fn prf_value_to_hmac_input(ev: &PRFValue) -> HMACGetSecretInput { - // SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.first). - let mut prefix = String::from("WebAuthn PRF").into_bytes(); - prefix.push(0x00); - - 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); - - let mut hasher = Sha256::default(); - hasher.update(salt1_input); - let salt1_hash = hasher.finalize().to_vec(); - 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 { - let mut salt2_input = prefix.clone(); - salt2_input.extend(second); - let mut hasher = Sha256::default(); - hasher.update(salt2_input); - let salt2_hash = hasher.finalize().to_vec(); - let mut salt2 = [0u8; 32]; - salt2.copy_from_slice(&salt2_hash[..32]); - input.salt2 = Some(salt2); - }; - - input -} - -#[derive(Debug, Clone, SerializeIndexed)] -pub struct CalculatedHMACGetSecretInput { - // keyAgreement(0x01): public key of platform key-agreement key. - #[serde(index = 0x01)] - pub public_key: PublicKey, - // saltEnc(0x02): Encryption of the one or two salts - #[serde(index = 0x02)] - pub salt_enc: ByteBuf, - // saltAuth(0x03): authenticate(shared secret, saltEnc) - #[serde(index = 0x03)] - pub salt_auth: ByteBuf, - // pinUvAuthProtocol(0x04): (optional) as selected when getting the shared secret. CTAP2.1 platforms MUST include this parameter if the value of pinUvAuthProtocol is not 1. - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(index = 0x04)] - pub pin_auth_proto: Option, -} - #[derive(Debug, Clone, DeserializeIndexed)] pub struct Ctap2GetAssertionResponse { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/libwebauthn/src/proto/ctap2/model/make_credential.rs b/libwebauthn/src/proto/ctap2/model/make_credential.rs index 08e7101..a616b15 100644 --- a/libwebauthn/src/proto/ctap2/model/make_credential.rs +++ b/libwebauthn/src/proto/ctap2/model/make_credential.rs @@ -13,11 +13,13 @@ use crate::{ ResidentKeyRequirement, }, pin::PinUvAuthProtocol, - proto::CtapError, + proto::{ + extensions::prf::{prf_value_to_hmac_input, CalculatedHMACGetSecretInput}, + CtapError, + }, transport::AuthTokenData, webauthn::Error, }; -use super::get_assertion::{prf_value_to_hmac_input, CalculatedHMACGetSecretInput}; use ctap_types::ctap2::credential_management::CredentialProtectionPolicy as Ctap2CredentialProtectionPolicy; use serde::{Deserialize, Serialize}; use serde_bytes::ByteBuf; diff --git a/libwebauthn/src/proto/extensions/mod.rs b/libwebauthn/src/proto/extensions/mod.rs new file mode 100644 index 0000000..641ae45 --- /dev/null +++ b/libwebauthn/src/proto/extensions/mod.rs @@ -0,0 +1 @@ +pub mod prf; diff --git a/libwebauthn/src/proto/extensions/prf.rs b/libwebauthn/src/proto/extensions/prf.rs new file mode 100644 index 0000000..0897b2e --- /dev/null +++ b/libwebauthn/src/proto/extensions/prf.rs @@ -0,0 +1,58 @@ +use crate::ops::webauthn::{HMACGetSecretInput, PRFValue}; + +use cosey::PublicKey; +use serde_bytes::ByteBuf; +use serde_indexed::SerializeIndexed; +use sha2::{Digest, Sha256}; + +/// Converts a PRFValue to HMACGetSecretInput by hashing the PRF values with the +/// "WebAuthn PRF" prefix as specified in the WebAuthn PRF extension. +/// https://w3c.github.io/webauthn/#prf +/// +/// Shared between GetAssertion (hmac-secret) and MakeCredential (hmac-secret-mc). +pub(crate) fn prf_value_to_hmac_input(ev: &PRFValue) -> HMACGetSecretInput { + // SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.first). + let mut prefix = String::from("WebAuthn PRF").into_bytes(); + prefix.push(0x00); + + 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); + + let mut hasher = Sha256::default(); + hasher.update(salt1_input); + let salt1_hash = hasher.finalize().to_vec(); + 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 { + let mut salt2_input = prefix.clone(); + salt2_input.extend(second); + let mut hasher = Sha256::default(); + hasher.update(salt2_input); + let salt2_hash = hasher.finalize().to_vec(); + let mut salt2 = [0u8; 32]; + salt2.copy_from_slice(&salt2_hash[..32]); + input.salt2 = Some(salt2); + }; + + input +} + +#[derive(Debug, Clone, SerializeIndexed)] +pub struct CalculatedHMACGetSecretInput { + // keyAgreement(0x01): public key of platform key-agreement key. + #[serde(index = 0x01)] + pub public_key: PublicKey, + // saltEnc(0x02): Encryption of the one or two salts + #[serde(index = 0x02)] + pub salt_enc: ByteBuf, + // saltAuth(0x03): authenticate(shared secret, saltEnc) + #[serde(index = 0x03)] + pub salt_auth: ByteBuf, + // pinUvAuthProtocol(0x04): (optional) as selected when getting the shared secret. CTAP2.1 platforms MUST include this parameter if the value of pinUvAuthProtocol is not 1. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(index = 0x04)] + pub pin_auth_proto: Option, +} diff --git a/libwebauthn/src/proto/mod.rs b/libwebauthn/src/proto/mod.rs index f7080d9..2e13311 100644 --- a/libwebauthn/src/proto/mod.rs +++ b/libwebauthn/src/proto/mod.rs @@ -2,5 +2,6 @@ mod error; pub mod ctap1; pub mod ctap2; +pub(crate) mod extensions; pub use error::CtapError; diff --git a/libwebauthn/src/tests/prf.rs b/libwebauthn/src/tests/prf.rs index 5760cb9..f13de96 100644 --- a/libwebauthn/src/tests/prf.rs +++ b/libwebauthn/src/tests/prf.rs @@ -100,7 +100,12 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { let challenge: [u8; 32] = thread_rng().gen(); let extensions = MakeCredentialsRequestExtensions { - prf: Some(MakeCredentialPrfInput { eval: None }), + prf: Some(MakeCredentialPrfInput { + eval: Some(PRFValue { + first: [1; 32], + second: None, + }), + }), ..Default::default() };