diff --git a/libwebauthn-tests/tests/prf.rs b/libwebauthn-tests/tests/prf.rs index 2223283..28edaad 100644 --- a/libwebauthn-tests/tests/prf.rs +++ b/libwebauthn-tests/tests/prf.rs @@ -67,6 +67,59 @@ async fn test_webauthn_prf_with_pin_set_forced_pin_protocol_two() { run_test_battery(&mut channel, true).await; } +/// The Trussed virtual key advertises `hmac-secret` but not `hmac-secret-mc`. +/// Requesting PRF.eval at create() must therefore degrade gracefully: the +/// credential is still created with `hmac-secret: true` so PRF works via GA, +/// no `hmac-secret-mc` is sent on the wire, and `prf.results` stays None. +#[test(tokio::test)] +async fn test_webauthn_prf_eval_at_create_degrades_when_unsupported() { + let mut device = get_virtual_device(); + let mut channel = device.channel().await.unwrap(); + let state_recv = channel.get_ux_update_receiver(); + tokio::spawn(handle_updates( + state_recv, + vec![UvUpdateShim::PresenceRequired], + )); + + let user_id: [u8; 32] = thread_rng().gen(); + let challenge: [u8; 32] = thread_rng().gen(); + let extensions = MakeCredentialsRequestExtensions { + prf: Some(MakeCredentialPrfInput { + eval: Some(PRFValue { + first: [9; 32], + second: None, + }), + }), + ..Default::default() + }; + let req = 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::Discouraged, + algorithms: vec![Ctap2CredentialType::default()], + exclude: None, + extensions: Some(extensions), + timeout: TIMEOUT, + top_origin: None, + }; + + let response = channel + .webauthn_make_credential(&req) + .await + .expect("MakeCredential should succeed"); + assert_eq!( + response.unsigned_extensions_output.prf, + Some(MakeCredentialPrfOutput { + enabled: Some(true), + results: None, + }), + "device does not advertise hmac-secret-mc; results must stay None" + ); +} + enum UvUpdateShim { PresenceRequired, PinRequired, @@ -100,7 +153,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 }), ..Default::default() }; @@ -161,7 +214,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/examples/features/webauthn_prf_hid.rs b/libwebauthn/examples/features/webauthn_prf_hid.rs index 0cda6e4..69b9b69 100644 --- a/libwebauthn/examples/features/webauthn_prf_hid.rs +++ b/libwebauthn/examples/features/webauthn_prf_hid.rs @@ -35,7 +35,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 }), ..Default::default() }; diff --git a/libwebauthn/src/ops/u2f.rs b/libwebauthn/src/ops/u2f.rs index ba40c6f..d3bfe34 100644 --- a/libwebauthn/src/ops/u2f.rs +++ b/libwebauthn/src/ops/u2f.rs @@ -157,7 +157,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/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index df55859..0c52863 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -45,6 +45,25 @@ pub struct PRFValue { pub second: Option<[u8; 32]>, } +impl PRFValue { + /// WebAuthn L3 PRF: salt = SHA-256("WebAuthn PRF" || 0x00 || ev.{first,second}). + pub fn to_hmac_secret_input(&self) -> HMACGetSecretInput { + const PREFIX: &[u8] = b"WebAuthn PRF\x00"; + let hash = |slice: &[u8; 32]| { + let mut hasher = Sha256::default(); + hasher.update(PREFIX); + hasher.update(slice); + let mut out = [0u8; 32]; + out.copy_from_slice(&hasher.finalize()[..32]); + out + }; + HMACGetSecretInput { + salt1: hash(&self.first), + salt2: self.second.as_ref().map(hash), + } + } +} + #[derive(Debug, Clone, PartialEq)] pub struct GetAssertionRequest { pub relying_party_id: String, diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 03d2b6d..6ea4e52 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -1,8 +1,7 @@ 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 serde::{Deserialize, Deserializer, Serialize}; use sha2::{Digest, Sha256}; use tracing::{debug, instrument, trace}; @@ -12,16 +11,17 @@ use crate::{ client_data::ClientData, idl::{ create::PublicKeyCredentialCreationOptionsJSON, + get::PrfValuesJson, origin::is_registrable_domain_suffix_or_equal, response::{ AuthenticationExtensionsClientOutputsJSON, AuthenticatorAttestationResponseJSON, - CredentialPropertiesOutputJSON, LargeBlobOutputJSON, PRFOutputJSON, + CredentialPropertiesOutputJSON, LargeBlobOutputJSON, PRFOutputJSON, PRFValuesJSON, RegistrationResponseJSON, ResponseSerializationError, WebAuthnIDLResponse, }, Base64UrlString, FromIdlModel, JsonError, WebAuthnIDL, }, psl::PublicSuffixList, - Operation, RelyingPartyId, RequestOrigin, + Operation, PRFValue, RelyingPartyId, RequestOrigin, }, proto::{ ctap1::{Ctap1RegisteredKey, Ctap1Version}, @@ -32,6 +32,7 @@ use crate::{ Ctap2PublicKeyCredentialUserEntity, }, }, + transport::AuthTokenData, }; use super::timeout::DEFAULT_TIMEOUT; @@ -176,11 +177,16 @@ impl MakeCredentialResponse { }); } - // PRF extension if let Some(prf) = &unsigned_ext.prf { results.prf = Some(PRFOutputJSON { enabled: prf.enabled, - results: None, + results: prf.results.as_ref().map(|v| PRFValuesJSON { + first: Base64UrlString::from(v.first.as_slice()), + second: v + .second + .as_ref() + .map(|s| Base64UrlString::from(s.as_slice())), + }), }); } @@ -216,19 +222,31 @@ impl MakeCredentialsResponseUnsignedExtensions { signed_extensions: &Option, request: &MakeCredentialRequest, info: Option<&Ctap2GetInfoResponse>, + auth_data: Option<&AuthTokenData>, ) -> MakeCredentialsResponseUnsignedExtensions { let mut hmac_create_secret = None; let mut prf = None; if let Some(signed_extensions) = signed_extensions { if let Some(incoming_ext) = &request.extensions { - // hmacCreateSecret and prf can both be requested and returned independently. - // Both map to the same underlying CTAP2 hmac-secret extension. if incoming_ext.hmac_create_secret.is_some() { hmac_create_secret = signed_extensions.hmac_secret; } if incoming_ext.prf.is_some() { + let results = signed_extensions + .hmac_secret_mc + .as_ref() + .zip(auth_data) + .and_then(|(out, auth)| { + let uv_proto = auth.protocol_version.create_protocol_object(); + out.decrypt_output(&auth.shared_secret, uv_proto.as_ref()) + }) + .map(|decrypted| PRFValue { + first: decrypted.output1, + second: decrypted.output2, + }); prf = Some(MakeCredentialPrfOutput { enabled: signed_extensions.hmac_secret, + results, }); } } @@ -448,19 +466,40 @@ impl WebAuthnIDL for MakeCredentialRequest { type IdlModel = PublicKeyCredentialCreationOptionsJSON; } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Default, 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. - #[serde(rename = "eval")] - pub _eval: Option, + #[serde(default, deserialize_with = "deserialize_prf_eval")] + pub eval: Option, +} + +fn deserialize_prf_eval<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let Some(json) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + let first: [u8; 32] = json.first.as_slice().try_into().map_err(|_| { + serde::de::Error::invalid_length( + json.first.as_slice().len(), + &"32 bytes (base64url-decoded)", + ) + })?; + let second = match json.second { + Some(s) => Some(s.as_slice().try_into().map_err(|_| { + serde::de::Error::invalid_length(s.as_slice().len(), &"32 bytes (base64url-decoded)") + })?), + None => None, + }; + Ok(Some(PRFValue { first, second })) } #[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)] @@ -772,18 +811,48 @@ mod tests { #[test] 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"}}}"#, - ); + let first = base64_url::encode(&[1u8; 32]); + let second = base64_url::encode(&[2u8; 32]); + let ext = format!(r#"{{"prf": {{"eval": {{"first": "{first}", "second": "{second}"}}}}}}"#); + let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext); let req: MakeCredentialRequest = MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) .unwrap(); + let prf = req + .extensions + .as_ref() + .and_then(|e| e.prf.as_ref()) + .and_then(|p| p.eval.as_ref()) + .expect("prf.eval parsed"); + assert_eq!(prf.first, [1u8; 32]); + assert_eq!(prf.second, Some([2u8; 32])); + } + + #[test] + fn test_request_from_json_prf_extension_empty() { + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", r#"{"prf": {}}"#); + + let req: MakeCredentialRequest = + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .unwrap(); + let prf = req.extensions.unwrap().prf.unwrap(); + assert!(prf.eval.is_none()); + } + + #[test] + fn test_request_from_json_prf_extension_invalid_length() { + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let short = base64_url::encode(&[0u8; 16]); + let ext = format!(r#"{{"prf": {{"eval": {{"first": "{short}"}}}}}}"#); + let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext); + + let res = + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); assert!(matches!( - req.extensions, - Some(MakeCredentialsRequestExtensions { prf: Some(_), .. }) + res, + Err(MakeCredentialRequestParsingError::EncodingError(_)) )); } @@ -1105,6 +1174,7 @@ mod tests { large_blob: None, prf: Some(MakeCredentialPrfOutput { enabled: Some(true), + results: None, }), }; diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index dd61a2a..c4f876b 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -21,7 +21,6 @@ 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; @@ -319,39 +318,8 @@ impl Ctap2GetAssertionRequestExtensions { ev = eval.as_ref(); } - // 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)) - } else { - // We don't have a usable PRF, so we don't do any HMAC - Ok(None) - } + // 5. If ev is not null, derive salt1/salt2 per WebAuthn L3. + Ok(ev.map(PRFValue::to_hmac_secret_input)) } } diff --git a/libwebauthn/src/proto/ctap2/model/get_info.rs b/libwebauthn/src/proto/ctap2/model/get_info.rs index ab38609..96303db 100644 --- a/libwebauthn/src/proto/ctap2/model/get_info.rs +++ b/libwebauthn/src/proto/ctap2/model/get_info.rs @@ -178,6 +178,12 @@ impl Ctap2GetInfoResponse { self.versions.iter().any(|v| v == "FIDO_2_1") } + pub fn supports_extension(&self, name: &str) -> bool { + self.extensions + .as_ref() + .is_some_and(|exts| exts.iter().any(|e| e == name)) + } + pub fn supports_credential_management(&self) -> bool { self.option_enabled("credMgmt") || self.option_enabled("credentialMgmtPreview") } diff --git a/libwebauthn/src/proto/ctap2/model/make_credential.rs b/libwebauthn/src/proto/ctap2/model/make_credential.rs index db798d8..47cd22a 100644 --- a/libwebauthn/src/proto/ctap2/model/make_credential.rs +++ b/libwebauthn/src/proto/ctap2/model/make_credential.rs @@ -1,25 +1,26 @@ use super::{ - Ctap2AttestationStatement, Ctap2AuthTokenPermissionRole, Ctap2CredentialType, - Ctap2GetInfoResponse, Ctap2PinUvAuthProtocol, Ctap2PublicKeyCredentialDescriptor, - Ctap2PublicKeyCredentialRpEntity, Ctap2PublicKeyCredentialUserEntity, - Ctap2UserVerifiableRequest, + get_assertion::CalculatedHMACGetSecretInput, Ctap2AttestationStatement, + Ctap2AuthTokenPermissionRole, Ctap2CredentialType, Ctap2GetInfoResponse, + Ctap2PinUvAuthProtocol, Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity, + Ctap2PublicKeyCredentialUserEntity, Ctap2UserVerifiableRequest, }; use crate::{ fido::AuthenticatorData, ops::webauthn::{ - CredentialProtectionPolicy, MakeCredentialLargeBlobExtension, MakeCredentialRequest, - MakeCredentialResponse, MakeCredentialsRequestExtensions, - MakeCredentialsResponseUnsignedExtensions, ResidentKeyRequirement, + CredentialProtectionPolicy, Ctap2HMACGetSecretOutput, MakeCredentialLargeBlobExtension, + MakeCredentialRequest, MakeCredentialResponse, MakeCredentialsRequestExtensions, + MakeCredentialsResponseUnsignedExtensions, PRFValue, ResidentKeyRequirement, }, pin::PinUvAuthProtocol, proto::CtapError, + transport::AuthTokenData, webauthn::{Error, PlatformError}, }; 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 tracing::{error, warn}; #[derive(Debug, Default, Clone, Copy, Serialize)] pub struct Ctap2MakeCredentialOptions { @@ -189,6 +190,11 @@ 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 § 12.8 + #[serde(rename = "hmac-secret-mc", skip_serializing_if = "Option::is_none")] + pub hmac_secret_mc: Option, + #[serde(skip)] + pub(crate) prf_input: Option, } impl Ctap2MakeCredentialsRequestExtensions { @@ -198,6 +204,7 @@ 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() } } @@ -251,7 +258,6 @@ impl Ctap2MakeCredentialsRequestExtensions { _ => None, }; - // HMAC Secret let hmac_secret = if requested_extensions.hmac_create_secret == Some(true) || requested_extensions.prf.is_some() { @@ -260,12 +266,22 @@ impl Ctap2MakeCredentialsRequestExtensions { None }; + let prf_input = requested_extensions + .prf + .as_ref() + .and_then(|prf| prf.eval.clone()) + .filter(|_| { + info.supports_extension("hmac-secret-mc") && info.supports_extension("hmac-secret") + }); + Ok(Ctap2MakeCredentialsRequestExtensions { cred_blob: requested_extensions .cred_blob .as_ref() .map(|inner| inner.0.clone()), hmac_secret, + hmac_secret_mc: None, + prf_input, cred_protect: requested_extensions .cred_protect .as_ref() @@ -274,6 +290,40 @@ impl Ctap2MakeCredentialsRequestExtensions { min_pin_length: requested_extensions.min_pin_length, }) } + + /// Encrypts the buffered PRF input with the channel's shared secret; CTAP 2.2 § 12.8. + pub fn calculate_hmac_secret_mc(&mut self, auth_data: &AuthTokenData) -> Result<(), Error> { + let Some(prf_input) = self.prf_input.take() else { + return Ok(()); + }; + debug_assert_eq!(self.hmac_secret, Some(true)); + let hmac_input = prf_input.to_hmac_secret_input(); + + let uv_proto = auth_data.protocol_version.create_protocol_object(); + let mut salts = hmac_input.salt1.to_vec(); + if let Some(salt2) = hmac_input.salt2 { + salts.extend(salt2); + } + let salt_enc = match uv_proto.encrypt(&auth_data.shared_secret, &salts) { + Ok(bytes) => ByteBuf::from(bytes), + Err(err) => { + error!( + ?err, + "Failed to encrypt hmac-secret-mc salts; dropping extension" + ); + return Ok(()); + } + }; + let salt_auth = ByteBuf::from(uv_proto.authenticate(&auth_data.shared_secret, &salt_enc)?); + + self.hmac_secret_mc = Some(CalculatedHMACGetSecretInput { + public_key: auth_data.key_agreement.clone(), + salt_enc, + salt_auth, + pin_auth_proto: Some(auth_data.protocol_version as u32), + }); + Ok(()) + } } #[derive(Debug, Clone, DeserializeIndexed)] @@ -301,12 +351,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, @@ -362,8 +414,14 @@ 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 mc_supported = get_info_response.supports_extension("hmac-secret-mc") + && get_info_response.supports_extension("hmac-secret"); + let mc_requested = self + .extensions + .as_ref() + .is_some_and(|e| e.prf_input.is_some()); + mc_supported && mc_requested } } @@ -382,7 +440,243 @@ pub struct Ctap2MakeCredentialsResponseExtensions { skip_serializing_if = "Option::is_none" )] pub hmac_secret: Option, + // CTAP 2.2 § 12.8 + #[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, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ops::webauthn::{MakeCredentialPrfInput, MakeCredentialRequest}; + use std::time::Duration; + + fn info_with_extensions(exts: &[&str]) -> Ctap2GetInfoResponse { + Ctap2GetInfoResponse { + extensions: Some(exts.iter().map(|s| s.to_string()).collect()), + ..Default::default() + } + } + + fn mc_request_with_prf(eval: Option) -> MakeCredentialRequest { + MakeCredentialRequest { + challenge: vec![0u8; 32], + origin: "https://example.org".into(), + top_origin: None, + relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), + user: Ctap2PublicKeyCredentialUserEntity::new(b"u", "u", "U"), + resident_key: None, + user_verification: Default::default(), + algorithms: vec![Ctap2CredentialType::default()], + exclude: None, + extensions: Some(MakeCredentialsRequestExtensions { + prf: Some(MakeCredentialPrfInput { eval }), + ..Default::default() + }), + timeout: Duration::from_secs(10), + } + } + + #[test] + fn prf_with_mc_supported_buffers_prf_input_and_sets_hmac_secret() { + let info = info_with_extensions(&["hmac-secret", "hmac-secret-mc"]); + let req = mc_request_with_prf(Some(PRFValue { + first: [3u8; 32], + second: None, + })); + let ctap = Ctap2MakeCredentialRequest::from_webauthn_request(&req, &info).unwrap(); + let ext = ctap.extensions.unwrap(); + assert_eq!(ext.hmac_secret, Some(true)); + assert!(ext.prf_input.is_some()); + assert!(ext.hmac_secret_mc.is_none()); // not yet encrypted + } + + #[test] + fn prf_without_mc_support_only_sets_hmac_secret() { + let info = info_with_extensions(&["hmac-secret"]); + let req = mc_request_with_prf(Some(PRFValue { + first: [3u8; 32], + second: None, + })); + let ctap = Ctap2MakeCredentialRequest::from_webauthn_request(&req, &info).unwrap(); + let ext = ctap.extensions.unwrap(); + assert_eq!(ext.hmac_secret, Some(true)); + assert!(ext.prf_input.is_none()); + assert!(ext.hmac_secret_mc.is_none()); + } + + #[test] + fn prf_without_eval_does_not_buffer_prf_input() { + let info = info_with_extensions(&["hmac-secret", "hmac-secret-mc"]); + let req = mc_request_with_prf(None); + let ctap = Ctap2MakeCredentialRequest::from_webauthn_request(&req, &info).unwrap(); + let ext = ctap.extensions.unwrap(); + assert_eq!(ext.hmac_secret, Some(true)); + assert!(ext.prf_input.is_none()); + } + + #[test] + fn needs_shared_secret_true_only_when_mc_advertised_and_buffered() { + let info_mc = info_with_extensions(&["hmac-secret", "hmac-secret-mc"]); + let info_no_mc = info_with_extensions(&["hmac-secret"]); + + let with = Ctap2MakeCredentialRequest::from_webauthn_request( + &mc_request_with_prf(Some(PRFValue::default())), + &info_mc, + ) + .unwrap(); + assert!(with.needs_shared_secret(&info_mc)); + assert!(!with.needs_shared_secret(&info_no_mc)); + + let without = + Ctap2MakeCredentialRequest::from_webauthn_request(&mc_request_with_prf(None), &info_mc) + .unwrap(); + assert!(!without.needs_shared_secret(&info_mc)); + } + + #[test] + fn calculate_hmac_secret_mc_populates_wire_field_and_clears_buffer() { + use crate::proto::ctap2::Ctap2UserVerificationOperation; + use cosey::{Bytes, PublicKey}; + + let info = info_with_extensions(&["hmac-secret", "hmac-secret-mc"]); + let req = mc_request_with_prf(Some(PRFValue { + first: [9u8; 32], + second: None, + })); + let mut ctap = Ctap2MakeCredentialRequest::from_webauthn_request(&req, &info).unwrap(); + + let pin_proto = Ctap2PinUvAuthProtocol::One; + let auth = AuthTokenData::new( + vec![0u8; 32], + pin_proto, + PublicKey::EcdhEsHkdf256Key(cosey::EcdhEsHkdf256PublicKey { + x: Bytes::from_slice(&[1u8; 32]).unwrap(), + y: Bytes::from_slice(&[2u8; 32]).unwrap(), + }), + Ctap2UserVerificationOperation::OnlyForSharedSecret, + ); + + let ext = ctap.extensions.as_mut().unwrap(); + ext.calculate_hmac_secret_mc(&auth).unwrap(); + assert!(ext.prf_input.is_none()); + let mc_in = ext.hmac_secret_mc.as_ref().expect("hmac_secret_mc set"); + assert_eq!(mc_in.pin_auth_proto, Some(pin_proto as u32)); + assert!(!mc_in.salt_enc.is_empty()); + assert!(!mc_in.salt_auth.is_empty()); + + // Wire round-trip: both keys must appear in the extensions CBOR map. + let bytes = crate::proto::ctap2::cbor::to_vec(&ext).unwrap(); + let parsed: std::collections::BTreeMap = + crate::proto::ctap2::cbor::from_slice(&bytes).unwrap(); + assert_eq!( + parsed.get("hmac-secret"), + Some(&serde_cbor_2::Value::Bool(true)) + ); + assert!(parsed.contains_key("hmac-secret-mc")); + } + + #[test] + fn calculate_hmac_secret_mc_pin_protocol_two() { + use crate::proto::ctap2::Ctap2UserVerificationOperation; + use cosey::{Bytes, PublicKey}; + + let info = info_with_extensions(&["hmac-secret", "hmac-secret-mc"]); + let mut ctap = Ctap2MakeCredentialRequest::from_webauthn_request( + &mc_request_with_prf(Some(PRFValue { + first: [0xAB; 32], + second: Some([0xCD; 32]), + })), + &info, + ) + .unwrap(); + // Protocol 2 shared secret is 64 bytes: HMAC key || AES key. + let auth = AuthTokenData::new( + vec![0u8; 64], + Ctap2PinUvAuthProtocol::Two, + PublicKey::EcdhEsHkdf256Key(cosey::EcdhEsHkdf256PublicKey { + x: Bytes::from_slice(&[1u8; 32]).unwrap(), + y: Bytes::from_slice(&[2u8; 32]).unwrap(), + }), + Ctap2UserVerificationOperation::OnlyForSharedSecret, + ); + let ext = ctap.extensions.as_mut().unwrap(); + ext.calculate_hmac_secret_mc(&auth).unwrap(); + let mc_in = ext.hmac_secret_mc.as_ref().unwrap(); + assert_eq!( + mc_in.pin_auth_proto, + Some(Ctap2PinUvAuthProtocol::Two as u32) + ); + // 16-byte IV || AES-256-CBC(64 bytes of salts). + assert_eq!(mc_in.salt_enc.len(), 16 + 64); + } + + #[test] + fn from_signed_extensions_decrypts_results_with_auth_data() { + use crate::proto::ctap2::Ctap2UserVerificationOperation; + use cosey::{Bytes, PublicKey}; + + // Round-trip a known PRF input through encrypt(client) → decrypt(client), + // simulating the authenticator returning encrypt(shared_secret, hmac_outputs). + let prf_value = PRFValue { + first: [1u8; 32], + second: Some([2u8; 32]), + }; + let pin_proto = Ctap2PinUvAuthProtocol::One; + let uv_proto = pin_proto.create_protocol_object(); + let shared_secret = vec![3u8; 32]; + let auth_data = AuthTokenData::new( + shared_secret.clone(), + pin_proto, + PublicKey::EcdhEsHkdf256Key(cosey::EcdhEsHkdf256PublicKey { + x: Bytes::from_slice(&[1u8; 32]).unwrap(), + y: Bytes::from_slice(&[2u8; 32]).unwrap(), + }), + Ctap2UserVerificationOperation::OnlyForSharedSecret, + ); + + // Fake authenticator output: any 64 bytes encrypted with the shared secret. + let fake_outputs = vec![0x42u8; 64]; + let encrypted = uv_proto.encrypt(&shared_secret, &fake_outputs).unwrap(); + let signed = Ctap2MakeCredentialsResponseExtensions { + hmac_secret: Some(true), + hmac_secret_mc: Some(Ctap2HMACGetSecretOutput { + encrypted_output: encrypted, + }), + ..Default::default() + }; + let req = mc_request_with_prf(Some(prf_value)); + + let out = MakeCredentialsResponseUnsignedExtensions::from_signed_extensions( + &Some(signed), + &req, + None, + Some(&auth_data), + ); + let prf = out.prf.expect("prf present"); + assert_eq!(prf.enabled, Some(true)); + let results = prf.results.expect("results populated"); + assert_eq!(results.first, [0x42; 32]); + assert_eq!(results.second, Some([0x42; 32])); + } + + #[test] + fn response_extensions_decode_hmac_secret_mc_key() { + use std::collections::BTreeMap; + let mut map: BTreeMap<&str, serde_cbor_2::Value> = BTreeMap::new(); + map.insert("hmac-secret", serde_cbor_2::Value::Bool(true)); + map.insert("hmac-secret-mc", serde_cbor_2::Value::Bytes(vec![0xAA; 32])); + let bytes = crate::proto::ctap2::cbor::to_vec(&map).unwrap(); + let parsed: Ctap2MakeCredentialsResponseExtensions = + crate::proto::ctap2::cbor::from_slice(&bytes).unwrap(); + assert_eq!(parsed.hmac_secret, Some(true)); + assert!(parsed.hmac_secret_mc.is_some()); + } +} diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index 5973d98..d53490e 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -123,6 +123,13 @@ async fn make_credential_fido2( ) .await?; + // Encrypt hmac-secret-mc before PresenceRequired (mirrors GA below). + if let Some(auth_data) = channel.get_auth_data() { + if let Some(ext) = ctap2_request.extensions.as_mut() { + ext.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 @@ -143,7 +150,8 @@ async fn make_credential_fido2( 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), channel.get_auth_data()); Ok(make_cred) }