From 020a9f90ae287af98b3f8bd6fa6931e387555602 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 9 Jul 2026 18:57:22 +0000 Subject: [PATCH] feat(webauthn): parse hints & merge into a transport preference WebAuthn Level 3 lets a relying party say how it expects the user to authenticate, through the hints array, the authenticator attachment, and per-credential transports. All three were parsed at the IDL layer and then dropped on the way to the internal request, so the preference never reached the caller. Parse them into typed enums and merge them into one ordered, de-duplicated list of transports, exposed through a new TransportHintedRequest trait on both request types, so that a consumer can prioritise its device chooser. Hints outrank the attachment, which outranks per-credential transports. A hybrid hint on its own also demotes the platform authenticator. The result is advisory ordering, never an eligibility filter. Unrecognised values parse as Unknown rather than failing, and are ignored when merging. Both new enums are non_exhaustive, since the WebAuthn enumerations are extensible and a future variant should not be a breaking change. Refs: linux-credentials/credentialsd#183 --- libwebauthn-tests/tests/basic_ctap2.rs | 3 + libwebauthn-tests/tests/large_blob.rs | 7 + libwebauthn-tests/tests/preflight.rs | 3 + libwebauthn-tests/tests/prf.rs | 13 + .../tests/signature_roundtrip.rs | 3 + libwebauthn/examples/features/prf_replay.rs | 1 + .../features/webauthn_extensions_hid.rs | 3 + .../features/webauthn_preflight_hid.rs | 3 + .../examples/features/webauthn_prf_cable.rs | 3 + .../examples/features/webauthn_prf_hid.rs | 4 + libwebauthn/src/ops/u2f.rs | 1 + libwebauthn/src/ops/webauthn/get_assertion.rs | 120 +++++- libwebauthn/src/ops/webauthn/hints.rs | 397 ++++++++++++++++++ libwebauthn/src/ops/webauthn/idl/create.rs | 7 +- libwebauthn/src/ops/webauthn/idl/get.rs | 4 +- libwebauthn/src/ops/webauthn/large_blob.rs | 3 + .../src/ops/webauthn/make_credential.rs | 136 +++++- libwebauthn/src/ops/webauthn/mod.rs | 2 + .../src/proto/ctap2/model/get_assertion.rs | 1 + .../src/proto/ctap2/model/make_credential.rs | 2 + libwebauthn/src/webauthn.rs | 1 + libwebauthn/src/webauthn/pin_uv_auth_token.rs | 1 + 22 files changed, 711 insertions(+), 7 deletions(-) create mode 100644 libwebauthn/src/ops/webauthn/hints.rs 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],