diff --git a/libwebauthn-tests/tests/basic_ctap2.rs b/libwebauthn-tests/tests/basic_ctap2.rs index 0cbb105c..9abaa878 100644 --- a/libwebauthn-tests/tests/basic_ctap2.rs +++ b/libwebauthn-tests/tests/basic_ctap2.rs @@ -38,6 +38,8 @@ async fn test_webauthn_basic_ctap2() { // Make Credentials ceremony let make_credentials_request = MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, challenge: Vec::from(challenge), origin: "example.org".to_owned(), top_origin: None, @@ -64,6 +66,7 @@ async fn test_webauthn_basic_ctap2() { let credential: Ctap2PublicKeyCredentialDescriptor = (&response.authenticator_data).try_into().unwrap(); let get_assertion = GetAssertionRequest { + hints: vec![], relying_party_id: "example.org".to_owned(), challenge: Vec::from(challenge), origin: "example.org".to_string(), diff --git a/libwebauthn-tests/tests/large_blob.rs b/libwebauthn-tests/tests/large_blob.rs index b1c42f74..ad1e0c9d 100644 --- a/libwebauthn-tests/tests/large_blob.rs +++ b/libwebauthn-tests/tests/large_blob.rs @@ -46,6 +46,8 @@ async fn test_webauthn_large_blob_read_returns_planted_blob() { let challenge: [u8; 32] = thread_rng().gen(); let make = MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, origin: RP.into(), challenge: challenge.to_vec(), relying_party: Ctap2PublicKeyCredentialRpEntity::new(RP, RP), @@ -91,6 +93,7 @@ async fn test_webauthn_large_blob_read_returns_planted_blob() { plant_large_blob_array(&mut channel, serialized).await; let ga = GetAssertionRequest { + hints: vec![], relying_party_id: RP.into(), origin: RP.into(), challenge: challenge.to_vec(), @@ -127,6 +130,7 @@ async fn capture_large_blob_key( challenge: &[u8; 32], ) -> [u8; 32] { let ga_for_key = GetAssertionRequest { + hints: vec![], relying_party_id: RP.into(), origin: RP.into(), challenge: challenge.to_vec(), @@ -239,6 +243,8 @@ async fn register_with_large_blob( ) -> Ctap2PublicKeyCredentialDescriptor { let user_id: [u8; 32] = thread_rng().gen(); let make = MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, origin: RP.into(), challenge: challenge.to_vec(), relying_party: Ctap2PublicKeyCredentialRpEntity::new(RP, RP), @@ -281,6 +287,7 @@ fn ga_request( ext: GetAssertionLargeBlobExtension, ) -> GetAssertionRequest { GetAssertionRequest { + hints: vec![], relying_party_id: RP.into(), origin: RP.into(), challenge: challenge.to_vec(), diff --git a/libwebauthn-tests/tests/preflight.rs b/libwebauthn-tests/tests/preflight.rs index d9d6a362..9a895c13 100644 --- a/libwebauthn-tests/tests/preflight.rs +++ b/libwebauthn-tests/tests/preflight.rs @@ -49,6 +49,8 @@ async fn make_credential_call_with_rp( ) -> Result<(Ctap2PublicKeyCredentialDescriptor, [u8; 32]), WebAuthnError> { let challenge: [u8; 32] = thread_rng().gen(); let make_credentials_request = MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, origin: rp_id.to_owned(), challenge: Vec::from(challenge), relying_party: Ctap2PublicKeyCredentialRpEntity::new(rp_id, rp_id), @@ -75,6 +77,7 @@ async fn get_assertion_call( ) -> Result> { let challenge: [u8; 32] = thread_rng().gen(); let get_assertion = GetAssertionRequest { + hints: vec![], origin: "example.org".to_owned(), relying_party_id: "example.org".to_owned(), challenge: Vec::from(challenge), diff --git a/libwebauthn-tests/tests/prf.rs b/libwebauthn-tests/tests/prf.rs index 35d5b7ad..b2fb8c55 100644 --- a/libwebauthn-tests/tests/prf.rs +++ b/libwebauthn-tests/tests/prf.rs @@ -99,6 +99,8 @@ async fn test_webauthn_prf_eval_at_create_degrades_when_unsupported() { ..Default::default() }; let req = MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, origin: "example.org".to_owned(), challenge: Vec::from(challenge), relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), @@ -174,6 +176,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { // Make Credentials ceremony let make_credentials_request = MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, origin: "example.org".to_owned(), challenge: Vec::from(challenge), relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), @@ -242,6 +246,7 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { let credential: Ctap2PublicKeyCredentialDescriptor = (&response.authenticator_data).try_into().unwrap(); let get_assertion = GetAssertionRequest { + hints: vec![], relying_party_id: "example.org".to_owned(), origin: "example.org".to_owned(), challenge: Vec::from(challenge), @@ -558,6 +563,7 @@ async fn run_success_test( printoutput: &str, ) { let get_assertion = GetAssertionRequest { + hints: vec![], relying_party_id: "example.org".to_owned(), origin: "example.org".to_owned(), challenge: Vec::from(challenge), @@ -625,6 +631,7 @@ async fn run_failed_test( expected_error: PlatformError, ) { let get_assertion = GetAssertionRequest { + hints: vec![], relying_party_id: "example.org".to_owned(), origin: "example.org".to_owned(), challenge: Vec::from(challenge), @@ -671,6 +678,8 @@ async fn test_webauthn_prf_variable_length_input() { let challenge: [u8; 32] = thread_rng().gen(); let make_credentials_request = MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, origin: "example.org".to_owned(), challenge: Vec::from(challenge), relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), @@ -716,6 +725,7 @@ async fn test_webauthn_prf_variable_length_input() { label: &str, ) -> [u8; 32] { let get_assertion = GetAssertionRequest { + hints: vec![], relying_party_id: "example.org".to_owned(), origin: "example.org".to_owned(), challenge: Vec::from(challenge.as_slice()), @@ -797,6 +807,8 @@ fn basic_make_credential_request( extensions: Option, ) -> MakeCredentialRequest { MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, origin: "example.org".to_owned(), challenge: Vec::from(challenge.as_slice()), relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), @@ -933,6 +945,7 @@ async fn test_webauthn_prf_upgrades_uv_at_assertion() { eval_by_credential: HashMap::new(), }; let get_assertion = GetAssertionRequest { + hints: vec![], relying_party_id: "example.org".to_owned(), origin: "example.org".to_owned(), challenge: Vec::from(challenge), diff --git a/libwebauthn-tests/tests/signature_roundtrip.rs b/libwebauthn-tests/tests/signature_roundtrip.rs index f15f566c..2b30f5f8 100644 --- a/libwebauthn-tests/tests/signature_roundtrip.rs +++ b/libwebauthn-tests/tests/signature_roundtrip.rs @@ -67,6 +67,8 @@ async fn test_ctap2_signature_roundtrip() { let challenge: [u8; 32] = thread_rng().gen(); let make_request = MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, challenge: Vec::from(challenge), origin: "example.org".to_owned(), top_origin: None, @@ -171,6 +173,7 @@ async fn test_ctap2_signature_roundtrip() { let credential: Ctap2PublicKeyCredentialDescriptor = (®istration.authenticator_data).try_into().unwrap(); let get_assertion = GetAssertionRequest { + hints: vec![], relying_party_id: "example.org".to_owned(), challenge: Vec::from(challenge), origin: "example.org".to_owned(), diff --git a/libwebauthn/examples/features/prf_replay.rs b/libwebauthn/examples/features/prf_replay.rs index 5a4ae944..2c4b9ae2 100644 --- a/libwebauthn/examples/features/prf_replay.rs +++ b/libwebauthn/examples/features/prf_replay.rs @@ -84,6 +84,7 @@ async fn run_success_test( printoutput: &str, ) { let get_assertion = GetAssertionRequest { + hints: vec![], relying_party_id: "demo.yubico.com".to_owned(), challenge: Vec::from(challenge), origin: "demo.yubico.com".to_string(), diff --git a/libwebauthn/examples/features/webauthn_extensions_hid.rs b/libwebauthn/examples/features/webauthn_extensions_hid.rs index 04be648a..418c2e8a 100644 --- a/libwebauthn/examples/features/webauthn_extensions_hid.rs +++ b/libwebauthn/examples/features/webauthn_extensions_hid.rs @@ -55,6 +55,8 @@ pub async fn main() -> Result<(), Box> { tokio::spawn(common::handle_uv_updates(state_recv)); let make_credentials_request = MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, challenge: Vec::from(challenge), origin: "example.org".to_owned(), top_origin: None, @@ -80,6 +82,7 @@ pub async fn main() -> Result<(), Box> { let credential: Ctap2PublicKeyCredentialDescriptor = (&response.authenticator_data).try_into().unwrap(); let get_assertion = GetAssertionRequest { + hints: vec![], relying_party_id: "example.org".to_owned(), challenge: Vec::from(challenge), origin: "example.org".to_string(), diff --git a/libwebauthn/examples/features/webauthn_preflight_hid.rs b/libwebauthn/examples/features/webauthn_preflight_hid.rs index 253dccdc..a9ba20f6 100644 --- a/libwebauthn/examples/features/webauthn_preflight_hid.rs +++ b/libwebauthn/examples/features/webauthn_preflight_hid.rs @@ -110,6 +110,8 @@ async fn make_credential_call( ) -> Result> { let challenge: [u8; 32] = thread_rng().gen(); let make_credentials_request = MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, challenge: Vec::from(challenge), origin: "example.org".to_owned(), top_origin: None, @@ -134,6 +136,7 @@ async fn get_assertion_call( ) -> Result> { let challenge: [u8; 32] = thread_rng().gen(); let get_assertion = GetAssertionRequest { + hints: vec![], relying_party_id: "example.org".to_owned(), challenge: Vec::from(challenge), origin: "example.org".to_string(), diff --git a/libwebauthn/examples/features/webauthn_prf_cable.rs b/libwebauthn/examples/features/webauthn_prf_cable.rs index ee86ecb9..6d5f6f24 100644 --- a/libwebauthn/examples/features/webauthn_prf_cable.rs +++ b/libwebauthn/examples/features/webauthn_prf_cable.rs @@ -108,6 +108,8 @@ async fn create() -> Result<(), Box> { }; let request = MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, challenge: vec![0x11; 32], origin: ORIGIN.to_owned(), top_origin: None, @@ -169,6 +171,7 @@ async fn get(credential_id: Option<&str>) -> Result<(), Box> { }; let request = GetAssertionRequest { + hints: vec![], relying_party_id: RP_ID.to_owned(), challenge: vec![0x22; 32], origin: ORIGIN.to_owned(), diff --git a/libwebauthn/examples/features/webauthn_prf_hid.rs b/libwebauthn/examples/features/webauthn_prf_hid.rs index e746a50b..21477939 100644 --- a/libwebauthn/examples/features/webauthn_prf_hid.rs +++ b/libwebauthn/examples/features/webauthn_prf_hid.rs @@ -48,6 +48,8 @@ pub async fn main() -> Result<(), Box> { tokio::spawn(common::handle_uv_updates(state_recv)); let make_credentials_request = MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, challenge: Vec::from(challenge), origin: "example.org".to_owned(), top_origin: None, @@ -333,6 +335,7 @@ async fn run_success_test( printoutput: &str, ) { let get_assertion = GetAssertionRequest { + hints: vec![], relying_party_id: "example.org".to_owned(), challenge: Vec::from(challenge), origin: "example.org".to_string(), @@ -364,6 +367,7 @@ async fn run_failed_test( expected_error: PlatformError, ) { let get_assertion = GetAssertionRequest { + hints: vec![], relying_party_id: "example.org".to_owned(), challenge: Vec::from(challenge), origin: "example.org".to_string(), diff --git a/libwebauthn/src/ops/u2f.rs b/libwebauthn/src/ops/u2f.rs index 144c3bf2..e51cc472 100644 --- a/libwebauthn/src/ops/u2f.rs +++ b/libwebauthn/src/ops/u2f.rs @@ -228,6 +228,7 @@ impl UpgradableResponse for SignResponse { // This isn't great, but we have no access to the original request, and need to construct // something like that here. In reality, we only need `extensions: None` currently. let orig_request = GetAssertionRequest { + hints: vec![], relying_party_id: String::new(), // We don't have access to that info here, but we don't need it either challenge: Vec::new(), // U2F path doesn't use client_data for response serialization origin: String::new(), diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 9f62d545..a011eaee 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -34,7 +34,8 @@ use crate::{ use super::timeout::DEFAULT_TIMEOUT; use super::{ - DowngradableRequest, RelyingPartyId, RequestOrigin, SignRequest, UserVerificationRequirement, + DowngradableRequest, PublicKeyCredentialHint, RelyingPartyId, RequestOrigin, SignRequest, + UserVerificationRequirement, }; /// PRF extension input salts. Per W3C WebAuthn L3 §10.1.4, these are @@ -83,6 +84,9 @@ pub struct GetAssertionRequest { /// nested browsing context. None for same-origin requests. pub top_origin: Option, pub allow: Vec, + /// Relying-party hints, in descending order of preference, verbatim as parsed (may contain + /// [`PublicKeyCredentialHint::Unknown`]). See [`TransportHintedRequest`](crate::ops::webauthn::TransportHintedRequest). + pub hints: Vec, pub extensions: Option, pub user_verification: UserVerificationRequirement, pub timeout: Duration, @@ -243,6 +247,7 @@ impl FromIdlModel for GetAssertionRequest .into_iter() .map(|c| c.into()) .collect(), + hints: inner.hints, extensions, user_verification: inner.user_verification, timeout, @@ -793,6 +798,7 @@ mod tests { fn request_base() -> GetAssertionRequest { GetAssertionRequest { + hints: vec![], relying_party_id: "example.org".to_owned(), challenge: base64_url::decode("Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu").unwrap(), origin: "https://example.org".to_string(), @@ -836,6 +842,117 @@ mod tests { assert_eq!(req, request_base()); } + #[tokio::test] + async fn test_request_hints_parsed_and_merged() { + use crate::ops::webauthn::{PublicKeyCredentialHint, TransportHintedRequest}; + use crate::proto::ctap2::Ctap2Transport; + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + &json_field_add( + REQUEST_BASE_JSON, + "allowCredentials", + r#"[{"type":"public-key","id":"bXktY3JlZGVudGlhbC1pZA","transports":["usb"]}]"#, + ), + "hints", + r#"["hybrid","security-key","made-up"]"#, + ); + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); + // Hints stored verbatim; the unknown value survives as `Unknown`. + assert_eq!( + req.hints, + vec![ + PublicKeyCredentialHint::Hybrid, + PublicKeyCredentialHint::SecurityKey, + PublicKeyCredentialHint::Unknown, + ] + ); + // hybrid first, then security-key's roaming set; the allowCredentials `usb` de-dups. + assert_eq!( + req.preferred_transports(), + vec![ + Ctap2Transport::Hybrid, + Ctap2Transport::Usb, + Ctap2Transport::Nfc, + Ctap2Transport::Ble, + Ctap2Transport::SmartCard, + ] + ); + } + + #[tokio::test] + async fn test_preferred_transports_from_allow_credentials_transports() { + use crate::ops::webauthn::TransportHintedRequest; + use crate::proto::ctap2::Ctap2Transport; + // No hints: preference comes purely from allowCredentials.transports, flattened in + // descriptor list order and de-duplicated. A descriptor with no `transports` (a + // discoverable credential) contributes nothing. + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "allowCredentials", + r#"[ + {"type":"public-key","id":"bXktY3JlZGVudGlhbC1pZA"}, + {"type":"public-key","id":"AQID","transports":["usb"]}, + {"type":"public-key","id":"BAUG","transports":["nfc","usb"]} + ]"#, + ); + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); + assert!(req.hints.is_empty()); + assert_eq!( + req.preferred_transports(), + vec![Ctap2Transport::Usb, Ctap2Transport::Nfc] + ); + } + + #[tokio::test] + async fn test_preferred_transports_orders_a_device_chooser() { + use crate::ops::webauthn::TransportHintedRequest; + use crate::proto::ctap2::Ctap2Transport; + // The consumer contract in miniature: a partial preference list used as a sort key, with + // unlisted transports ranked last while keeping their original relative order. + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add(REQUEST_BASE_JSON, "hints", r#"["client-device"]"#); + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); + let pref = req.preferred_transports(); + assert_eq!(pref, vec![Ctap2Transport::Internal]); + + let mut devices = vec![ + Ctap2Transport::Usb, + Ctap2Transport::Internal, + Ctap2Transport::Hybrid, + ]; + devices.sort_by_key(|t| pref.iter().position(|p| p == t).unwrap_or(usize::MAX)); + assert_eq!( + devices, + vec![ + Ctap2Transport::Internal, + Ctap2Transport::Usb, + Ctap2Transport::Hybrid, + ] + ); + } + #[tokio::test] async fn test_request_from_json_passes_through_unknown_transports() { use crate::proto::ctap2::Ctap2Transport; @@ -1427,6 +1544,7 @@ mod tests { fn create_test_request() -> GetAssertionRequest { GetAssertionRequest { + hints: vec![], relying_party_id: "example.org".to_owned(), challenge: b"DEADCODE_challenge".to_vec(), origin: "example.org".to_string(), diff --git a/libwebauthn/src/ops/webauthn/hints.rs b/libwebauthn/src/ops/webauthn/hints.rs new file mode 100644 index 00000000..36e0849a --- /dev/null +++ b/libwebauthn/src/ops/webauthn/hints.rs @@ -0,0 +1,397 @@ +//! Relying party hints about how the user is expected to authenticate. +//! +//! WebAuthn Level 3 lets a site express a preference through three signals: the hints array, +//! per-credential transports, and, on registration, the authenticator attachment. This module +//! parses them and merges them into a single ordered list of transports, so that a device chooser +//! can offer the likely ones first. +//! +//! See . + +use serde::Deserialize; + +use super::{GetAssertionRequest, MakeCredentialRequest}; +use crate::proto::ctap2::Ctap2Transport; + +/// A relying party's hint about how the user is expected to authenticate. +/// +/// A value we do not recognise parses as [`Unknown`](Self::Unknown) rather than failing, and is +/// ignored when merging. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum PublicKeyCredentialHint { + /// A physical, roaming security key. + SecurityKey, + /// A platform authenticator built into the client device. + ClientDevice, + /// A general purpose authenticator, such as a phone reached over hybrid transport. + Hybrid, + /// A value we do not recognise. + #[serde(other)] + Unknown, +} + +impl PublicKeyCredentialHint { + /// Parses a hint held as a raw string, for callers that bypass the JSON layer. + pub fn from_dom_string(s: &str) -> Self { + match s { + "security-key" => Self::SecurityKey, + "client-device" => Self::ClientDevice, + "hybrid" => Self::Hybrid, + _ => Self::Unknown, + } + } +} + +/// The authenticator attachment requested at registration. Assertion requests carry no attachment. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum AuthenticatorAttachment { + /// An authenticator built into the client device. + Platform, + /// A roaming authenticator. + CrossPlatform, + /// A value we do not recognise. + #[serde(other)] + Unknown, +} + +impl AuthenticatorAttachment { + /// Parses an attachment held as a raw string, for callers that bypass the JSON layer. + pub fn from_dom_string(s: &str) -> Self { + match s { + "platform" => Self::Platform, + "cross-platform" => Self::CrossPlatform, + _ => Self::Unknown, + } + } +} + +/// Appends the transports that are not already present, keeping the first occurrence. The lists are +/// only ever a handful of entries, so a linear scan is fine. +fn push_absent(out: &mut Vec, ts: &[Ctap2Transport]) { + for t in ts { + if !out.contains(t) { + out.push(t.clone()); + } + } +} + +/// Merges a relying party's signals into one transport preference, most preferred first. +/// +/// Hints outrank the attachment, which outranks per-credential transports. The spec allows the +/// three to contradict each other and gives hints the final say, so precedence falls out of the +/// order in which each source contributes. +/// +/// The result is advisory ordering, never an eligibility filter, and it is partial: a transport the +/// site did not mention is simply absent, and a caller should rank it last rather than exclude it. +/// Empty input means no preference. +fn merge_transport_preference( + hints: &[PublicKeyCredentialHint], + attachment: Option, + descriptor_transports: &[Ctap2Transport], +) -> Vec { + use AuthenticatorAttachment as A; + use Ctap2Transport::*; + use PublicKeyCredentialHint as H; + + let mut out: Vec = Vec::new(); + + // Hints rank highest, and the array is already in descending preference order. The order within + // a bucket is our own choice, as the spec does not rank them. + for h in hints { + match h { + H::SecurityKey => push_absent(&mut out, &[Usb, Nfc, Ble, SmartCard]), + H::ClientDevice => push_absent(&mut out, &[Internal]), + H::Hybrid => push_absent(&mut out, &[Hybrid]), + H::Unknown => {} + } + } + + // The attachment only fills gaps. It never reorders anything a hint already placed. + match attachment { + Some(A::Platform) => push_absent(&mut out, &[Internal]), + Some(A::CrossPlatform) => push_absent(&mut out, &[Usb, Nfc, Ble, SmartCard, Hybrid]), + Some(A::Unknown) | None => {} + } + + // Per-credential transports fill the remaining gaps. Values we do not recognise are ignored. + for t in descriptor_transports { + if !matches!(t, Ctap2Transport::Other(_)) && !out.contains(t) { + out.push(t.clone()); + } + } + + // A hybrid hint without a client-device hint is a signal not to promote the platform + // authenticator, so drop it even if the attachment or a credential transport put it back. + if hints.contains(&H::Hybrid) && !hints.contains(&H::ClientDevice) { + out.retain(|t| *t != Internal); + } + + out +} + +/// A request that carries relying party hints about the preferred transport. +pub trait TransportHintedRequest { + /// The advisory transport preference for this request, most preferred first. + /// + /// This is ordering only, and it is partial. A device whose transport is absent must still be + /// offered, just ranked last. It does not apply attachment eligibility, so a caller that needs + /// to exclude ineligible modalities should read the attachment field on the request itself. + fn preferred_transports(&self) -> Vec; +} + +impl TransportHintedRequest for GetAssertionRequest { + fn preferred_transports(&self) -> Vec { + // The allow list is already in descending preference order. A descriptor with no transports, + // which is how a discoverable credential arrives, contributes nothing. + let descriptor_transports: Vec = self + .allow + .iter() + .flat_map(|d| d.transports.iter().flatten().cloned()) + .collect(); + merge_transport_preference(&self.hints, None, &descriptor_transports) + } +} + +impl TransportHintedRequest for MakeCredentialRequest { + fn preferred_transports(&self) -> Vec { + // The exclude list is a signal to avoid, not a preference, so it contributes nothing here. + merge_transport_preference(&self.hints, self.authenticator_attachment, &[]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use AuthenticatorAttachment as A; + use Ctap2Transport::*; + use PublicKeyCredentialHint as H; + + // --- merge: coarse -> fine mapping ------------------------------------------------------- + + #[test] + fn security_key_expands_to_roaming_set() { + assert_eq!( + merge_transport_preference(&[H::SecurityKey], None, &[]), + vec![Usb, Nfc, Ble, SmartCard] + ); + } + + #[test] + fn client_device_expands_to_internal() { + assert_eq!( + merge_transport_preference(&[H::ClientDevice], None, &[]), + vec![Internal] + ); + } + + #[test] + fn hybrid_expands_to_hybrid() { + assert_eq!( + merge_transport_preference(&[H::Hybrid], None, &[]), + vec![Hybrid] + ); + } + + #[test] + fn platform_attachment_adds_internal() { + assert_eq!( + merge_transport_preference(&[], Some(A::Platform), &[]), + vec![Internal] + ); + } + + #[test] + fn cross_platform_attachment_adds_roaming_set() { + assert_eq!( + merge_transport_preference(&[], Some(A::CrossPlatform), &[]), + vec![Usb, Nfc, Ble, SmartCard, Hybrid] + ); + } + + // --- merge: ordering & precedence -------------------------------------------------------- + + #[test] + fn hint_array_order_is_descending_preference() { + assert_eq!( + merge_transport_preference(&[H::Hybrid, H::SecurityKey], None, &[]), + vec![Hybrid, Usb, Nfc, Ble, SmartCard] + ); + } + + #[test] + fn first_contradictory_hint_wins() { + // client-device before security-key => Internal ranks first. + assert_eq!( + merge_transport_preference(&[H::ClientDevice, H::SecurityKey], None, &[]), + vec![Internal, Usb, Nfc, Ble, SmartCard] + ); + } + + #[test] + fn hints_outrank_attachment() { + // create: client-device hint + cross-platform attachment => the hint's Internal ranks + // first, then the attachment gap-fills the roaming transports. + assert_eq!( + merge_transport_preference(&[H::ClientDevice], Some(A::CrossPlatform), &[]), + vec![Internal, Usb, Nfc, Ble, SmartCard, Hybrid] + ); + } + + #[test] + fn all_three_sources_respect_precedence() { + // All three inputs populated: client-device hint contributes Internal first; the platform + // attachment re-adds nothing; the descriptor's Usb gap-fills last. + assert_eq!( + merge_transport_preference(&[H::ClientDevice], Some(A::Platform), &[Usb]), + vec![Internal, Usb] + ); + } + + // --- merge: de-duplication --------------------------------------------------------------- + + #[test] + fn cross_source_duplicates_are_removed_keeping_first() { + // security-key hint already yields Usb; a descriptor Usb must not double it. + assert_eq!( + merge_transport_preference(&[H::SecurityKey], None, &[Usb]), + vec![Usb, Nfc, Ble, SmartCard] + ); + } + + #[test] + fn descriptor_transports_gap_fill_in_order() { + assert_eq!( + merge_transport_preference(&[], None, &[Nfc, Usb, Nfc]), + vec![Nfc, Usb] + ); + } + + #[test] + fn unknown_descriptor_transport_is_skipped() { + // A forward-compatible RP may list a transport libwebauthn does not model; it must be + // ignored, not surfaced as a preference. + assert_eq!( + merge_transport_preference(&[], None, &[Other("future-transport".to_owned()), Usb]), + vec![Usb] + ); + } + + // --- merge: unknown / empty -------------------------------------------------------------- + + #[test] + fn unknown_hint_is_skipped() { + assert_eq!( + merge_transport_preference(&[H::SecurityKey, H::Unknown], None, &[]), + vec![Usb, Nfc, Ble, SmartCard] + ); + } + + #[test] + fn unknown_hint_alone_contributes_nothing() { + assert_eq!( + merge_transport_preference(&[H::Unknown], None, &[]), + Vec::::new() + ); + } + + #[test] + fn empty_input_yields_empty() { + assert_eq!( + merge_transport_preference(&[], None, &[]), + Vec::::new() + ); + } + + #[test] + fn unknown_attachment_contributes_nothing() { + assert_eq!( + merge_transport_preference(&[], Some(A::Unknown), &[]), + Vec::::new() + ); + } + + // --- merge: hybrid negative signal ------------------------------------------------------- + + #[test] + fn hybrid_suppresses_platform_from_attachment() { + assert_eq!( + merge_transport_preference(&[H::Hybrid], Some(A::Platform), &[]), + vec![Hybrid] + ); + } + + #[test] + fn hybrid_suppresses_platform_from_descriptor() { + assert_eq!( + merge_transport_preference(&[H::Hybrid], None, &[Internal]), + vec![Hybrid] + ); + } + + #[test] + fn hybrid_and_client_device_together_keep_internal() { + assert_eq!( + merge_transport_preference(&[H::Hybrid, H::ClientDevice], None, &[]), + vec![Hybrid, Internal] + ); + } + + // --- enum deserialization ---------------------------------------------------------------- + + #[test] + fn hint_deserializes_by_string_value() { + assert_eq!( + serde_json::from_str::("\"security-key\"").unwrap(), + H::SecurityKey + ); + assert_eq!( + serde_json::from_str::("\"nonsense\"").unwrap(), + H::Unknown + ); + } + + #[test] + fn attachment_deserializes_by_string_value() { + assert_eq!( + serde_json::from_str::("\"cross-platform\"").unwrap(), + A::CrossPlatform + ); + assert_eq!( + serde_json::from_str::("\"nonsense\"").unwrap(), + A::Unknown + ); + } + + #[test] + fn unknown_element_does_not_fail_the_array() { + assert_eq!( + serde_json::from_str::>( + "[\"security-key\",\"totally-unknown\"]" + ) + .unwrap(), + vec![H::SecurityKey, H::Unknown] + ); + } + + #[test] + fn from_dom_string_matches_serde() { + for (s, h) in [ + ("security-key", H::SecurityKey), + ("client-device", H::ClientDevice), + ("hybrid", H::Hybrid), + ("whatever", H::Unknown), + ] { + assert_eq!(PublicKeyCredentialHint::from_dom_string(s), h); + } + for (s, a) in [ + ("platform", A::Platform), + ("cross-platform", A::CrossPlatform), + ("whatever", A::Unknown), + ] { + assert_eq!(AuthenticatorAttachment::from_dom_string(s), a); + } + } +} diff --git a/libwebauthn/src/ops/webauthn/idl/create.rs b/libwebauthn/src/ops/webauthn/idl/create.rs index 3f055f5c..f3cddc6d 100644 --- a/libwebauthn/src/ops/webauthn/idl/create.rs +++ b/libwebauthn/src/ops/webauthn/idl/create.rs @@ -2,7 +2,8 @@ use super::get::PublicKeyCredentialDescriptorJSON; use super::Base64UrlString; use crate::{ ops::webauthn::{ - MakeCredentialsRequestExtensions, ResidentKeyRequirement, UserVerificationRequirement, + AuthenticatorAttachment, MakeCredentialsRequestExtensions, PublicKeyCredentialHint, + ResidentKeyRequirement, UserVerificationRequirement, }, proto::ctap2::Ctap2CredentialType, }; @@ -16,7 +17,7 @@ use serde::Deserialize; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AuthenticatorSelectionCriteria { - pub authenticator_attachment: Option, + pub authenticator_attachment: Option, pub resident_key: Option, #[serde(default)] pub require_resident_key: bool, @@ -55,7 +56,7 @@ pub struct PublicKeyCredentialCreationOptionsJSON { #[serde(default)] pub exclude_credentials: Vec, pub authenticator_selection: Option, - pub hints: Option>, + pub hints: Option>, pub attestation: Option, pub attestation_formats: Option>, pub extensions: Option, diff --git a/libwebauthn/src/ops/webauthn/idl/get.rs b/libwebauthn/src/ops/webauthn/idl/get.rs index c953e809..94a00296 100644 --- a/libwebauthn/src/ops/webauthn/idl/get.rs +++ b/libwebauthn/src/ops/webauthn/idl/get.rs @@ -4,7 +4,7 @@ use serde::Deserialize; use serde_bytes::ByteBuf; use crate::{ - ops::webauthn::{Base64UrlString, UserVerificationRequirement}, + ops::webauthn::{Base64UrlString, PublicKeyCredentialHint, UserVerificationRequirement}, proto::ctap2::{ Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialType, Ctap2Transport, }, @@ -22,7 +22,7 @@ pub struct PublicKeyCredentialRequestOptionsJSON { #[serde(rename = "userVerification", default)] pub user_verification: UserVerificationRequirement, #[serde(default)] - pub hints: Vec, + pub hints: Vec, pub extensions: Option, } diff --git a/libwebauthn/src/ops/webauthn/large_blob.rs b/libwebauthn/src/ops/webauthn/large_blob.rs index e85bc389..2e373a57 100644 --- a/libwebauthn/src/ops/webauthn/large_blob.rs +++ b/libwebauthn/src/ops/webauthn/large_blob.rs @@ -1004,6 +1004,7 @@ mod tests { ); // 3. ctap2_get_assertion. Discouraged UV path → up=true, uv=false. let req = crate::proto::ctap2::Ctap2GetAssertionRequest::from(GetAssertionRequest { + hints: vec![], relying_party_id: "example.com".into(), challenge: vec![0u8; 32], origin: "example.com".into(), @@ -1043,6 +1044,7 @@ mod tests { ); let request = GetAssertionRequest { + hints: vec![], relying_party_id: "example.com".into(), challenge: vec![0u8; 32], origin: "example.com".into(), @@ -1139,6 +1141,7 @@ mod tests { ); let make_req = || GetAssertionRequest { + hints: vec![], relying_party_id: "example.com".into(), challenge: vec![0u8; 32], origin: "example.com".into(), diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 177a6dd7..d6087dd4 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -39,7 +39,10 @@ use crate::{ }; use super::timeout::DEFAULT_TIMEOUT; -use super::{DowngradableRequest, RegisterRequest, UserVerificationRequirement}; +use super::{ + AuthenticatorAttachment, DowngradableRequest, PublicKeyCredentialHint, RegisterRequest, + UserVerificationRequirement, +}; #[derive(Debug, Clone)] pub struct MakeCredentialResponse { @@ -420,6 +423,12 @@ pub struct MakeCredentialRequest { pub algorithms: Vec, /// excludeCredentialDescriptorList pub exclude: Option>, + /// Relying-party hints, in descending order of preference, verbatim as parsed (may contain + /// [`PublicKeyCredentialHint::Unknown`]). See [`TransportHintedRequest`](crate::ops::webauthn::TransportHintedRequest). + pub hints: Vec, + /// The requested `authenticatorSelection.authenticatorAttachment`, if any. Feeds the merged + /// transport preference and is a registration-only eligibility signal. + pub authenticator_attachment: Option, /// extensions pub extensions: Option, /// Attestation conveyance preference. `Some("none")` scrubs attestation. @@ -526,6 +535,11 @@ impl FromIdlModel for MakeCredentialRequ .collect(), ) }, + hints: inner.hints.unwrap_or_default(), + authenticator_attachment: inner + .authenticator_selection + .as_ref() + .and_then(|s| s.authenticator_attachment), extensions: inner.extensions, // WebAuthn IDL defaults attestation conveyance to "none". attestation: inner.attestation.or_else(|| Some("none".to_string())), @@ -688,6 +702,8 @@ impl MakeCredentialRequest { #[cfg(test)] pub(crate) fn dummy() -> Self { Self { + hints: vec![], + authenticator_attachment: None, challenge: Vec::new(), origin: "example.org".to_owned(), top_origin: None, @@ -903,6 +919,8 @@ mod tests { fn request_base() -> MakeCredentialRequest { MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, challenge: base64_url::decode("Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu").unwrap(), origin: "https://example.org".to_string(), top_origin: None, @@ -963,6 +981,120 @@ mod tests { assert_eq!(req, request_base()); } + #[tokio::test] + async fn test_request_hints_and_attachment_parsed_and_merged() { + use crate::ops::webauthn::TransportHintedRequest; + use crate::proto::ctap2::Ctap2Transport; + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + &json_field_add( + REQUEST_BASE_JSON, + "authenticatorSelection", + r#"{"residentKey":"discouraged","userVerification":"preferred","authenticatorAttachment":"cross-platform"}"#, + ), + "hints", + r#"["security-key"]"#, + ); + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); + assert_eq!(req.hints, vec![PublicKeyCredentialHint::SecurityKey]); + assert_eq!( + req.authenticator_attachment, + Some(AuthenticatorAttachment::CrossPlatform) + ); + // security-key => roaming set; cross-platform attachment only gap-fills `hybrid`. + assert_eq!( + req.preferred_transports(), + vec![ + Ctap2Transport::Usb, + Ctap2Transport::Nfc, + Ctap2Transport::Ble, + Ctap2Transport::SmartCard, + Ctap2Transport::Hybrid, + ] + ); + } + + #[tokio::test] + async fn test_preferred_transports_from_platform_attachment_only() { + use crate::ops::webauthn::TransportHintedRequest; + use crate::proto::ctap2::Ctap2Transport; + // No hints, authenticatorAttachment=platform: the preference is the platform authenticator. + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "authenticatorSelection", + r#"{"residentKey":"discouraged","userVerification":"preferred","authenticatorAttachment":"platform"}"#, + ); + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); + assert!(req.hints.is_empty()); + assert_eq!( + req.authenticator_attachment, + Some(AuthenticatorAttachment::Platform) + ); + assert_eq!(req.preferred_transports(), vec![Ctap2Transport::Internal]); + } + + #[tokio::test] + async fn test_request_unknown_attachment_does_not_fail_parse() { + // An unrecognized authenticatorAttachment must not fail the parse (§2.1.1); it deserializes + // to `Unknown` and contributes nothing to the merge. + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "authenticatorSelection", + r#"{"residentKey":"discouraged","userVerification":"preferred","authenticatorAttachment":"made-up"}"#, + ); + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); + assert_eq!( + req.authenticator_attachment, + Some(AuthenticatorAttachment::Unknown) + ); + } + + #[tokio::test] + async fn test_exclude_credentials_transports_are_not_a_preference() { + use crate::ops::webauthn::TransportHintedRequest; + // `excludeCredentials.transports` are an "avoid" signal (§5.4), not a preference: they must + // NOT gap-fill `preferred_transports()`. With no hints/attachment, the result is empty. + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "excludeCredentials", + r#"[{"type":"public-key","id":"AQID","transports":["usb"]}]"#, + ); + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); + assert!(req.exclude.is_some()); + assert!(req.preferred_transports().is_empty()); + } + #[tokio::test] async fn test_request_from_json_require_rp() { test_request_from_json_required_field("rp").await; @@ -1687,6 +1819,8 @@ mod tests { fn create_test_request() -> MakeCredentialRequest { MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, challenge: b"DEADCODE_challenge".to_vec(), origin: "example.org".to_string(), top_origin: None, diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index f2616a31..6d2ffc2a 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -1,5 +1,6 @@ mod client_data; mod get_assertion; +mod hints; pub mod idl; mod large_blob; mod make_credential; @@ -17,6 +18,7 @@ pub use get_assertion::{ GetAssertionResponse, GetAssertionResponseExtensions, GetAssertionResponseUnsignedExtensions, HMACGetSecretInput, HMACGetSecretOutput, PrfInput, PrfInputValue, PrfOutputValue, }; +pub use hints::{AuthenticatorAttachment, PublicKeyCredentialHint, TransportHintedRequest}; pub use idl::{ origin::{HostParseError, Origin, OriginHost, OriginParseError, RequestOrigin, Scheme}, rpid::RelyingPartyId, diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index de0f746f..d469275a 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -805,6 +805,7 @@ mod tests { fn make_request(allow: Vec) -> GetAssertionRequest { GetAssertionRequest { + hints: vec![], relying_party_id: "example.com".to_string(), challenge: vec![0u8; 32], origin: "https://example.com".to_string(), diff --git a/libwebauthn/src/proto/ctap2/model/make_credential.rs b/libwebauthn/src/proto/ctap2/model/make_credential.rs index 63ad7d37..ce07de30 100644 --- a/libwebauthn/src/proto/ctap2/model/make_credential.rs +++ b/libwebauthn/src/proto/ctap2/model/make_credential.rs @@ -683,6 +683,8 @@ mod tests { fn mc_request_with_prf(eval: Option) -> MakeCredentialRequest { MakeCredentialRequest { + hints: vec![], + authenticator_attachment: None, challenge: vec![0u8; 32], origin: "https://example.org".into(), top_origin: None, diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index 386bd4c4..7bfe8984 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -917,6 +917,7 @@ mod tests { allow: Vec, ) -> GetAssertionRequest { GetAssertionRequest { + hints: vec![], relying_party_id: "example.com".to_string(), challenge: vec![0u8; 32], origin: "https://example.com".to_string(), diff --git a/libwebauthn/src/webauthn/pin_uv_auth_token.rs b/libwebauthn/src/webauthn/pin_uv_auth_token.rs index 460ff7c4..b6fd76a4 100644 --- a/libwebauthn/src/webauthn/pin_uv_auth_token.rs +++ b/libwebauthn/src/webauthn/pin_uv_auth_token.rs @@ -742,6 +742,7 @@ mod test { ) -> Ctap2GetAssertionRequest { Ctap2GetAssertionRequest::from_webauthn_request( &GetAssertionRequest { + hints: vec![], relying_party_id: String::from("example.com"), origin: String::from("example.com"), challenge: vec![9; 32],