diff --git a/libwebauthn/examples/webauthn_prf_hid.rs b/libwebauthn/examples/webauthn_prf_hid.rs index 3e50fd8..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 }), + prf: Some(MakeCredentialPrfInput { 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/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index bc129de..f6edf83 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -28,11 +28,15 @@ 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], - #[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 5ba789b..2bf1656 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}; @@ -68,6 +67,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 +79,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, }); } } @@ -278,19 +296,20 @@ impl WebAuthnIDL for MakeCredentialRequest { type InnerModel = PublicKeyCredentialCreationOptionsJSON; } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, 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, + /// PRF eval values for hmac-secret-mc (CTAP 2.2). + /// At MC time, only a single eval value is supported (no eval_by_credential). + #[serde(default)] + 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)] @@ -605,17 +624,40 @@ mod tests { let req_json = json_field_add( REQUEST_BASE_JSON, "extensions", - r#"{"prf": {"eval": {"first": "second"}}}"#, + r#"{"prf": {}}"#, ); let req: MakeCredentialRequest = 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(); diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index 078e9ab..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; @@ -319,33 +320,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,23 +328,6 @@ impl Ctap2GetAssertionRequestExtensions { } } -#[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 739dc9f..a616b15 100644 --- a/libwebauthn/src/proto/ctap2/model/make_credential.rs +++ b/libwebauthn/src/proto/ctap2/model/make_credential.rs @@ -7,19 +7,24 @@ 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, + proto::{ + extensions::prf::{prf_value_to_hmac_input, CalculatedHMACGetSecretInput}, + CtapError, + }, + transport::AuthTokenData, webauthn::Error, }; 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 +194,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 +209,40 @@ 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), + }); } } @@ -260,12 +305,32 @@ 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 { + requested_extensions + .prf + .as_ref() + .and_then(|prf| prf.eval.as_ref()) + .map(prf_value_to_hmac_input) + } 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 +366,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 +425,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 +455,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/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 b638864..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() }; @@ -160,7 +165,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) }