diff --git a/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml index 79366d74..79400312 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -175,6 +175,10 @@ path = "examples/features/webauthn_preflight_hid.rs" name = "webauthn_prf_hid" path = "examples/features/webauthn_prf_hid.rs" +[[example]] +name = "webauthn_prf_cable" +path = "examples/features/webauthn_prf_cable.rs" + [[example]] name = "webauthn_related_origins_hid" path = "examples/features/webauthn_related_origins_hid.rs" diff --git a/libwebauthn/examples/features/webauthn_prf_cable.rs b/libwebauthn/examples/features/webauthn_prf_cable.rs new file mode 100644 index 00000000..c375ff41 --- /dev/null +++ b/libwebauthn/examples/features/webauthn_prf_cable.rs @@ -0,0 +1,199 @@ +//! PRF extension over caBLE/hybrid, against a phone authenticator that +//! advertises the `prf` extension in getInfo. +//! +//! cargo run --example webauthn_prf_cable -- create +//! cargo run --example webauthn_prf_cable -- get [credential-id] +//! +//! `create` registers a discoverable credential with PRF enabled and an eval +//! at creation, then prints the credential ID. `get` asserts with PRF eval +//! salts. When a credential ID is given, it is added to the allow list with an +//! evalByCredential entry. + +use std::collections::HashMap; +use std::error::Error; +use std::time::Duration; + +use libwebauthn::ops::webauthn::{ + GetAssertionRequest, GetAssertionRequestExtensions, JsonFormat, MakeCredentialPrfInput, + MakeCredentialRequest, MakeCredentialsRequestExtensions, PrfInput, PrfInputValue, + ResidentKeyRequirement, UserVerificationRequirement, WebAuthnIDLResponse as _, +}; +use libwebauthn::proto::ctap2::{ + Ctap2CredentialType, Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity, + Ctap2PublicKeyCredentialType, Ctap2PublicKeyCredentialUserEntity, +}; +use libwebauthn::transport::cable::channel::CableChannel; +use libwebauthn::transport::cable::is_available; +use libwebauthn::transport::cable::qr_code_device::{ + CableQrCodeDevice, CableTransports, QrCodeOperationHint, +}; +use libwebauthn::transport::{Channel as _, ChannelSettings, Device}; +use libwebauthn::webauthn::WebAuthn; +use qrcode::render::unicode; +use qrcode::QrCode; +use serde_bytes::ByteBuf; + +#[path = "../common/mod.rs"] +mod common; + +const TIMEOUT: Duration = Duration::from_secs(120); +const RP_ID: &str = "example.org"; +const ORIGIN: &str = "https://example.org"; + +// Deterministic PRF inputs, so results can be cross-checked against another +// client holding the same credential. +const CREATE_EVAL_FIRST: &[u8] = b"example-prf-create-first"; +const CREATE_EVAL_SECOND: &[u8] = b"example-prf-create-second"; +const GET_EVAL_FIRST: &[u8] = b"example-prf-get-first"; +const GET_EVAL_SECOND: &[u8] = b"example-prf-get-second"; +const BY_CRED_FIRST: &[u8] = b"example-prf-bycred-first"; +const BY_CRED_SECOND: &[u8] = b"example-prf-bycred-second"; + +#[tokio::main] +pub async fn main() -> Result<(), Box> { + common::setup_logging(); + + let args: Vec = std::env::args().collect(); + let mode = args.get(1).map(String::as_str); + + if !is_available().await { + eprintln!("No Bluetooth adapter found. Cable/Hybrid transport is unavailable."); + return Err("Cable transport not available".into()); + } + + match mode { + Some("create") => create().await, + Some("get") => get(args.get(2).map(String::as_str)).await, + _ => { + eprintln!("Usage: webauthn_prf_cable "); + Err("missing or unknown subcommand".into()) + } + } +} + +async fn connect( + hint: QrCodeOperationHint, +) -> Result<(CableQrCodeDevice, CableChannel), Box> { + let mut device: CableQrCodeDevice = + CableQrCodeDevice::new_transient(hint, CableTransports::CloudAssistedOrLocal)?; + + println!("Created QR code, awaiting advertisement."); + let qr_code = QrCode::new(device.qr_code.to_string()).unwrap(); + let image = qr_code + .render::() + .dark_color(unicode::Dense1x2::Light) + .light_color(unicode::Dense1x2::Dark) + .build(); + println!("{}", image); + + let channel = device.channel(ChannelSettings::default()).await?; + println!("Channel established {:?}", channel); + + let state_recv = channel.get_ux_update_receiver(); + tokio::spawn(common::handle_cable_updates(state_recv)); + Ok((device, channel)) +} + +async fn create() -> Result<(), Box> { + let (_device, mut channel) = connect(QrCodeOperationHint::MakeCredential).await?; + + let extensions = MakeCredentialsRequestExtensions { + prf: Some(MakeCredentialPrfInput { + eval: Some(PrfInputValue { + first: CREATE_EVAL_FIRST.to_vec(), + second: Some(CREATE_EVAL_SECOND.to_vec()), + }), + }), + ..Default::default() + }; + + let request = MakeCredentialRequest { + challenge: vec![0x11; 32], + origin: ORIGIN.to_owned(), + top_origin: None, + relying_party: Ctap2PublicKeyCredentialRpEntity::new(RP_ID, "Example Relying Party"), + user: Ctap2PublicKeyCredentialUserEntity::new(&[0x42; 16], "alice", "Alice"), + resident_key: Some(ResidentKeyRequirement::Required), + user_verification: UserVerificationRequirement::Preferred, + algorithms: vec![Ctap2CredentialType::default()], + exclude: None, + extensions: Some(extensions), + timeout: TIMEOUT, + }; + + let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); + + let response_json = response + .to_json_string(&request, JsonFormat::Prettified) + .expect("Failed to serialize MakeCredential response"); + println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); + + let credential: Ctap2PublicKeyCredentialDescriptor = + (&response.authenticator_data).try_into().unwrap(); + println!( + "\nCredential ID (base64url): {}", + base64_url::encode(&credential.id) + ); + println!("Next: run the `get` leg, e.g."); + println!( + "cargo run --example webauthn_prf_cable -- get {}", + base64_url::encode(&credential.id) + ); + Ok(()) +} + +async fn get(credential_id: Option<&str>) -> Result<(), Box> { + let (_device, mut channel) = connect(QrCodeOperationHint::GetAssertionRequest).await?; + + let mut eval_by_credential = HashMap::new(); + let allow = match credential_id { + Some(encoded) => { + eval_by_credential.insert( + encoded.to_owned(), + PrfInputValue { + first: BY_CRED_FIRST.to_vec(), + second: Some(BY_CRED_SECOND.to_vec()), + }, + ); + vec![Ctap2PublicKeyCredentialDescriptor { + r#type: Ctap2PublicKeyCredentialType::PublicKey, + id: ByteBuf::from( + base64_url::decode(encoded) + .map_err(|e| format!("invalid credential id: {e}"))?, + ), + transports: None, + }] + } + None => vec![], + }; + + let request = GetAssertionRequest { + relying_party_id: RP_ID.to_owned(), + challenge: vec![0x22; 32], + origin: ORIGIN.to_owned(), + top_origin: None, + allow, + user_verification: UserVerificationRequirement::Preferred, + extensions: Some(GetAssertionRequestExtensions { + prf: Some(PrfInput { + eval: Some(PrfInputValue { + first: GET_EVAL_FIRST.to_vec(), + second: Some(GET_EVAL_SECOND.to_vec()), + }), + eval_by_credential, + }), + ..Default::default() + }), + timeout: TIMEOUT, + }; + + let response = retry_user_errors!(channel.webauthn_get_assertion(&request)).unwrap(); + + for (num, assertion) in response.assertions.iter().enumerate() { + let assertion_json = assertion + .to_json_string(&request, JsonFormat::Prettified) + .expect("Failed to serialize GetAssertion response"); + println!("Assertion {num} (JSON):\n{assertion_json}"); + } + Ok(()) +} diff --git a/libwebauthn/src/ops/u2f.rs b/libwebauthn/src/ops/u2f.rs index 62e51cae..b4d2e552 100644 --- a/libwebauthn/src/ops/u2f.rs +++ b/libwebauthn/src/ops/u2f.rs @@ -168,6 +168,7 @@ impl UpgradableResponse for Regis attestation_statement, enterprise_attestation: None, large_blob_key: None, + unsigned_extension_outputs: 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 f368a813..a75db158 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::time::Duration; use async_trait::async_trait; @@ -25,10 +26,11 @@ use crate::{ proto::{ ctap1::{Ctap1RegisteredKey, Ctap1Version}, ctap2::{ - cbor, cose, Ctap2AttestationStatement, Ctap2COSEAlgorithmIdentifier, - Ctap2CredentialType, Ctap2GetInfoResponse, Ctap2MakeCredentialsResponseExtensions, - Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity, - Ctap2PublicKeyCredentialUserEntity, + cbor, cbor::Value, cose, parse_unsigned_prf, Ctap2AttestationStatement, + Ctap2COSEAlgorithmIdentifier, Ctap2CredentialType, Ctap2GetInfoResponse, + Ctap2MakeCredentialsResponseExtensions, Ctap2PublicKeyCredentialDescriptor, + Ctap2PublicKeyCredentialRpEntity, Ctap2PublicKeyCredentialUserEntity, + UnsignedPrfOutput, }, }, transport::AuthTokenData, @@ -203,35 +205,44 @@ impl MakeCredentialsResponseUnsignedExtensions { pub fn from_signed_extensions( signed_extensions: &Option, + unsigned_outputs: Option<&BTreeMap>, 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 { - 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| PrfOutputValue { - first: decrypted.output1, - second: decrypted.output2, - }); - prf = Some(MakeCredentialPrfOutput { - enabled: signed_extensions.hmac_secret, - results, + // Native `prf` outputs arrive in unsignedExtensionOutputs, not authData. + let unsigned_prf = unsigned_outputs.and_then(parse_unsigned_prf); + if let Some(incoming_ext) = &request.extensions { + if incoming_ext.hmac_create_secret.is_some() { + hmac_create_secret = signed_extensions.as_ref().and_then(|s| s.hmac_secret); + } + if incoming_ext.prf.is_some() && (signed_extensions.is_some() || unsigned_prf.is_some()) + { + let decrypted_results = signed_extensions + .as_ref() + .and_then(|s| s.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| PrfOutputValue { + first: decrypted.output1, + second: decrypted.output2, }); - } + let UnsignedPrfOutput { + enabled: unsigned_enabled, + results: unsigned_results, + } = unsigned_prf.unwrap_or_default(); + prf = Some(MakeCredentialPrfOutput { + enabled: signed_extensions + .as_ref() + .and_then(|s| s.hmac_secret) + .or(unsigned_enabled), + results: decrypted_results.or(unsigned_results), + }); } } @@ -1448,6 +1459,32 @@ mod tests { ); } + #[test] + fn prf_output_serialized_into_client_extension_results() { + let mut response = create_test_response(); + response.unsigned_extensions_output = MakeCredentialsResponseUnsignedExtensions { + prf: Some(MakeCredentialPrfOutput { + enabled: Some(true), + results: Some(PrfOutputValue { + first: [0xAB; 32], + second: Some([0xCD; 32]), + }), + }), + ..Default::default() + }; + + let results = serde_json::to_value(response.build_client_extension_results()).unwrap(); + assert_eq!(results["prf"]["enabled"], serde_json::json!(true)); + assert_eq!( + results["prf"]["results"]["first"], + serde_json::json!(base64_url::encode(&[0xAB; 32])) + ); + assert_eq!( + results["prf"]["results"]["second"], + serde_json::json!(base64_url::encode(&[0xCD; 32])) + ); + } + #[test] fn test_response_to_idl_model() { let response = create_test_response(); diff --git a/libwebauthn/src/proto/ctap2/mod.rs b/libwebauthn/src/proto/ctap2/mod.rs index ed632f7d..b2600f11 100644 --- a/libwebauthn/src/proto/ctap2/mod.rs +++ b/libwebauthn/src/proto/ctap2/mod.rs @@ -20,6 +20,7 @@ pub use model::{ Ctap2UserVerificationOperation, FidoU2fAttestationStmt, }; +pub(crate) use model::{parse_unsigned_prf, UnsignedPrfOutput}; pub use model::{ Ctap2AuthenticatorConfigCommand, Ctap2AuthenticatorConfigParams, Ctap2AuthenticatorConfigRequest, @@ -34,10 +35,12 @@ pub use model::{ }; pub use model::{ Ctap2GetAssertionRequest, Ctap2GetAssertionResponse, Ctap2GetAssertionResponseExtensions, + Ctap2PrfGetAssertionInput, Ctap2PrfSalts, }; pub use model::{Ctap2LargeBlobsRequest, Ctap2LargeBlobsResponse}; pub use model::{ - Ctap2MakeCredentialRequest, Ctap2MakeCredentialResponse, Ctap2MakeCredentialsResponseExtensions, + Ctap2MakeCredentialRequest, Ctap2MakeCredentialResponse, + Ctap2MakeCredentialsResponseExtensions, Ctap2PrfMakeCredentialInput, }; pub mod preflight; pub use protocol::Ctap2; diff --git a/libwebauthn/src/proto/ctap2/model.rs b/libwebauthn/src/proto/ctap2/model.rs index 71ddb541..d7b8e4b0 100644 --- a/libwebauthn/src/proto/ctap2/model.rs +++ b/libwebauthn/src/proto/ctap2/model.rs @@ -32,12 +32,14 @@ pub use client_pin::{ mod make_credential; pub use make_credential::{ Ctap2MakeCredentialOptions, Ctap2MakeCredentialRequest, Ctap2MakeCredentialResponse, - Ctap2MakeCredentialsResponseExtensions, + Ctap2MakeCredentialsResponseExtensions, Ctap2PrfMakeCredentialInput, }; mod get_assertion; +pub(crate) use get_assertion::{parse_unsigned_prf, UnsignedPrfOutput}; pub use get_assertion::{ Ctap2AttestationStatement, Ctap2GetAssertionOptions, Ctap2GetAssertionRequest, - Ctap2GetAssertionResponse, Ctap2GetAssertionResponseExtensions, FidoU2fAttestationStmt, + Ctap2GetAssertionResponse, Ctap2GetAssertionResponseExtensions, Ctap2PrfGetAssertionInput, + Ctap2PrfSalts, FidoU2fAttestationStmt, }; mod credential_management; pub use credential_management::{ diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index 27db1641..e3fd735d 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -165,7 +165,16 @@ impl Ctap2GetAssertionRequest { } } - Ok(Ctap2GetAssertionRequest::from(req)) + let mut ctap2_request = Ctap2GetAssertionRequest::from(req); + if info.supports_extension("prf") { + let Ctap2GetAssertionRequest { + allow, extensions, .. + } = &mut ctap2_request; + if let Some(ext) = extensions.as_mut() { + ext.convert_prf_to_native(allow)?; + } + } + Ok(ctap2_request) } } @@ -190,6 +199,11 @@ impl From for Ctap2GetAssertionRequest { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Ctap2GetAssertionRequestExtensions { + // Field order is CTAP2 canonical CBOR map order: shortest key first, then bytewise. + /// Native `prf` extension, used by phone/platform authenticators that advertise + /// `prf` in getInfo instead of `hmac-secret` (e.g. over hybrid). + #[serde(skip_serializing_if = "Option::is_none")] + pub prf: Option, #[serde(skip_serializing_if = "std::ops::Not::not")] pub cred_blob: bool, // Thanks, FIDO-spec for this consistent naming scheme... @@ -221,6 +235,7 @@ impl From for Ctap2GetAssertionRequestExtensions | Some(GetAssertionLargeBlobExtension::Delete) ); 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), @@ -232,12 +247,57 @@ impl From for Ctap2GetAssertionRequestExtensions impl Ctap2GetAssertionRequestExtensions { pub fn skip_serializing(&self) -> bool { - !self.cred_blob + self.prf.is_none() + && !self.cred_blob && self.hmac_secret.is_none() && self.large_blob_key.is_none() && self.hmac_or_prf.is_none() } + /// Rewrites the buffered PRF input as a native `prf` extension, bypassing the + /// hmac-secret shared-secret envelope (the transport tunnel provides confidentiality). + fn convert_prf_to_native( + &mut self, + allow_list: &[Ctap2PublicKeyCredentialDescriptor], + ) -> Result<(), Error> { + let Some(GetAssertionHmacOrPrfInput::Prf(prf_input)) = &self.hmac_or_prf else { + return Ok(()); + }; + + // Same WebAuthn L3 §10.1.4 client checks as prf_to_hmac_input below. + if !prf_input.eval_by_credential.is_empty() && allow_list.is_empty() { + return Err(Error::Platform(PlatformError::NotSupported)); + } + + let mut eval_by_credential = BTreeMap::new(); + for (enc_cred_id, value) in &prf_input.eval_by_credential { + if enc_cred_id.is_empty() { + return Err(Error::Platform(PlatformError::SyntaxError)); + } + let cred_id = base64_url::decode(enc_cred_id) + .map_err(|_| Error::Platform(PlatformError::SyntaxError))?; + // Entries not matching the allow list are skipped, mirroring prf_to_hmac_input. + if allow_list.iter().any(|cred| cred.id == cred_id) { + eval_by_credential.insert(ByteBuf::from(cred_id), Ctap2PrfSalts::from(value)); + } + } + + let eval = prf_input.eval.as_ref().map(Ctap2PrfSalts::from); + // No usable input: send nothing, like the hmac path (L3 §10.1.4 step 5). + if eval.is_some() || !eval_by_credential.is_empty() { + self.prf = Some(Ctap2PrfGetAssertionInput { + eval, + eval_by_credential: if eval_by_credential.is_empty() { + None + } else { + Some(eval_by_credential) + }, + }); + } + self.hmac_or_prf = None; + Ok(()) + } + pub fn calculate_hmac( &mut self, allow_list: &[Ctap2PublicKeyCredentialDescriptor], @@ -336,6 +396,104 @@ impl Ctap2GetAssertionRequestExtensions { } } +/// Hashed PRF salts (WebAuthn L3 §10.1.4) sent in the clear within the native +/// `prf` extension; no hmac-secret encryption envelope is applied. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Ctap2PrfSalts { + #[serde(with = "serde_bytes")] + pub first: [u8; 32], + #[serde(skip_serializing_if = "Option::is_none", with = "serde_bytes")] + pub second: Option<[u8; 32]>, +} + +impl From<&PrfInputValue> for Ctap2PrfSalts { + fn from(value: &PrfInputValue) -> Self { + let hashed = value.to_hmac_secret_input(); + Self { + first: hashed.salt1, + second: hashed.salt2, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Ctap2PrfGetAssertionInput { + #[serde(skip_serializing_if = "Option::is_none")] + pub eval: Option, + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_canonical_byte_map" + )] + pub eval_by_credential: Option>, +} + +/// CTAP2 canonical CBOR map order for byte-string keys: shorter keys first, +/// then bytewise. `BTreeMap` alone gives plain lexicographic order. +fn serialize_canonical_byte_map( + map: &Option>, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + use serde::ser::SerializeMap; + let Some(map) = map else { + // Unreachable behind skip_serializing_if, but stay total. + return serializer.serialize_none(); + }; + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_by(|(a, _), (b, _)| a.len().cmp(&b.len()).then_with(|| a.cmp(b))); + let mut ser = serializer.serialize_map(Some(entries.len()))?; + for (key, value) in entries { + ser.serialize_entry(key, value)?; + } + ser.end() +} + +/// `prf` entry of a response's unsignedExtensionOutputs map. +#[derive(Debug, Default)] +pub(crate) struct UnsignedPrfOutput { + pub enabled: Option, + pub results: Option, +} + +pub(crate) fn parse_unsigned_prf(outputs: &BTreeMap) -> Option { + let bytes32 = |value: Option<&Value>| -> Option<[u8; 32]> { + match value { + Some(Value::Bytes(bytes)) => bytes.as_slice().try_into().ok(), + _ => None, + } + }; + let Some(Value::Map(prf)) = outputs.get(&Value::Text("prf".to_string())) else { + return None; + }; + let enabled = match prf.get(&Value::Text("enabled".to_string())) { + Some(Value::Bool(enabled)) => Some(*enabled), + _ => None, + }; + let results = match prf.get(&Value::Text("results".to_string())) { + Some(Value::Map(results)) => { + let first = bytes32(results.get(&Value::Text("first".to_string()))); + let second = results.get(&Value::Text("second".to_string())); + // Any malformed entry invalidates the whole results map. + match (first, second) { + (Some(first), None) => Some(PrfOutputValue { + first, + second: None, + }), + (Some(first), Some(second)) => bytes32(Some(second)).map(|second| PrfOutputValue { + first, + second: Some(second), + }), + (None, _) => None, + } + } + _ => None, + }; + Some(UnsignedPrfOutput { enabled, results }) +} + #[derive(Debug, Clone, SerializeIndexed)] pub struct CalculatedHMACGetSecretInput { // keyAgreement(0x01): public key of platform key-agreement key. @@ -476,12 +634,31 @@ impl Ctap2GetAssertionResponse { // authenticator-data extensions, so surface them even when no signed // extensions are present. CTAP 2.2: an empty map equals an omitted field. if let Some(map) = &self.unsigned_extension_outputs { - let object = map_to_json_object(map); + let mut object = map_to_json_object(map); + // The typed prf field below is the canonical surface for this entry. + object.remove("prf"); if !object.is_empty() { unsigned_extensions_output .get_or_insert_with(Default::default) .unsigned_extension_outputs = object; } + + // Native `prf` path: results arrive here in plaintext, not inside the + // signed extensions under an hmac-secret envelope. + let prf_requested = request + .extensions + .as_ref() + .is_some_and(|ext| ext.prf.is_some()); + if prf_requested { + if let Some(results) = parse_unsigned_prf(map).and_then(|prf| prf.results) { + let unsigned = unsigned_extensions_output.get_or_insert_with(Default::default); + if unsigned.prf.is_none() { + unsigned.prf = Some(GetAssertionPrfOutput { + results: Some(results), + }); + } + } + } } // CTAP2 6.2.2: authenticators may omit credential ID when the allow list has one entry. @@ -701,6 +878,479 @@ mod tests { assert!(large_blob.blob.is_none()); } + fn info_with_extensions(exts: &[&str]) -> Ctap2GetInfoResponse { + Ctap2GetInfoResponse { + extensions: Some(exts.iter().map(|s| s.to_string()).collect()), + ..Default::default() + } + } + + fn hashed_salt(input: &[u8]) -> [u8; 32] { + PrfInputValue { + first: input.to_vec(), + second: None, + } + .to_hmac_secret_input() + .salt1 + } + + fn prf_request( + allow: Vec, + eval: Option, + eval_by_credential: HashMap, + ) -> GetAssertionRequest { + let mut request = make_request(allow); + request.extensions = Some(GetAssertionRequestExtensions { + cred_blob: false, + prf: Some(crate::ops::webauthn::PrfInput { + eval, + eval_by_credential, + }), + large_blob: None, + appid: None, + }); + request + } + + #[test] + fn native_prf_used_when_getinfo_advertises_prf() { + let cred = make_credential(b"cred-1"); + let mut by_cred = HashMap::new(); + by_cred.insert( + base64_url::encode(b"cred-1"), + PrfInputValue { + first: b"by-cred-first".to_vec(), + second: None, + }, + ); + let request = prf_request( + vec![cred], + Some(PrfInputValue { + first: b"eval-first".to_vec(), + second: Some(b"eval-second".to_vec()), + }), + by_cred, + ); + let info = info_with_extensions(&["prf"]); + + let ctap2 = Ctap2GetAssertionRequest::from_webauthn_request(&request, &info).unwrap(); + let ext = ctap2.extensions.as_ref().unwrap(); + assert!(ext.hmac_or_prf.is_none(), "buffered input must be consumed"); + assert!(!ctap2.needs_shared_secret(&info)); + let ext = ctap2.extensions.as_ref().unwrap(); + assert!(!ext.skip_serializing()); + let prf = ext.prf.as_ref().expect("native prf set"); + let eval = prf.eval.as_ref().expect("eval present"); + assert_eq!(eval.first, hashed_salt(b"eval-first")); + assert_eq!(eval.second, Some(hashed_salt(b"eval-second"))); + let by_cred = prf.eval_by_credential.as_ref().expect("evalByCredential"); + let entry = by_cred + .get(&ByteBuf::from(b"cred-1".to_vec())) + .expect("keyed by raw credential id bytes"); + assert_eq!(entry.first, hashed_salt(b"by-cred-first")); + assert_eq!(entry.second, None); + + // Wire format: {"prf": {"eval": {...}, "evalByCredential": {bytes: {...}}}} + let bytes = crate::proto::ctap2::cbor::to_vec(&ext).unwrap(); + let parsed: Value = crate::proto::ctap2::cbor::from_slice(&bytes).unwrap(); + let Value::Map(map) = parsed else { + panic!("extensions must serialize to a map") + }; + assert_eq!(map.len(), 1, "only the prf entry must be serialized"); + let Some(Value::Map(prf_map)) = map.get(&Value::Text("prf".to_string())) else { + panic!("prf entry missing") + }; + let Some(Value::Map(eval_map)) = prf_map.get(&Value::Text("eval".to_string())) else { + panic!("eval entry missing") + }; + // Salts must encode as 32-byte CBOR byte strings. + for key in ["first", "second"] { + match eval_map.get(&Value::Text(key.to_string())) { + Some(Value::Bytes(bytes)) => assert_eq!(bytes.len(), 32, "{key}"), + other => panic!("{key} must be a byte string, got {other:?}"), + } + } + let Some(Value::Map(by_cred_map)) = + prf_map.get(&Value::Text("evalByCredential".to_string())) + else { + panic!("evalByCredential entry missing") + }; + assert!(by_cred_map.contains_key(&Value::Bytes(b"cred-1".to_vec()))); + } + + #[test] + fn native_prf_preferred_over_hmac_secret_when_both_advertised() { + let request = prf_request( + vec![], + Some(PrfInputValue { + first: b"x".to_vec(), + second: None, + }), + HashMap::new(), + ); + let info = info_with_extensions(&["hmac-secret", "prf"]); + + let ctap2 = Ctap2GetAssertionRequest::from_webauthn_request(&request, &info).unwrap(); + let ext = ctap2.extensions.as_ref().unwrap(); + assert!(ext.prf.is_some()); + assert!(ext.hmac_or_prf.is_none()); + assert!(!ctap2.needs_shared_secret(&info)); + } + + #[test] + fn native_prf_not_used_without_getinfo_support() { + let request = prf_request( + vec![], + Some(PrfInputValue { + first: b"eval-first".to_vec(), + second: None, + }), + HashMap::new(), + ); + let info = info_with_extensions(&["hmac-secret"]); + + let ctap2 = Ctap2GetAssertionRequest::from_webauthn_request(&request, &info).unwrap(); + let ext = ctap2.extensions.as_ref().unwrap(); + assert!(ext.prf.is_none()); + assert!( + ext.hmac_or_prf.is_some(), + "hmac-secret path keeps the input" + ); + } + + #[test] + fn native_prf_skips_entries_not_in_allow_list() { + let cred = make_credential(b"cred-1"); + let mut by_cred = HashMap::new(); + by_cred.insert( + base64_url::encode(b"unknown-cred"), + PrfInputValue { + first: b"x".to_vec(), + second: None, + }, + ); + let request = prf_request(vec![cred], None, by_cred); + let info = info_with_extensions(&["prf"]); + + let ctap2 = Ctap2GetAssertionRequest::from_webauthn_request(&request, &info).unwrap(); + let ext = ctap2.extensions.as_ref().unwrap(); + // No usable input: nothing is sent, like the hmac path. + assert!(ext.prf.is_none()); + assert!(ext.hmac_or_prf.is_none()); + assert!(ext.skip_serializing()); + } + + #[test] + fn native_prf_request_serializes_extensions_at_0x04() { + // Regression guard for the original bug: the prf input used to vanish + // from the serialized request entirely. + let request = prf_request( + vec![], + Some(PrfInputValue { + first: b"input".to_vec(), + second: None, + }), + HashMap::new(), + ); + let info = info_with_extensions(&["prf"]); + let ctap2 = Ctap2GetAssertionRequest::from_webauthn_request(&request, &info).unwrap(); + + let bytes = crate::proto::ctap2::cbor::to_vec(&ctap2).unwrap(); + let parsed: BTreeMap = crate::proto::ctap2::cbor::from_slice(&bytes).unwrap(); + let Some(Value::Map(extensions)) = parsed.get(&0x04) else { + panic!("extensions (0x04) missing from the wire") + }; + assert!(extensions.contains_key(&Value::Text("prf".to_string()))); + } + + #[test] + fn native_prf_composes_with_large_blob_write() { + let mut request = prf_request( + vec![make_credential(b"cred-1")], + Some(PrfInputValue { + first: b"input".to_vec(), + second: None, + }), + HashMap::new(), + ); + request.extensions.as_mut().unwrap().large_blob = + Some(GetAssertionLargeBlobExtension::Write(b"blob".to_vec())); + let info = Ctap2GetInfoResponse { + extensions: Some(vec!["prf".to_string()]), + options: Some( + [("largeBlobs".to_string(), true), ("uv".to_string(), true)] + .into_iter() + .collect(), + ), + ..Default::default() + }; + + let ctap2 = Ctap2GetAssertionRequest::from_webauthn_request(&request, &info).unwrap(); + let ext = ctap2.extensions.as_ref().unwrap(); + assert!(ext.prf.is_some()); + assert!(ext.hmac_or_prf.is_none()); + assert_eq!(ext.large_blob_key, Some(true)); + // The pinUvAuthToken is still negotiated for the lbw permission. + assert!(ctap2.needs_pin_uv_auth_token(&info)); + assert!(ctap2.needs_shared_secret(&info)); + } + + #[test] + fn native_prf_forwards_all_matching_eval_by_credential_entries() { + let mut by_cred = HashMap::new(); + for (id, salt) in [ + (&b"cred-1"[..], &b"salt-1"[..]), + (b"cred-2", b"salt-2"), + (b"unknown-cred", b"salt-3"), + ] { + by_cred.insert( + base64_url::encode(id), + PrfInputValue { + first: salt.to_vec(), + second: None, + }, + ); + } + let request = prf_request( + vec![make_credential(b"cred-1"), make_credential(b"cred-2")], + None, + by_cred, + ); + let info = info_with_extensions(&["prf"]); + + let ctap2 = Ctap2GetAssertionRequest::from_webauthn_request(&request, &info).unwrap(); + let prf = ctap2.extensions.as_ref().unwrap().prf.as_ref().unwrap(); + let by_cred = prf.eval_by_credential.as_ref().unwrap(); + assert_eq!(by_cred.len(), 2); + assert_eq!( + by_cred + .get(&ByteBuf::from(b"cred-1".to_vec())) + .unwrap() + .first, + hashed_salt(b"salt-1") + ); + assert_eq!( + by_cred + .get(&ByteBuf::from(b"cred-2".to_vec())) + .unwrap() + .first, + hashed_salt(b"salt-2") + ); + } + + #[test] + fn native_prf_invalid_eval_by_credential_keys_are_syntax_errors() { + let info = info_with_extensions(&["prf"]); + let cred = make_credential(b"cred-1"); + for bad_key in ["", "not base64url!"] { + let mut by_cred = HashMap::new(); + by_cred.insert( + bad_key.to_string(), + PrfInputValue { + first: b"x".to_vec(), + second: None, + }, + ); + let request = prf_request(vec![cred.clone()], None, by_cred); + let result = Ctap2GetAssertionRequest::from_webauthn_request(&request, &info); + assert!( + matches!(result, Err(Error::Platform(PlatformError::SyntaxError))), + "key {bad_key:?}" + ); + } + } + + #[test] + fn native_prf_eval_by_credential_without_allow_list_is_not_supported() { + let mut by_cred = HashMap::new(); + by_cred.insert( + base64_url::encode(b"cred-1"), + PrfInputValue { + first: b"x".to_vec(), + second: None, + }, + ); + let request = prf_request(vec![], None, by_cred); + let info = info_with_extensions(&["prf"]); + + let result = Ctap2GetAssertionRequest::from_webauthn_request(&request, &info); + assert!(matches!( + result, + Err(Error::Platform(PlatformError::NotSupported)) + )); + } + + #[test] + fn native_prf_eval_by_credential_serializes_in_canonical_key_order() { + // CTAP2 canonical CBOR: shorter keys first, then bytewise. Plain + // lexicographic order would put [1, 2, 3] before [2]. + let salts = Ctap2PrfSalts { + first: [0; 32], + second: None, + }; + let mut by_cred = BTreeMap::new(); + by_cred.insert(ByteBuf::from(vec![1u8, 2, 3]), salts.clone()); + by_cred.insert(ByteBuf::from(vec![2u8]), salts); + let input = Ctap2PrfGetAssertionInput { + eval: None, + eval_by_credential: Some(by_cred), + }; + let bytes = crate::proto::ctap2::cbor::to_vec(&input).unwrap(); + let pos_short = bytes + .windows(2) + .position(|w| w == [0x41, 0x02]) + .expect("key h'02' present"); + let pos_long = bytes + .windows(4) + .position(|w| w == [0x43, 0x01, 0x02, 0x03]) + .expect("key h'010203' present"); + assert!(pos_short < pos_long, "shorter key must serialize first"); + } + + fn unsigned_prf_outputs(first: &[u8], second: Option<&[u8]>) -> BTreeMap { + let mut results = BTreeMap::new(); + results.insert( + Value::Text("first".to_string()), + Value::Bytes(first.to_vec()), + ); + if let Some(second) = second { + results.insert( + Value::Text("second".to_string()), + Value::Bytes(second.to_vec()), + ); + } + let mut prf = BTreeMap::new(); + prf.insert(Value::Text("results".to_string()), Value::Map(results)); + let mut outputs = BTreeMap::new(); + outputs.insert(Value::Text("prf".to_string()), Value::Map(prf)); + outputs + } + + #[test] + fn assertion_output_populates_prf_from_unsigned_extension_outputs() { + let cred = make_credential(b"cred-1"); + let mut response = make_response(Some(cred.clone())); + // No signed extensions at all: ED flag unset, as phones respond. + response.unsigned_extension_outputs = + Some(unsigned_prf_outputs(&[0xAB; 32], Some(&[0xCD; 32]))); + + let request = prf_request( + vec![cred], + Some(PrfInputValue { + first: b"eval-first".to_vec(), + second: None, + }), + HashMap::new(), + ); + + let assertion = response.into_assertion_output(&request, None); + let unsigned = assertion + .unsigned_extensions_output + .expect("unsigned extensions present"); + // The raw passthrough map must not duplicate the typed prf entry. + assert!(unsigned.unsigned_extension_outputs.is_empty()); + let prf = unsigned.prf.expect("prf output present"); + let results = prf.results.expect("results present"); + assert_eq!(results.first, [0xAB; 32]); + assert_eq!(results.second, Some([0xCD; 32])); + } + + #[test] + fn assertion_output_ignores_unsigned_prf_when_not_requested() { + let cred = make_credential(b"cred-1"); + let mut response = make_response(Some(cred.clone())); + response.unsigned_extension_outputs = Some(unsigned_prf_outputs(&[0xAB; 32], None)); + + let request = make_request(vec![cred]); + let assertion = response.into_assertion_output(&request, None); + // Not requested: neither a typed nor a raw prf output is surfaced. + assert!(assertion.unsigned_extensions_output.is_none()); + } + + #[test] + fn parse_unsigned_prf_handles_enabled_and_malformed_entries() { + let mut prf = BTreeMap::new(); + prf.insert(Value::Text("enabled".to_string()), Value::Bool(true)); + let mut outputs = BTreeMap::new(); + outputs.insert(Value::Text("prf".to_string()), Value::Map(prf)); + let parsed = parse_unsigned_prf(&outputs).expect("prf entry"); + assert_eq!(parsed.enabled, Some(true)); + assert!(parsed.results.is_none()); + + // Non-map prf entry + let mut outputs = BTreeMap::new(); + outputs.insert(Value::Text("prf".to_string()), Value::Bool(true)); + assert!(parse_unsigned_prf(&outputs).is_none()); + + // No prf entry + assert!(parse_unsigned_prf(&BTreeMap::new()).is_none()); + + // Wrong-length results are dropped + let parsed = parse_unsigned_prf(&unsigned_prf_outputs(&[0xAB; 16], None)).unwrap(); + assert!(parsed.results.is_none()); + + // A malformed second invalidates the whole results map + let parsed = + parse_unsigned_prf(&unsigned_prf_outputs(&[0xAB; 32], Some(&[0xCD; 16]))).unwrap(); + assert!(parsed.results.is_none()); + + // Non-bool enabled is ignored + let mut prf = BTreeMap::new(); + prf.insert(Value::Text("enabled".to_string()), Value::Integer(1)); + let mut outputs = BTreeMap::new(); + outputs.insert(Value::Text("prf".to_string()), Value::Map(prf)); + let parsed = parse_unsigned_prf(&outputs).unwrap(); + assert!(parsed.enabled.is_none()); + } + + #[test] + fn surfaces_passthrough_prf_results_in_client_extension_results() { + use crate::ops::webauthn::idl::response::{JsonFormat, WebAuthnIDLResponse}; + + // End-to-end GPM shape: results only in unsignedExtensionOutputs (0x08), + // no signed extensions, ED flag unset. + let mut auth_data = vec![0u8; 37]; + auth_data[32] = AuthenticatorDataFlags::USER_PRESENT.bits(); + + let mut response: BTreeMap = BTreeMap::new(); + response.insert(0x02, Value::Bytes(auth_data)); + response.insert(0x03, Value::Bytes(vec![0xAAu8; 64])); + response.insert( + 0x08, + Value::Map(unsigned_prf_outputs(&[0xAB; 32], Some(&[0xCD; 32]))), + ); + + let bytes = crate::proto::ctap2::cbor::to_vec(&response).unwrap(); + let parsed: Ctap2GetAssertionResponse = + crate::proto::ctap2::cbor::from_slice(&bytes).unwrap(); + + let request = prf_request( + vec![make_credential(b"cred-1")], + Some(PrfInputValue { + first: b"input".to_vec(), + second: None, + }), + HashMap::new(), + ); + let assertion = parsed.into_assertion_output(&request, None); + let json_str = assertion + .to_json_string(&request, JsonFormat::default()) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + let prf = &json["clientExtensionResults"]["prf"]; + assert_eq!( + prf["results"]["first"], + serde_json::json!(base64_url::encode(&[0xAB; 32])) + ); + assert_eq!( + prf["results"]["second"], + serde_json::json!(base64_url::encode(&[0xCD; 32])) + ); + // Exactly one prf member: the raw passthrough must not emit a duplicate. + assert_eq!(json_str.matches("\"prf\"").count(), 1); + } + #[test] fn decodes_unsigned_extension_outputs_at_index_0x08() { // 0x08 is unsignedExtensionOutputs (a CBOR map), not enterprise attestation. diff --git a/libwebauthn/src/proto/ctap2/model/make_credential.rs b/libwebauthn/src/proto/ctap2/model/make_credential.rs index f2076d99..cea1dcc5 100644 --- a/libwebauthn/src/proto/ctap2/model/make_credential.rs +++ b/libwebauthn/src/proto/ctap2/model/make_credential.rs @@ -1,8 +1,9 @@ use super::{ - get_assertion::CalculatedHMACGetSecretInput, Ctap2AttestationStatement, - Ctap2AuthTokenPermissionRole, Ctap2CredentialType, Ctap2GetInfoResponse, - Ctap2PinUvAuthProtocol, Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity, - Ctap2PublicKeyCredentialUserEntity, Ctap2UserVerifiableRequest, + get_assertion::{CalculatedHMACGetSecretInput, Ctap2PrfSalts}, + Ctap2AttestationStatement, Ctap2AuthTokenPermissionRole, Ctap2CredentialType, + Ctap2GetInfoResponse, Ctap2PinUvAuthProtocol, Ctap2PublicKeyCredentialDescriptor, + Ctap2PublicKeyCredentialRpEntity, Ctap2PublicKeyCredentialUserEntity, + Ctap2UserVerifiableRequest, }; use crate::{ fido::AuthenticatorData, @@ -12,7 +13,7 @@ use crate::{ MakeCredentialsResponseUnsignedExtensions, PrfInputValue, ResidentKeyRequirement, }, pin::PinUvAuthProtocol, - proto::CtapError, + proto::{ctap2::cbor::Value, CtapError}, transport::AuthTokenData, webauthn::{Error, PlatformError}, }; @@ -20,6 +21,7 @@ use ctap_types::ctap2::credential_management::CredentialProtectionPolicy as Ctap use serde::{Deserialize, Serialize}; use serde_bytes::ByteBuf; use serde_indexed::{DeserializeIndexed, SerializeIndexed}; +use std::collections::BTreeMap; use tracing::{error, warn}; #[derive(Debug, Default, Clone, Copy, Serialize)] @@ -180,6 +182,10 @@ impl Ctap2MakeCredentialRequest { #[serde(rename_all = "camelCase")] pub struct Ctap2MakeCredentialsRequestExtensions { // Field order is CTAP2 canonical CBOR map order: shortest key first, then bytewise. + /// Native `prf` extension, used by phone/platform authenticators that advertise + /// `prf` in getInfo instead of `hmac-secret` (e.g. over hybrid). + #[serde(skip_serializing_if = "Option::is_none")] + pub prf: Option, #[serde(skip_serializing_if = "Option::is_none", with = "serde_bytes")] pub cred_blob: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -200,7 +206,8 @@ pub struct Ctap2MakeCredentialsRequestExtensions { impl Ctap2MakeCredentialsRequestExtensions { pub fn skip_serializing(&self) -> bool { - self.cred_blob.is_none() + self.prf.is_none() + && self.cred_blob.is_none() && self.cred_protect.is_none() && self.hmac_secret.is_none() && self.large_blob_key.is_none() @@ -209,6 +216,12 @@ impl Ctap2MakeCredentialsRequestExtensions { } } +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] +pub struct Ctap2PrfMakeCredentialInput { + #[serde(skip_serializing_if = "Option::is_none")] + pub eval: Option, +} + impl Ctap2MakeCredentialsRequestExtensions { fn from_webauthn_request( requested_extensions: &MakeCredentialsRequestExtensions, @@ -257,8 +270,12 @@ impl Ctap2MakeCredentialsRequestExtensions { _ => None, }; + // Prefer the native `prf` extension when advertised; otherwise map the + // WebAuthn PRF input onto hmac-secret (+ hmac-secret-mc where available). + let native_prf = requested_extensions.prf.is_some() && info.supports_extension("prf"); + let hmac_secret = if requested_extensions.hmac_create_secret == Some(true) - || requested_extensions.prf.is_some() + || (requested_extensions.prf.is_some() && !native_prf) { Some(true) } else { @@ -270,10 +287,24 @@ impl Ctap2MakeCredentialsRequestExtensions { .as_ref() .and_then(|prf| prf.eval.clone()) .filter(|_| { - info.supports_extension("hmac-secret-mc") && info.supports_extension("hmac-secret") + !native_prf + && info.supports_extension("hmac-secret-mc") + && info.supports_extension("hmac-secret") }); + let prf = if native_prf { + requested_extensions + .prf + .as_ref() + .map(|prf| Ctap2PrfMakeCredentialInput { + eval: prf.eval.as_ref().map(Ctap2PrfSalts::from), + }) + } else { + None + }; + Ok(Ctap2MakeCredentialsRequestExtensions { + prf, cred_blob: requested_extensions .cred_blob .as_ref() @@ -343,6 +374,12 @@ pub struct Ctap2MakeCredentialResponse { #[serde(skip_serializing_if = "Option::is_none")] #[serde(index = 0x05)] pub large_blob_key: Option, + + /// unsignedExtensionOutputs (CTAP 2.2 §6.1), where the native `prf` output + /// is returned by phone authenticators. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(index = 0x06)] + pub unsigned_extension_outputs: Option>, } impl Ctap2MakeCredentialResponse { @@ -355,6 +392,7 @@ impl Ctap2MakeCredentialResponse { let unsigned_extensions_output = MakeCredentialsResponseUnsignedExtensions::from_signed_extensions( &self.authenticator_data.extensions, + self.unsigned_extension_outputs.as_ref(), request, info, auth_data, @@ -602,6 +640,189 @@ mod tests { assert!(ext.prf_input.is_none()); } + #[test] + fn native_prf_used_when_getinfo_advertises_prf() { + let info = info_with_extensions(&["prf"]); + let req = mc_request_with_prf(Some(PrfInputValue { + first: b"create-first".to_vec(), + second: None, + })); + let ctap = Ctap2MakeCredentialRequest::from_webauthn_request(&req, &info).unwrap(); + let ext = ctap.extensions.as_ref().unwrap(); + assert!(ext.hmac_secret.is_none(), "hmac-secret must not be sent"); + assert!(ext.prf_input.is_none(), "no hmac-secret-mc buffering"); + let prf = ext.prf.as_ref().expect("native prf set"); + let eval = prf.eval.as_ref().expect("eval present"); + let expected = PrfInputValue { + first: b"create-first".to_vec(), + second: None, + } + .to_hmac_secret_input(); + assert_eq!(eval.first, expected.salt1); + assert!(!ctap.needs_shared_secret(&info)); + + // Wire format: {"prf": {"eval": {"first": h'..32 bytes..'}}} + 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.len(), 1); + let Some(Value::Map(prf_map)) = parsed.get("prf") else { + panic!("prf entry missing") + }; + let Some(Value::Map(eval_map)) = prf_map.get(&Value::Text("eval".to_string())) else { + panic!("eval entry missing") + }; + // The salt must encode as a 32-byte CBOR byte string. + match eval_map.get(&Value::Text("first".to_string())) { + Some(Value::Bytes(bytes)) => assert_eq!(bytes.len(), 32), + other => panic!("first must be a byte string, got {other:?}"), + } + } + + #[test] + fn native_prf_without_eval_sends_empty_map() { + let info = info_with_extensions(&["prf"]); + let req = mc_request_with_prf(None); + let ctap = Ctap2MakeCredentialRequest::from_webauthn_request(&req, &info).unwrap(); + let ext = ctap.extensions.as_ref().unwrap(); + assert_eq!(ext.prf, Some(Ctap2PrfMakeCredentialInput { eval: None })); + assert!(!ext.skip_serializing()); + + // {"prf": {}} + let bytes = crate::proto::ctap2::cbor::to_vec(&ext).unwrap(); + assert_eq!(bytes, vec![0xA1, 0x63, b'p', b'r', b'f', 0xA0]); + } + + #[test] + fn native_prf_preferred_over_hmac_secret_when_both_advertised() { + let info = info_with_extensions(&["hmac-secret", "hmac-secret-mc", "prf"]); + let req = mc_request_with_prf(Some(PrfInputValue { + first: b"x".to_vec(), + second: None, + })); + let ctap = Ctap2MakeCredentialRequest::from_webauthn_request(&req, &info).unwrap(); + let ext = ctap.extensions.as_ref().unwrap(); + assert!(ext.prf.is_some()); + assert!(ext.hmac_secret.is_none()); + assert!(ext.prf_input.is_none()); + } + + #[test] + fn prf_enabled_parsed_from_unsigned_extension_outputs() { + // Phone case: no signed extensions, prf enabled in unsignedExtensionOutputs (0x06). + let mut prf_entry = BTreeMap::new(); + prf_entry.insert(Value::Text("enabled".to_string()), Value::Bool(true)); + let mut outputs = BTreeMap::new(); + outputs.insert(Value::Text("prf".to_string()), Value::Map(prf_entry)); + + let req = mc_request_with_prf(None); + let out = MakeCredentialsResponseUnsignedExtensions::from_signed_extensions( + &None, + Some(&outputs), + &req, + None, + None, + ); + let prf = out.prf.expect("prf output present"); + assert_eq!(prf.enabled, Some(true)); + assert!(prf.results.is_none()); + } + + #[test] + fn native_prf_request_serializes_extensions_at_0x06() { + let info = info_with_extensions(&["prf"]); + let req = mc_request_with_prf(Some(PrfInputValue { + first: b"input".to_vec(), + second: None, + })); + let ctap = Ctap2MakeCredentialRequest::from_webauthn_request(&req, &info).unwrap(); + + let bytes = crate::proto::ctap2::cbor::to_vec(&ctap).unwrap(); + let parsed: BTreeMap = crate::proto::ctap2::cbor::from_slice(&bytes).unwrap(); + let Some(Value::Map(extensions)) = parsed.get(&0x06) else { + panic!("extensions (0x06) missing from the wire") + }; + assert!(extensions.contains_key(&Value::Text("prf".to_string()))); + } + + #[test] + fn hmac_create_secret_not_rerouted_by_prf_support() { + // The passthrough applies to the prf extension only. + let info = info_with_extensions(&["prf"]); + let req = MakeCredentialRequest { + extensions: Some(MakeCredentialsRequestExtensions { + hmac_create_secret: Some(true), + ..Default::default() + }), + ..mc_request_with_prf(None) + }; + let ctap = Ctap2MakeCredentialRequest::from_webauthn_request(&req, &info).unwrap(); + let ext = ctap.extensions.unwrap(); + assert!(ext.prf.is_none()); + assert_eq!(ext.hmac_secret, Some(true)); + } + + #[test] + fn prf_create_time_results_parsed_from_unsigned_extension_outputs() { + // Google Password Manager phones evaluate eval at creation and return + // results alongside enabled. + let mut results = BTreeMap::new(); + results.insert( + Value::Text("first".to_string()), + Value::Bytes(vec![0xAB; 32]), + ); + results.insert( + Value::Text("second".to_string()), + Value::Bytes(vec![0xCD; 32]), + ); + let mut prf_entry = BTreeMap::new(); + prf_entry.insert(Value::Text("enabled".to_string()), Value::Bool(true)); + prf_entry.insert(Value::Text("results".to_string()), Value::Map(results)); + let mut outputs = BTreeMap::new(); + outputs.insert(Value::Text("prf".to_string()), Value::Map(prf_entry)); + + let req = mc_request_with_prf(Some(PrfInputValue { + first: b"input".to_vec(), + second: Some(b"input-2".to_vec()), + })); + let out = MakeCredentialsResponseUnsignedExtensions::from_signed_extensions( + &None, + Some(&outputs), + &req, + None, + None, + ); + let prf = out.prf.expect("prf output present"); + assert_eq!(prf.enabled, Some(true)); + let results = prf.results.expect("create-time results present"); + assert_eq!(results.first, [0xAB; 32]); + assert_eq!(results.second, Some([0xCD; 32])); + } + + #[test] + fn decodes_unsigned_extension_outputs_at_index_0x06() { + // 0x06 is unsignedExtensionOutputs (CTAP 2.2 §6.1). + let mut auth_data = vec![0u8; 37]; + auth_data[32] = crate::fido::AuthenticatorDataFlags::USER_PRESENT.bits(); + + let mut prf_entry = BTreeMap::new(); + prf_entry.insert(Value::Text("enabled".to_string()), Value::Bool(true)); + let mut ueo = BTreeMap::new(); + ueo.insert(Value::Text("prf".to_string()), Value::Map(prf_entry)); + + let mut response: BTreeMap = BTreeMap::new(); + response.insert(0x01, Value::Text("none".to_string())); + response.insert(0x02, Value::Bytes(auth_data)); + response.insert(0x03, Value::Map(BTreeMap::new())); + response.insert(0x06, Value::Map(ueo.clone())); + + let bytes = crate::proto::ctap2::cbor::to_vec(&response).unwrap(); + let parsed: Ctap2MakeCredentialResponse = + crate::proto::ctap2::cbor::from_slice(&bytes).unwrap(); + + assert_eq!(parsed.unsigned_extension_outputs, Some(ueo)); + } + #[test] fn needs_shared_secret_true_only_when_mc_advertised_and_buffered() { let info_mc = info_with_extensions(&["hmac-secret", "hmac-secret-mc"]); @@ -736,6 +957,7 @@ mod tests { let out = MakeCredentialsResponseUnsignedExtensions::from_signed_extensions( &Some(signed), + None, &req, None, Some(&auth_data), @@ -774,6 +996,7 @@ mod tests { } let ext = Ctap2MakeCredentialsRequestExtensions { + prf: None, cred_protect: Some(Ctap2CredentialProtectionPolicy::Required), cred_blob: Some(vec![1, 2, 3]), large_blob_key: Some(true),