From 155e1b5eaa6055fdf6486dd9b6a9ddc2a911c4fe Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:30:09 +0100 Subject: [PATCH 01/29] feat(deps): add optional reqwest 0.12 behind related-origins-client feature --- libwebauthn/Cargo.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml index 0921dff3..b20dbec5 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -39,6 +39,10 @@ nfc-backend-libnfc = [ # external crates (e.g. libwebauthn-tests) can plug in a virtual HID transport # for end-to-end tests. virt = [] +# Provides the reqwest-backed default RelatedOriginsHttpClient. Off by default so +# the core crate stays HTTP-client-free; consumers that want the default impl +# opt in, others bring their own client. +related-origins-client = ["dep:reqwest"] [dependencies] base64-url = "3.0.0" @@ -100,6 +104,12 @@ apdu = { version = "0.4.0", optional = true } pcsc = { version = "2.9.0", optional = true } nfc1 = { version = "=0.6.0", optional = true, default-features = false } nfc1-sys = { version = "0.3.9", optional = true, default-features = false } +reqwest = { version = "0.12", default-features = false, features = [ + "rustls-tls-native-roots", + "http2", + "stream", + "charset", +], optional = true } [dev-dependencies] tracing-subscriber = { version = "0.3.3", features = ["env-filter"] } From 4fb8710f992a84c4c754df905776053b3aae8405 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:31:26 +0100 Subject: [PATCH 02/29] feat(webauthn): add related_origins module with trait and validator --- libwebauthn/src/ops/webauthn/mod.rs | 5 + .../src/ops/webauthn/related_origins/mod.rs | 166 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 libwebauthn/src/ops/webauthn/related_origins/mod.rs diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 767c6c0a..d34ba19c 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -3,6 +3,7 @@ mod get_assertion; pub mod idl; mod make_credential; pub mod psl; +pub mod related_origins; mod timeout; use super::u2f::{RegisterRequest, SignRequest}; @@ -36,6 +37,10 @@ pub use psl::{ PublicSuffixList, SystemLoadError, SystemPublicSuffixList, SYSTEM_PSL_DAFSA_PATH, SYSTEM_PSL_PATH, }; +pub use related_origins::{ + validate_related_origins, NoRelatedOriginsClient, RelatedOriginsError, + RelatedOriginsHttpClient, WellKnownResponse, MAX_REGISTRABLE_LABELS, +}; use serde::Deserialize; #[derive(Debug, Clone, Copy, Deserialize, PartialEq)] diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs new file mode 100644 index 00000000..3ccf18d2 --- /dev/null +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -0,0 +1,166 @@ +//! Related-origins validation (WebAuthn L3 §5.11). +//! +//! The HTTP fetch of the `webauthn` well-known document is abstracted behind +//! [`RelatedOriginsHttpClient`]; a reqwest-backed default lives in [`http`] +//! behind the `related-origins-client` cargo feature. + +use std::collections::BTreeSet; + +use async_trait::async_trait; +use serde::Deserialize; +use url::{Host, Url}; + +use super::idl::origin::Origin; +use super::idl::rpid::RelyingPartyId; +use super::psl::PublicSuffixList; + +#[cfg(feature = "related-origins-client")] +pub mod http; + +/// WebAuthn L3 §5.11 requires support for at least 5 registrable origin labels; +/// we cap at exactly 5 to bound abuse surface. +pub const MAX_REGISTRABLE_LABELS: usize = 5; + +#[derive(Debug, Clone)] +pub struct WellKnownResponse { + pub content_type: Option, + pub body: Vec, +} + +/// Fetcher for `https://{rp_id}/.well-known/webauthn`, per WebAuthn L3 §5.11.1 +/// step 2. Implementations MUST send no credentials, no Referer, refuse +/// non-`https://` redirects, cap the body size, and bound the request duration. +#[async_trait] +pub trait RelatedOriginsHttpClient: Send + Sync { + async fn fetch_well_known( + &self, + rp_id: &RelyingPartyId, + ) -> Result; +} + +#[derive(thiserror::Error, Debug, Clone)] +pub enum RelatedOriginsError { + #[error("well-known fetch failed: {0}")] + FetchFailed(String), + #[error("unexpected content type: {0:?}")] + UnexpectedContentType(Option), + #[error("malformed JSON body: {0}")] + MalformedJson(String), + #[error("malformed well-known document: {0}")] + MalformedDocument(String), + #[error("no listed related origin matches the caller origin")] + NoMatchingOrigin, +} + +pub type RelatedOriginsResult = Result<(), RelatedOriginsError>; + +#[derive(Debug, Deserialize)] +struct WellKnownDocument { + origins: Vec, +} + +/// Runs the WebAuthn L3 §5.11.1 related-origins validation procedure. +/// Returns `Ok(())` when a listed origin matches `caller_origin`, otherwise +/// returns the first fetch/parse error or [`RelatedOriginsError::NoMatchingOrigin`]. +pub async fn validate_related_origins( + caller_origin: &Origin, + rp_id: &RelyingPartyId, + psl: &dyn PublicSuffixList, + http: &dyn RelatedOriginsHttpClient, +) -> RelatedOriginsResult { + let resp = http.fetch_well_known(rp_id).await?; + let content_type_ok = resp + .content_type + .as_deref() + .map(is_application_json) + .unwrap_or(false); + if !content_type_ok { + return Err(RelatedOriginsError::UnexpectedContentType(resp.content_type)); + } + + let doc: WellKnownDocument = serde_json::from_slice(&resp.body) + .map_err(|e| RelatedOriginsError::MalformedJson(e.to_string()))?; + + let mut labels_seen: BTreeSet = BTreeSet::new(); + for origin_item in &doc.origins { + let Ok(url) = Url::parse(origin_item) else { + continue; + }; + let Some(domain) = effective_domain_of(&url) else { + continue; + }; + let label = match registrable_origin_label(&domain, psl) { + Some(l) if !l.is_empty() => l, + _ => continue, + }; + if labels_seen.len() >= MAX_REGISTRABLE_LABELS && !labels_seen.contains(&label) { + continue; + } + if same_origin(caller_origin, &url) { + return Ok(()); + } + if labels_seen.len() < MAX_REGISTRABLE_LABELS { + labels_seen.insert(label); + } + } + + Err(RelatedOriginsError::NoMatchingOrigin) +} + +/// First label of `host`'s registrable domain (eTLD+1), or `None` when the host +/// has no registrable domain (e.g. bare eTLD, IP literal, unknown TLD). +pub(crate) fn registrable_origin_label( + host: &str, + psl: &dyn PublicSuffixList, +) -> Option { + let registrable = psl.registrable_domain(host)?; + let label = registrable.split('.').next()?; + if label.is_empty() { + return None; + } + Some(label.to_string()) +} + +/// Effective domain of a URL per HTML §6.2: domain hosts and IP literals; opaque +/// hosts and host-less URLs return `None`. +fn effective_domain_of(url: &Url) -> Option { + match url.host()? { + Host::Domain(d) => Some(d.to_string()), + Host::Ipv4(ip) => Some(ip.to_string()), + Host::Ipv6(ip) => Some(format!("[{ip}]")), + } +} + +/// WebAuthn L3 §5.11.1 step 4.f: typed-origin equality between the caller's +/// origin and the listed entry's tuple origin. +fn same_origin(caller: &Origin, listed: &Url) -> bool { + let Ok(listed_str) = listed.as_str().parse::() else { + return false; + }; + *caller == listed_str +} + +/// Fetch §2.5 `application/json` essence check: case-insensitive, parameters +/// ignored. Used for WebAuthn L3 §5.11.1 step 2.a. +fn is_application_json(value: &str) -> bool { + let essence = value.split(';').next().unwrap_or("").trim(); + essence.eq_ignore_ascii_case("application/json") +} + +/// `RelatedOriginsHttpClient` that always refuses; preserves today's +/// "mismatching rp.id is a hard error" semantics for callers that do not opt +/// into related-origin fetches. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoRelatedOriginsClient; + +#[async_trait] +impl RelatedOriginsHttpClient for NoRelatedOriginsClient { + async fn fetch_well_known( + &self, + _: &RelyingPartyId, + ) -> Result { + Err(RelatedOriginsError::FetchFailed( + "this client does not support related origin requests".into(), + )) + } +} From ce6fd2354574c3592441f30053024f7848792127 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:32:37 +0100 Subject: [PATCH 03/29] feat(webauthn): add ReqwestRelatedOriginsClient behind feature flag --- .../src/ops/webauthn/related_origins/http.rs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 libwebauthn/src/ops/webauthn/related_origins/http.rs diff --git a/libwebauthn/src/ops/webauthn/related_origins/http.rs b/libwebauthn/src/ops/webauthn/related_origins/http.rs new file mode 100644 index 00000000..f0974c2a --- /dev/null +++ b/libwebauthn/src/ops/webauthn/related_origins/http.rs @@ -0,0 +1,109 @@ +//! reqwest-backed [`RelatedOriginsHttpClient`]. Gated by the +//! `related-origins-client` cargo feature. + +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use reqwest::header::{HeaderMap, HeaderValue, REFERER}; +use reqwest::redirect::Policy; +use reqwest::{Client, StatusCode}; + +use super::{RelatedOriginsError, RelatedOriginsHttpClient, WellKnownResponse}; +use crate::ops::webauthn::idl::rpid::RelyingPartyId; + +#[derive(Debug, Clone)] +pub struct HttpPolicy { + pub request_timeout: Duration, + pub max_body_bytes: usize, + pub max_redirects: usize, +} + +impl Default for HttpPolicy { + fn default() -> Self { + Self { + request_timeout: Duration::from_secs(10), + max_body_bytes: 256 * 1024, + max_redirects: 5, + } + } +} + +#[derive(Debug, Clone)] +pub struct ReqwestRelatedOriginsClient { + client: Client, + max_body_bytes: usize, +} + +impl ReqwestRelatedOriginsClient { + pub fn new() -> Result { + Self::with_policy(HttpPolicy::default()) + } + + pub fn with_policy(policy: HttpPolicy) -> Result { + let max_redirects = policy.max_redirects; + let redirect_policy = Policy::custom(move |attempt| { + if attempt.previous().len() >= max_redirects { + return attempt.error("redirect limit exceeded"); + } + if attempt.url().scheme() != "https" { + return attempt.error("non-https redirect"); + } + attempt.follow() + }); + let mut default_headers = HeaderMap::new(); + default_headers.insert(REFERER, HeaderValue::from_static("")); + // `cookies` feature off, so reqwest holds no cookie jar to disable. + let client = Client::builder() + .https_only(true) + .redirect(redirect_policy) + .timeout(policy.request_timeout) + .default_headers(default_headers) + .build() + .map_err(|e| RelatedOriginsError::FetchFailed(e.to_string()))?; + Ok(Self { + client, + max_body_bytes: policy.max_body_bytes, + }) + } +} + +#[async_trait] +impl RelatedOriginsHttpClient for ReqwestRelatedOriginsClient { + async fn fetch_well_known( + &self, + rp_id: &RelyingPartyId, + ) -> Result { + let url = format!("https://{}/.well-known/webauthn", rp_id.0); + let response = self + .client + .get(&url) + .send() + .await + .map_err(|e| RelatedOriginsError::FetchFailed(e.to_string()))?; + if response.status() != StatusCode::OK { + return Err(RelatedOriginsError::FetchFailed(format!( + "status {}", + response.status() + ))); + } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + + let mut body = Vec::with_capacity(8 * 1024); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| RelatedOriginsError::FetchFailed(e.to_string()))?; + if body.len() + chunk.len() > self.max_body_bytes { + return Err(RelatedOriginsError::FetchFailed( + "body exceeded size cap".into(), + )); + } + body.extend_from_slice(&chunk); + } + Ok(WellKnownResponse { content_type, body }) + } +} From 129d38f9be8577bbb5988e629e65cf1210631484 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:39:03 +0100 Subject: [PATCH 04/29] refactor(webauthn): make FromIdlModel async with related-origins client arg Adds the http parameter to FromIdlModel::from_idl_model and the default WebAuthnIDL::from_json; updates the make_credential and get_assertion impls to take it (currently unused, wired in next commit). Updates ceremony examples and existing from_json tests to pass &NoRelatedOriginsClient and .await the calls. --- Cargo.lock | 391 ++++++++++++++++++ libwebauthn/examples/ceremony/webauthn_ble.rs | 25 +- .../examples/ceremony/webauthn_cable.rs | 14 +- .../examples/ceremony/webauthn_cable_wss.rs | 25 +- libwebauthn/examples/ceremony/webauthn_hid.rs | 26 +- libwebauthn/examples/ceremony/webauthn_nfc.rs | 8 +- libwebauthn/src/ops/webauthn/get_assertion.rs | 197 ++++++--- libwebauthn/src/ops/webauthn/idl/mod.rs | 29 +- .../src/ops/webauthn/make_credential.rs | 266 ++++++++---- .../src/ops/webauthn/related_origins/mod.rs | 9 +- 10 files changed, 788 insertions(+), 202 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e3fcfbfd..c35ac071 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,6 +200,12 @@ dependencies = [ "critical-section", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" @@ -1026,6 +1032,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "env_filter" version = "1.0.1" @@ -1159,6 +1174,12 @@ dependencies = [ "synstructure 0.13.2", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1302,8 +1323,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1313,9 +1336,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1369,6 +1394,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1504,12 +1548,95 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1662,6 +1789,12 @@ dependencies = [ "loom", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1845,6 +1978,7 @@ dependencies = [ "publicsuffix", "qrcode", "rand 0.8.6", + "reqwest", "rustls", "serde", "serde-indexed 0.2.0", @@ -1970,6 +2104,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "macaddr" version = "1.0.1" @@ -1997,6 +2137,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2542,6 +2688,61 @@ dependencies = [ "image", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -2666,6 +2867,50 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -2765,6 +3010,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -2785,6 +3031,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "salty" version = "0.3.0" @@ -2988,6 +3240,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serdect" version = "0.2.0" @@ -3210,6 +3474,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.12.6" @@ -3386,6 +3659,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -3495,6 +3783,51 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -3660,6 +3993,12 @@ dependencies = [ "trussed-hkdf", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.26.2" @@ -3783,6 +4122,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3820,6 +4168,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.121" @@ -3874,6 +4232,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -3886,6 +4257,26 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/libwebauthn/examples/ceremony/webauthn_ble.rs b/libwebauthn/examples/ceremony/webauthn_ble.rs index f46fbc01..14681548 100644 --- a/libwebauthn/examples/ceremony/webauthn_ble.rs +++ b/libwebauthn/examples/ceremony/webauthn_ble.rs @@ -1,8 +1,8 @@ use std::error::Error; use libwebauthn::ops::webauthn::{ - DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, - WebAuthnIDL as _, WebAuthnIDLResponse as _, + DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, + NoRelatedOriginsClient, RequestOrigin, WebAuthnIDL as _, WebAuthnIDLResponse as _, }; use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; use libwebauthn::transport::ble::list_devices; @@ -53,8 +53,14 @@ pub async fn main() -> Result<(), Box> { } "#; let make_credentials_request: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &psl, request_json) - .expect("Failed to parse request JSON"); + MakeCredentialRequest::from_json( + &request_origin, + &psl, + &NoRelatedOriginsClient, + request_json, + ) + .await + .expect("Failed to parse request JSON"); println!( "WebAuthn MakeCredential request: {:?}", make_credentials_request @@ -96,9 +102,14 @@ pub async fn main() -> Result<(), Box> { }} "# ); - let get_assertion: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &psl, &request_json) - .expect("Failed to parse request JSON"); + let get_assertion: GetAssertionRequest = GetAssertionRequest::from_json( + &request_origin, + &psl, + &NoRelatedOriginsClient, + &request_json, + ) + .await + .expect("Failed to parse request JSON"); println!("WebAuthn GetAssertion request: {:?}", get_assertion); let response = retry_user_errors!(channel.webauthn_get_assertion(&get_assertion)).unwrap(); diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index 779406b4..46aa04dd 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -11,8 +11,8 @@ use qrcode::render::unicode; use qrcode::QrCode; use libwebauthn::ops::webauthn::{ - DatFilePublicSuffixList, JsonFormat, MakeCredentialRequest, RequestOrigin, WebAuthnIDL as _, - WebAuthnIDLResponse as _, + DatFilePublicSuffixList, JsonFormat, MakeCredentialRequest, NoRelatedOriginsClient, + RequestOrigin, WebAuthnIDL as _, WebAuthnIDLResponse as _, }; use libwebauthn::transport::{Channel as _, Device}; use libwebauthn::webauthn::WebAuthn; @@ -79,8 +79,14 @@ pub async fn main() -> Result<(), Box> { let state_recv = channel.get_ux_update_receiver(); tokio::spawn(common::handle_cable_updates(state_recv)); - let request = MakeCredentialRequest::from_json(&request_origin, &psl, MAKE_CREDENTIAL_REQUEST) - .expect("Failed to parse request JSON"); + let request = MakeCredentialRequest::from_json( + &request_origin, + &psl, + &NoRelatedOriginsClient, + MAKE_CREDENTIAL_REQUEST, + ) + .await + .expect("Failed to parse request JSON"); let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); let response_json = response diff --git a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs index fc3c40c2..a35f5faf 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs @@ -14,8 +14,8 @@ use qrcode::QrCode; use tokio::time::sleep; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, SystemPublicSuffixList, - WebAuthnIDL as _, WebAuthnIDLResponse as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, NoRelatedOriginsClient, RequestOrigin, + SystemPublicSuffixList, WebAuthnIDL as _, WebAuthnIDLResponse as _, }; use libwebauthn::transport::cable::channel::CableChannel; use libwebauthn::transport::{Channel as _, Device}; @@ -95,9 +95,14 @@ pub async fn main() -> Result<(), Box> { let state_recv = channel.get_ux_update_receiver(); tokio::spawn(common::handle_cable_updates(state_recv)); - let request = - MakeCredentialRequest::from_json(&request_origin, &psl, MAKE_CREDENTIAL_REQUEST) - .expect("Failed to parse request JSON"); + let request = MakeCredentialRequest::from_json( + &request_origin, + &psl, + &NoRelatedOriginsClient, + MAKE_CREDENTIAL_REQUEST, + ) + .await + .expect("Failed to parse request JSON"); let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); let response_json = response @@ -155,8 +160,14 @@ async fn run_get_assertion( let state_recv = channel.get_ux_update_receiver(); tokio::spawn(common::handle_cable_updates(state_recv)); - let request = GetAssertionRequest::from_json(request_origin, psl, GET_ASSERTION_REQUEST) - .expect("Failed to parse request JSON"); + let request = GetAssertionRequest::from_json( + request_origin, + psl, + &NoRelatedOriginsClient, + GET_ASSERTION_REQUEST, + ) + .await + .expect("Failed to parse request JSON"); let response = retry_user_errors!(channel.webauthn_get_assertion(&request)).unwrap(); for assertion in &response.assertions { let assertion_json = assertion diff --git a/libwebauthn/examples/ceremony/webauthn_hid.rs b/libwebauthn/examples/ceremony/webauthn_hid.rs index aa03a04b..18e0d139 100644 --- a/libwebauthn/examples/ceremony/webauthn_hid.rs +++ b/libwebauthn/examples/ceremony/webauthn_hid.rs @@ -2,8 +2,8 @@ use std::error::Error; use std::time::Duration; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, SystemPublicSuffixList, - WebAuthnIDL as _, WebAuthnIDLResponse as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, NoRelatedOriginsClient, RequestOrigin, + SystemPublicSuffixList, WebAuthnIDL as _, WebAuthnIDLResponse as _, }; use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; use libwebauthn::transport::hid::list_devices; @@ -56,9 +56,14 @@ pub async fn main() -> Result<(), Box> { "attestation": "none" } "#; - let make_credentials_request: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &psl, request_json) - .expect("Failed to parse request JSON"); + let make_credentials_request: MakeCredentialRequest = MakeCredentialRequest::from_json( + &request_origin, + &psl, + &NoRelatedOriginsClient, + request_json, + ) + .await + .expect("Failed to parse request JSON"); println!( "WebAuthn MakeCredential request: {:?}", make_credentials_request @@ -100,9 +105,14 @@ pub async fn main() -> Result<(), Box> { }} "# ); - let get_assertion: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &psl, &request_json) - .expect("Failed to parse request JSON"); + let get_assertion: GetAssertionRequest = GetAssertionRequest::from_json( + &request_origin, + &psl, + &NoRelatedOriginsClient, + &request_json, + ) + .await + .expect("Failed to parse request JSON"); println!("WebAuthn GetAssertion request: {:?}", get_assertion); let response = retry_user_errors!(channel.webauthn_get_assertion(&get_assertion)).unwrap(); diff --git a/libwebauthn/examples/ceremony/webauthn_nfc.rs b/libwebauthn/examples/ceremony/webauthn_nfc.rs index 6c58fdb9..5f4ebd03 100644 --- a/libwebauthn/examples/ceremony/webauthn_nfc.rs +++ b/libwebauthn/examples/ceremony/webauthn_nfc.rs @@ -1,8 +1,8 @@ use std::error::Error; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, SystemPublicSuffixList, - WebAuthnIDL as _, WebAuthnIDLResponse as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, NoRelatedOriginsClient, RequestOrigin, + SystemPublicSuffixList, WebAuthnIDL as _, WebAuthnIDLResponse as _, }; use libwebauthn::transport::nfc::{get_nfc_device, is_nfc_available}; use libwebauthn::transport::{Channel as _, Device}; @@ -33,6 +33,7 @@ pub async fn main() -> Result<(), Box> { let make_credentials_request = MakeCredentialRequest::from_json( &request_origin, &psl, + &NoRelatedOriginsClient, r#" { "rp": { @@ -58,6 +59,7 @@ pub async fn main() -> Result<(), Box> { } "#, ) + .await .expect("Failed to parse request JSON"); println!( "WebAuthn MakeCredential request: {:?}", @@ -77,6 +79,7 @@ pub async fn main() -> Result<(), Box> { let get_assertion = GetAssertionRequest::from_json( &request_origin, &psl, + &NoRelatedOriginsClient, r#" { "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu", @@ -86,6 +89,7 @@ pub async fn main() -> Result<(), Box> { } "#, ) + .await .expect("Failed to parse request JSON"); println!("WebAuthn GetAssertion request: {:?}", get_assertion); diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 1fcbf7d0..5d217144 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, time::Duration}; +use async_trait::async_trait; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tracing::{debug, error, trace}; @@ -22,6 +23,7 @@ use crate::{ Base64UrlString, FromIdlModel, JsonError, }, psl::PublicSuffixList, + related_origins::RelatedOriginsHttpClient, Operation, WebAuthnIDL, }, pin::PinUvAuthProtocol, @@ -164,12 +166,14 @@ impl WebAuthnIDL for GetAssertionRequest { sequence hints = []; AuthenticationExtensionsClientInputsJSON extensions; }; */ +#[async_trait] impl FromIdlModel for GetAssertionRequest { - fn from_idl_model( + async fn from_idl_model( request_origin: &RequestOrigin, psl: &dyn PublicSuffixList, + _http: &dyn RelatedOriginsHttpClient, inner: PublicKeyCredentialRequestOptionsJSON, ) -> Result { let effective_rp_id = request_origin.origin.host.as_str(); @@ -652,7 +656,7 @@ mod tests { use serde_bytes::ByteBuf; use crate::ops::webauthn::psl::MockPublicSuffixList; - use crate::ops::webauthn::{GetAssertionRequest, RequestOrigin}; + use crate::ops::webauthn::{GetAssertionRequest, NoRelatedOriginsClient, RequestOrigin}; use crate::proto::ctap2::Ctap2PublicKeyCredentialType; use super::*; @@ -703,49 +707,66 @@ mod tests { serde_json::to_string(&v).unwrap() } - #[test] - fn test_request_from_json_base() { + #[tokio::test] + async fn test_request_from_json_base() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req: GetAssertionRequest = GetAssertionRequest::from_json( &request_origin, &MockPublicSuffixList, + &NoRelatedOriginsClient, REQUEST_BASE_JSON, ) + .await .unwrap(); assert_eq!(req, request_base()); } - #[test] - fn test_request_from_json_ignore_missing_rp_id() { + #[tokio::test] + async fn test_request_from_json_ignore_missing_rp_id() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_rm(REQUEST_BASE_JSON, "rpId"); - let req: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: GetAssertionRequest = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); assert_eq!(req, request_base()); } - #[test] - fn test_request_from_json_invalid_rp_id() { + #[tokio::test] + async fn test_request_from_json_invalid_rp_id() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.org.""#); - let result = - GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + let result = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await; assert!(matches!( result, Err(GetAssertionRequestParsingError::InvalidRelyingPartyId(_)) )); } - #[test] - fn test_request_from_json_mismatching_rp_id() { + #[tokio::test] + async fn test_request_from_json_mismatching_rp_id() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""other.example.org""#); - let result = - GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + let result = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await; assert!(matches!( result, Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( @@ -755,26 +776,37 @@ mod tests { )); } - #[test] - fn test_request_from_json_rp_id_is_parent_registrable_suffix() { + #[tokio::test] + async fn test_request_from_json_rp_id_is_parent_registrable_suffix() { // origin = login.example.org, rp.id = example.org -> accepted. let request_origin: RequestOrigin = "https://login.example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.org""#); - let req = GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); assert_eq!(req.relying_party_id, "example.org"); assert_eq!(req.origin, "https://login.example.org"); } - #[test] - fn test_request_from_json_rp_id_is_etld_rejected() { + #[tokio::test] + async fn test_request_from_json_rp_id_is_etld_rejected() { // origin = example.co.uk, rp.id = co.uk (a public suffix) -> rejected. let request_origin: RequestOrigin = "https://example.co.uk".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""co.uk""#); - let result = - GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + let result = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await; assert!(matches!( result, Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( @@ -784,14 +816,19 @@ mod tests { )); } - #[test] - fn test_request_from_json_ignore_missing_allow_credentials() { + #[tokio::test] + async fn test_request_from_json_ignore_missing_allow_credentials() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_rm(REQUEST_BASE_JSON, "allowCredentials"); - let req: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: GetAssertionRequest = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); assert_eq!( req, GetAssertionRequest { @@ -801,36 +838,46 @@ mod tests { ); } - #[test] - fn test_request_from_json_default_timeout() { + #[tokio::test] + async fn test_request_from_json_default_timeout() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_rm(REQUEST_BASE_JSON, "timeout"); - let req: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: GetAssertionRequest = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); assert_eq!(req.timeout, DEFAULT_TIMEOUT); } - #[test] - fn test_request_from_json_empty_extensions() { + #[tokio::test] + async fn test_request_from_json_empty_extensions() { // Test that "extensions": {} results in Some(default) not None // This is important for strict portals that distinguish between // no extensions key vs empty extensions object let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", r#"{}"#); - let req: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: GetAssertionRequest = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); assert_eq!( req.extensions, Some(GetAssertionRequestExtensions::default()) ); } - #[test] - fn test_request_from_json_appid_extension() { + #[tokio::test] + async fn test_request_from_json_appid_extension() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add( REQUEST_BASE_JSON, @@ -838,9 +885,14 @@ mod tests { r#"{"appid":"https://www.example.org/u2f/origins.json"}"#, ); - let req: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: GetAssertionRequest = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); let ext = req.extensions.expect("extensions should be present"); assert_eq!( ext.appid.as_deref(), @@ -848,8 +900,8 @@ mod tests { ); } - #[test] - fn test_request_from_json_appid_extension_invalid_non_https() { + #[tokio::test] + async fn test_request_from_json_appid_extension_invalid_non_https() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add( REQUEST_BASE_JSON, @@ -857,7 +909,13 @@ mod tests { r#"{"appid":"http://www.example.org/u2f/origins.json"}"#, ); - let res = GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + let res = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await; assert!(matches!( res, Err(GetAssertionRequestParsingError::InvalidAppId(_)) @@ -909,34 +967,40 @@ mod tests { assert_eq!(sign_requests[0].app_id_hash, rp_hash); } - fn parse_prf(extensions_json: &str) -> PrfInput { + async fn parse_prf(extensions_json: &str) -> PrfInput { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", extensions_json); - let req = GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .expect("request should parse"); + let req = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .expect("request should parse"); req.extensions .expect("extensions") .prf .expect("prf extension") } - #[test] - fn test_request_from_json_prf_extension() { + #[tokio::test] + async fn test_request_from_json_prf_extension() { // Non-32-byte inputs must now parse (W3C WebAuthn L3 §10.1.4). "AQID" // decodes to 0x010203, "BAUG" to 0x040506. - let prf = parse_prf(r#"{"prf":{"eval":{"first":"AQID","second":"BAUG"}}}"#); + let prf = parse_prf(r#"{"prf":{"eval":{"first":"AQID","second":"BAUG"}}}"#).await; let eval = prf.eval.expect("eval"); assert_eq!(eval.first, vec![0x01, 0x02, 0x03]); assert_eq!(eval.second.as_deref(), Some(&[0x04u8, 0x05, 0x06][..])); } - #[test] - fn test_prf_input_variable_length() { + #[tokio::test] + async fn test_prf_input_variable_length() { // W3C WebAuthn L3 §10.1.4: PRF inputs are BufferSources of any length. for len in [1usize, 16, 31, 33, 64, 256] { let bytes = vec![0xABu8; len]; let b64 = base64_url::encode(&bytes); - let prf = parse_prf(&format!(r#"{{"prf":{{"eval":{{"first":"{b64}"}}}}}}"#)); + let prf = parse_prf(&format!(r#"{{"prf":{{"eval":{{"first":"{b64}"}}}}}}"#)).await; let eval = prf.eval.unwrap(); assert_eq!(eval.first.len(), len, "len {len}"); assert_eq!(eval.first, bytes, "len {len}"); @@ -944,34 +1008,35 @@ mod tests { } } - #[test] - fn test_prf_input_short_via_json() { + #[tokio::test] + async fn test_prf_input_short_via_json() { // Regression test for #209: a sub-32-byte salt encoded in the JSON IDL // must round-trip through GetAssertionRequest::from_json into a // PrfInputValue with the expected bytes. - let prf = parse_prf(r#"{"prf":{"eval":{"first":"aGk"}}}"#); // base64url "aGk" -> b"hi" + let prf = parse_prf(r#"{"prf":{"eval":{"first":"aGk"}}}"#).await; // base64url "aGk" -> b"hi" let eval = prf.eval.expect("eval"); assert_eq!(eval.first, b"hi"); assert!(eval.second.is_none()); } - #[test] - fn test_prf_input_empty_allowed() { + #[tokio::test] + async fn test_prf_input_empty_allowed() { // §10.1.4 says "of any length" with no lower bound; empty must parse. - let prf = parse_prf(r#"{"prf":{"eval":{"first":""}}}"#); + let prf = parse_prf(r#"{"prf":{"eval":{"first":""}}}"#).await; let eval = prf.eval.unwrap(); assert!(eval.first.is_empty()); assert!(eval.second.is_none()); } - #[test] - fn test_prf_eval_by_credential_variable_length() { + #[tokio::test] + async fn test_prf_eval_by_credential_variable_length() { // NOTE: the IDL field is currently deserialized as `eval_by_credential` // rather than the spec name `evalByCredential` — separate concern from // #209. Use the field name the deserializer accepts. let prf = parse_prf( r#"{"prf":{"eval_by_credential":{"Y3JlZDE":{"first":"AQ","second":"AgIC"}}}}"#, - ); + ) + .await; let v = prf.eval_by_credential.get("Y3JlZDE").expect("entry"); assert_eq!(v.first, vec![0x01]); assert_eq!(v.second.as_deref(), Some(&[0x02u8, 0x02, 0x02][..])); diff --git a/libwebauthn/src/ops/webauthn/idl/mod.rs b/libwebauthn/src/ops/webauthn/idl/mod.rs index 49eb8377..7a366f09 100644 --- a/libwebauthn/src/ops/webauthn/idl/mod.rs +++ b/libwebauthn/src/ops/webauthn/idl/mod.rs @@ -14,45 +14,48 @@ pub use response::{ WebAuthnIDLResponse, }; +use async_trait::async_trait; use origin::RequestOrigin; - -use super::psl::PublicSuffixList; - use serde::de::DeserializeOwned; use serde_json; +use super::psl::PublicSuffixList; +use super::related_origins::RelatedOriginsHttpClient; + pub type JsonError = serde_json::Error; +#[async_trait] pub trait WebAuthnIDL: Sized where - E: std::error::Error, // Validation error type. + E: std::error::Error, Self: FromIdlModel, { - /// An error type that can be returned when deserializing from JSON, including - /// JSON parsing errors and any additional validation errors. type Error: std::error::Error + From + From; + type IdlModel: DeserializeOwned + Send; - /// The JSON model that this IDL can deserialize from. - type IdlModel: DeserializeOwned; - - fn from_json( + async fn from_json( request_origin: &RequestOrigin, psl: &dyn PublicSuffixList, + http: &dyn RelatedOriginsHttpClient, json: &str, ) -> Result { let idl_model: Self::IdlModel = serde_json::from_str(json)?; - Self::from_idl_model(request_origin, psl, idl_model).map_err(From::from) + Self::from_idl_model(request_origin, psl, http, idl_model) + .await + .map_err(From::from) } } +#[async_trait] pub trait FromIdlModel: Sized where - T: DeserializeOwned, + T: DeserializeOwned + Send, E: std::error::Error, { - fn from_idl_model( + async fn from_idl_model( request_origin: &RequestOrigin, psl: &dyn PublicSuffixList, + http: &dyn RelatedOriginsHttpClient, model: T, ) -> Result; } diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 0601a9a6..e8a3032b 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use async_trait::async_trait; use ctap_types::ctap2::credential_management::CredentialProtectionPolicy as Ctap2CredentialProtectionPolicy; use serde::{Deserialize, Deserializer, Serialize}; use sha2::{Digest, Sha256}; @@ -21,6 +22,7 @@ use crate::{ Base64UrlString, FromIdlModel, JsonError, WebAuthnIDL, }, psl::PublicSuffixList, + related_origins::RelatedOriginsHttpClient, Operation, PrfInputValue, PrfOutputValue, RelyingPartyId, RequestOrigin, }, proto::{ @@ -365,12 +367,14 @@ impl MakeCredentialRequest { } } +#[async_trait] impl FromIdlModel for MakeCredentialRequest { - fn from_idl_model( + async fn from_idl_model( request_origin: &RequestOrigin, psl: &dyn PublicSuffixList, + _http: &dyn RelatedOriginsHttpClient, inner: PublicKeyCredentialCreationOptionsJSON, ) -> Result { let effective_rp_id = request_origin.origin.host.as_str(); @@ -692,7 +696,7 @@ mod tests { use std::time::Duration; use crate::ops::webauthn::psl::MockPublicSuffixList; - use crate::ops::webauthn::{MakeCredentialRequest, RequestOrigin}; + use crate::ops::webauthn::{MakeCredentialRequest, NoRelatedOriginsClient, RequestOrigin}; use crate::proto::ctap2::Ctap2PublicKeyCredentialType; use super::*; @@ -756,76 +760,93 @@ mod tests { serde_json::to_string(&v).unwrap() } - fn test_request_from_json_required_field(field: &str) { + async fn test_request_from_json_required_field(field: &str) { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_rm(REQUEST_BASE_JSON, field); - let result = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + let result = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await; assert!(matches!( result, Err(MakeCredentialRequestParsingError::EncodingError(_)) )); } - #[test] - fn test_request_from_json_base() { + #[tokio::test] + async fn test_request_from_json_base() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req: MakeCredentialRequest = MakeCredentialRequest::from_json( &request_origin, &MockPublicSuffixList, + &NoRelatedOriginsClient, REQUEST_BASE_JSON, ) + .await .unwrap(); assert_eq!(req, request_base()); } - #[test] - fn test_request_from_json_require_rp() { - test_request_from_json_required_field("rp"); + #[tokio::test] + async fn test_request_from_json_require_rp() { + test_request_from_json_required_field("rp").await; } - #[test] - fn test_request_from_json_require_user() { - test_request_from_json_required_field("user"); + #[tokio::test] + async fn test_request_from_json_require_user() { + test_request_from_json_required_field("user").await; } - #[test] - fn test_request_from_json_require_pub_key_cred_params() { - test_request_from_json_required_field("pubKeyCredParams"); + #[tokio::test] + async fn test_request_from_json_require_pub_key_cred_params() { + test_request_from_json_required_field("pubKeyCredParams").await; } - #[test] - fn test_request_from_json_require_challenge() { - test_request_from_json_required_field("challenge"); + #[tokio::test] + async fn test_request_from_json_require_challenge() { + test_request_from_json_required_field("challenge").await; } - #[test] + #[tokio::test] #[ignore] // FIXME(#134): Add validation for challenges - fn test_request_from_json_challenge_empty() { + async fn test_request_from_json_challenge_empty() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json: String = json_field_rm(REQUEST_BASE_JSON, "challenge"); let req_json = json_field_add(&req_json, "challenge", r#""""#); - let result = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + let result = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await; assert!(matches!( result, Err(MakeCredentialRequestParsingError::EncodingError(_)) )); } - #[test] - fn test_request_from_json_prf_extension() { + #[tokio::test] + async fn test_request_from_json_prf_extension() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let first = base64_url::encode(&[1u8; 32]); let second = base64_url::encode(&[2u8; 32]); let ext = format!(r#"{{"prf": {{"eval": {{"first": "{first}", "second": "{second}"}}}}}}"#); let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext); - let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); let prf = req .extensions .as_ref() @@ -836,29 +857,39 @@ mod tests { assert_eq!(prf.second, Some(vec![2u8; 32])); } - #[test] - fn test_request_from_json_prf_extension_empty() { + #[tokio::test] + async fn test_request_from_json_prf_extension_empty() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", r#"{"prf": {}}"#); - let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); let prf = req.extensions.unwrap().prf.unwrap(); assert!(prf.eval.is_none()); } - #[test] - fn test_request_from_json_prf_extension_short_input() { + #[tokio::test] + async fn test_request_from_json_prf_extension_short_input() { // WebAuthn L3 §10.1.4: PRF salt inputs are BufferSources of any length. let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let short = base64_url::encode(&[0u8; 16]); let ext = format!(r#"{{"prf": {{"eval": {{"first": "{short}"}}}}}}"#); let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext); - let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); let prf = req .extensions .as_ref() @@ -869,8 +900,8 @@ mod tests { assert!(prf.second.is_none()); } - #[test] - fn test_request_from_json_appid_exclude_extension() { + #[tokio::test] + async fn test_request_from_json_appid_exclude_extension() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add( REQUEST_BASE_JSON, @@ -878,9 +909,14 @@ mod tests { r#"{"appidExclude": "https://www.example.org/u2f/origins.json"}"#, ); - let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); let ext = req.extensions.expect("extensions should be present"); assert_eq!( ext.appid_exclude.as_deref(), @@ -888,17 +924,22 @@ mod tests { ); } - #[test] - fn test_request_from_json_unknown_pub_key_cred_params() { + #[tokio::test] + async fn test_request_from_json_unknown_pub_key_cred_params() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add( REQUEST_BASE_JSON, "pubKeyCredParams", r#"[{"type": "something", "alg": -12345}]"#, ); - let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); assert_eq!( req.algorithms, vec![Ctap2CredentialType { @@ -908,27 +949,37 @@ mod tests { ); } - #[test] - fn test_request_from_json_default_timeout() { + #[tokio::test] + async fn test_request_from_json_default_timeout() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_rm(REQUEST_BASE_JSON, "timeout"); - let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); assert_eq!(req.timeout, DEFAULT_TIMEOUT); } /// Per spec, when authenticatorSelection is missing, userVerification should default to "preferred". /// https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-userverification - #[test] - fn test_request_from_json_default_user_verification_preferred() { + #[tokio::test] + async fn test_request_from_json_default_user_verification_preferred() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_rm(REQUEST_BASE_JSON, "authenticatorSelection"); - let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); assert_eq!( req.user_verification, UserVerificationRequirement::Preferred @@ -937,8 +988,8 @@ mod tests { /// Per spec, when userVerification is missing inside authenticatorSelection, /// it should default to "preferred". - #[test] - fn test_request_from_json_missing_user_verification_in_authenticator_selection() { + #[tokio::test] + async fn test_request_from_json_missing_user_verification_in_authenticator_selection() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); // Replace authenticatorSelection with one that has no userVerification field let mut req_json = json_field_rm(REQUEST_BASE_JSON, "authenticatorSelection"); @@ -948,17 +999,22 @@ mod tests { r#"{"residentKey": "discouraged"}"#, ); - let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); assert_eq!( req.user_verification, UserVerificationRequirement::Preferred ); } - #[test] - fn test_request_from_json_invalid_rp_id() { + #[tokio::test] + async fn test_request_from_json_invalid_rp_id() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add( REQUEST_BASE_JSON, @@ -966,16 +1022,21 @@ mod tests { r#"{"id": "example.org.", "name": "example.org"}"#, ); - let result = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + let result = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await; assert!(matches!( result, Err(MakeCredentialRequestParsingError::InvalidRelyingPartyId(_)) )); } - #[test] - fn test_request_from_json_mismatching_rp_id() { + #[tokio::test] + async fn test_request_from_json_mismatching_rp_id() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add( REQUEST_BASE_JSON, @@ -983,16 +1044,21 @@ mod tests { r#"{"id": "other.example.org", "name": "example.org"}"#, ); - let result = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + let result = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await; assert!(matches!( result, Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _)) )); } - #[test] - fn test_request_from_json_rp_id_is_parent_registrable_suffix() { + #[tokio::test] + async fn test_request_from_json_rp_id_is_parent_registrable_suffix() { let request_origin: RequestOrigin = "https://login.example.org".parse().unwrap(); let req_json = json_field_add( REQUEST_BASE_JSON, @@ -1000,15 +1066,20 @@ mod tests { r#"{"id": "example.org", "name": "example.org"}"#, ); - let req = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); assert_eq!(req.relying_party.id, "example.org"); assert_eq!(req.origin, "https://login.example.org"); } - #[test] - fn test_request_from_json_rp_id_is_etld_rejected() { + #[tokio::test] + async fn test_request_from_json_rp_id_is_etld_rejected() { let request_origin: RequestOrigin = "https://example.co.uk".parse().unwrap(); let req_json = json_field_add( REQUEST_BASE_JSON, @@ -1016,16 +1087,21 @@ mod tests { r#"{"id": "co.uk", "name": "co.uk"}"#, ); - let result = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + let result = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await; assert!(matches!( result, Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _)) )); } - #[test] - fn test_request_from_json_http_localhost_accepted() { + #[tokio::test] + async fn test_request_from_json_http_localhost_accepted() { let request_origin: RequestOrigin = "http://localhost".parse().unwrap(); let req_json = json_field_add( REQUEST_BASE_JSON, @@ -1033,15 +1109,20 @@ mod tests { r#"{"id": "localhost", "name": "localhost"}"#, ); - let req = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); assert_eq!(req.relying_party.id, "localhost"); assert_eq!(req.origin, "http://localhost"); } - #[test] - fn test_request_from_json_http_localhost_with_port_accepted() { + #[tokio::test] + async fn test_request_from_json_http_localhost_with_port_accepted() { let request_origin: RequestOrigin = "http://localhost:3000".parse().unwrap(); let req_json = json_field_add( REQUEST_BASE_JSON, @@ -1049,9 +1130,14 @@ mod tests { r#"{"id": "localhost", "name": "localhost"}"#, ); - let req = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &NoRelatedOriginsClient, + &req_json, + ) + .await + .unwrap(); assert_eq!(req.relying_party.id, "localhost"); assert_eq!(req.origin, "http://localhost:3000"); } diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs index 3ccf18d2..a99047e9 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/mod.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -75,7 +75,9 @@ pub async fn validate_related_origins( .map(is_application_json) .unwrap_or(false); if !content_type_ok { - return Err(RelatedOriginsError::UnexpectedContentType(resp.content_type)); + return Err(RelatedOriginsError::UnexpectedContentType( + resp.content_type, + )); } let doc: WellKnownDocument = serde_json::from_slice(&resp.body) @@ -109,10 +111,7 @@ pub async fn validate_related_origins( /// First label of `host`'s registrable domain (eTLD+1), or `None` when the host /// has no registrable domain (e.g. bare eTLD, IP literal, unknown TLD). -pub(crate) fn registrable_origin_label( - host: &str, - psl: &dyn PublicSuffixList, -) -> Option { +pub(crate) fn registrable_origin_label(host: &str, psl: &dyn PublicSuffixList) -> Option { let registrable = psl.registrable_domain(host)?; let label = registrable.split('.').next()?; if label.is_empty() { From bd140b8ffed5aa6ea4d4aa77d87996d456c43085 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:40:18 +0100 Subject: [PATCH 05/29] feat(webauthn): wire related-origins fallback into make_credential and get_assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the rp.id is not a registrable suffix of the caller's effective domain, call validate_related_origins() per WebAuthn L3 §5.11.1. On success, accept the request; on failure, surface the existing MismatchingRelyingPartyId variant unchanged so callers' pattern matches keep working. --- libwebauthn/src/ops/webauthn/get_assertion.rs | 20 +++++++++------- .../src/ops/webauthn/make_credential.rs | 24 +++++++++++-------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 5d217144..dcd49b8f 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, time::Duration}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use tracing::{debug, error, trace}; +use tracing::{debug, error, trace, warn}; use crate::{ fido::AuthenticatorData, @@ -23,7 +23,7 @@ use crate::{ Base64UrlString, FromIdlModel, JsonError, }, psl::PublicSuffixList, - related_origins::RelatedOriginsHttpClient, + related_origins::{validate_related_origins, RelatedOriginsHttpClient}, Operation, WebAuthnIDL, }, pin::PinUvAuthProtocol, @@ -173,7 +173,7 @@ impl FromIdlModel Result { let effective_rp_id = request_origin.origin.host.as_str(); @@ -181,12 +181,16 @@ impl FromIdlModel Result { let effective_rp_id = request_origin.origin.host.as_str(); let rp_id = RelyingPartyId::try_from(inner.rp.id.as_str()).map_err(|err| { MakeCredentialRequestParsingError::InvalidRelyingPartyId(err.to_string()) })?; - // TODO(#160): Add related-origins fallback per WebAuthn L3 §5.11. if !is_registrable_domain_suffix_or_equal(&rp_id.0, effective_rp_id, psl) { - return Err( - MakeCredentialRequestParsingError::MismatchingRelyingPartyId( - rp_id.0, - effective_rp_id.to_string(), - ), - ); + if let Err(err) = + validate_related_origins(&request_origin.origin, &rp_id, psl, http).await + { + warn!(rp_id = %rp_id.0, error = ?err, "Related-origins validation failed"); + return Err( + MakeCredentialRequestParsingError::MismatchingRelyingPartyId( + rp_id.0, + effective_rp_id.to_string(), + ), + ); + } } let mut relying_party = inner.rp; relying_party.id = rp_id.0; From 913a744d91b06ba193254098da8e5d35f8d9e451 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:42:36 +0100 Subject: [PATCH 06/29] feat(webauthn): distinguish step 2.b and 2.c errors in related-origins validator --- libwebauthn/src/ops/webauthn/related_origins/mod.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs index a99047e9..0186600a 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/mod.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -44,8 +44,10 @@ pub enum RelatedOriginsError { FetchFailed(String), #[error("unexpected content type: {0:?}")] UnexpectedContentType(Option), + /// Step 2.b: body did not decode as JSON. #[error("malformed JSON body: {0}")] MalformedJson(String), + /// Step 2.c: top-level `origins` was missing or not an array of strings. #[error("malformed well-known document: {0}")] MalformedDocument(String), #[error("no listed related origin matches the caller origin")] @@ -80,8 +82,15 @@ pub async fn validate_related_origins( )); } - let doc: WellKnownDocument = serde_json::from_slice(&resp.body) + let value: serde_json::Value = serde_json::from_slice(&resp.body) .map_err(|e| RelatedOriginsError::MalformedJson(e.to_string()))?; + if !value.is_object() { + return Err(RelatedOriginsError::MalformedJson( + "top-level value is not a JSON object".into(), + )); + } + let doc: WellKnownDocument = serde_json::from_value(value) + .map_err(|e| RelatedOriginsError::MalformedDocument(e.to_string()))?; let mut labels_seen: BTreeSet = BTreeSet::new(); for origin_item in &doc.origins { From 384c548f4d5eab76a79c5549b8ac06b10e148471 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:49:07 +0100 Subject: [PATCH 07/29] test(webauthn): add unit tests for related-origins validator --- .../src/ops/webauthn/related_origins/mod.rs | 496 ++++++++++++++++++ 1 file changed, 496 insertions(+) diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs index 0186600a..09e316a3 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/mod.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -172,3 +172,499 @@ impl RelatedOriginsHttpClient for NoRelatedOriginsClient { )) } } + +#[cfg(test)] +mod tests { + use super::super::psl::MockPublicSuffixList; + use super::*; + + struct MockClient { + response: Result, + } + + #[async_trait] + impl RelatedOriginsHttpClient for MockClient { + async fn fetch_well_known( + &self, + _: &RelyingPartyId, + ) -> Result { + self.response.clone() + } + } + + fn json_ct(body: &str) -> WellKnownResponse { + WellKnownResponse { + content_type: Some("application/json".into()), + body: body.as_bytes().to_vec(), + } + } + + fn caller(s: &str) -> Origin { + Origin::try_from(s).unwrap() + } + + fn rp(s: &str) -> RelyingPartyId { + RelyingPartyId::try_from(s).unwrap() + } + + #[test] + fn registrable_origin_label_basic() { + let psl = MockPublicSuffixList; + assert_eq!( + registrable_origin_label("example.co.uk", &psl).as_deref(), + Some("example"), + ); + assert_eq!( + registrable_origin_label("www.example.org", &psl).as_deref(), + Some("example"), + ); + assert_eq!(registrable_origin_label("co.uk", &psl), None); + assert_eq!(registrable_origin_label("localhost", &psl), None); + } + + #[test] + fn registrable_origin_label_ipv4_is_none() { + let psl = MockPublicSuffixList; + assert_eq!(registrable_origin_label("127.0.0.1", &psl), None); + } + + #[tokio::test] + async fn same_origin_caller_listed_first() { + let body = r#"{"origins":["https://example.com"]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Ok(()))); + } + + #[tokio::test] + async fn same_origin_with_port_match() { + let body = r#"{"origins":["https://example.com:8443"]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://example.com:8443"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Ok(()))); + } + + #[tokio::test] + async fn same_origin_with_port_mismatch_rejected() { + let body = r#"{"origins":["https://example.com:8443"]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); + } + + #[tokio::test] + async fn same_origin_default_port_normalised() { + let body = r#"{"origins":["https://example.com:443"]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Ok(()))); + } + + #[tokio::test] + async fn caller_listed_after_other_origins() { + // Substituted `.de` with `.net` (MockPublicSuffixList lacks `.de`). + let body = r#"{"origins":["https://other.net","https://example.com"]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Ok(()))); + } + + #[tokio::test] + async fn label_cap_blocks_sixth_distinct_label_match() { + // §5.11.1 step 4.e: a sixth distinct label is silently skipped, so the + // would-be match never reaches step 4.f. + let body = r#"{"origins":[ + "https://a.com", + "https://b.com", + "https://c.com", + "https://d.com", + "https://e.com", + "https://example.com" + ]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); + } + + #[tokio::test] + async fn label_cap_allows_repeats_of_seen_label() { + // §5.11.1 step 4.e "contains label" exception: once `example` has been + // recorded, further `example`-label origins still proceed to step 4.f. + let body = r#"{"origins":[ + "https://a.example.com", + "https://b.example.com", + "https://c.example.com", + "https://d.example.com", + "https://e.example.com", + "https://login.example.com" + ]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://login.example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Ok(()))); + } + + #[tokio::test] + async fn same_origin_https_vs_http_rejected() { + // `http://example.com` is rejected by `Origin::try_from` (non-localhost), + // so the listed entry can never be same-origin with the https caller. + let body = r#"{"origins":["http://example.com"]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); + } + + #[tokio::test] + async fn unparseable_origin_item_skipped() { + let body = r#"{"origins":["not a url","https://example.com"]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Ok(()))); + } + + #[tokio::test] + async fn non_https_origin_item_skipped_not_rejected() { + let body = r#"{"origins":["data:text/plain,foo","https://example.com"]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Ok(()))); + } + + #[tokio::test] + async fn unknown_suffix_origin_skipped() { + // `internal.localhost` has no registrable domain in MockPSL. + let body = r#"{"origins":["https://internal.localhost","https://example.com"]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Ok(()))); + } + + #[tokio::test] + async fn bare_etld_origin_skipped() { + // §5.11.1 step 4.c returns None for `co.uk`. + let body = r#"{"origins":["https://co.uk","https://example.co.uk"]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://example.co.uk"), + &rp("example.co.uk"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Ok(()))); + } + + #[tokio::test] + async fn wrong_content_type_rejected() { + let http = MockClient { + response: Ok(WellKnownResponse { + content_type: Some("text/html".into()), + body: b"{\"origins\":[]}".to_vec(), + }), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!( + res, + Err(RelatedOriginsError::UnexpectedContentType(_)) + )); + } + + #[tokio::test] + async fn missing_content_type_rejected() { + let http = MockClient { + response: Ok(WellKnownResponse { + content_type: None, + body: b"{\"origins\":[]}".to_vec(), + }), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!( + res, + Err(RelatedOriginsError::UnexpectedContentType(None)) + )); + } + + #[tokio::test] + async fn content_type_with_charset_accepted() { + let http = MockClient { + response: Ok(WellKnownResponse { + content_type: Some("application/json; charset=utf-8".into()), + body: br#"{"origins":["https://elsewhere.com"]}"#.to_vec(), + }), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); + } + + #[tokio::test] + async fn content_type_case_insensitive() { + let http = MockClient { + response: Ok(WellKnownResponse { + content_type: Some("Application/JSON".into()), + body: br#"{"origins":["https://example.com"]}"#.to_vec(), + }), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Ok(()))); + } + + #[tokio::test] + async fn malformed_json_rejected() { + let http = MockClient { + response: Ok(json_ct("{not json}")), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Err(RelatedOriginsError::MalformedJson(_)))); + } + + #[tokio::test] + async fn non_object_json_rejected() { + let http = MockClient { + response: Ok(json_ct("[1,2,3]")), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Err(RelatedOriginsError::MalformedJson(_)))); + } + + #[tokio::test] + async fn missing_origins_key_rejected() { + let http = MockClient { + response: Ok(json_ct("{}")), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!( + res, + Err(RelatedOriginsError::MalformedDocument(_)) + )); + } + + #[tokio::test] + async fn origins_not_array_rejected() { + let http = MockClient { + response: Ok(json_ct(r#"{"origins":"https://example.com"}"#)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!( + res, + Err(RelatedOriginsError::MalformedDocument(_)) + )); + } + + #[tokio::test] + async fn origins_array_of_non_strings_rejected() { + let http = MockClient { + response: Ok(json_ct(r#"{"origins":[1,2,3]}"#)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!( + res, + Err(RelatedOriginsError::MalformedDocument(_)) + )); + } + + #[tokio::test] + async fn empty_origins_array_no_match() { + let http = MockClient { + response: Ok(json_ct(r#"{"origins":[]}"#)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); + } + + #[tokio::test] + async fn fetch_error_propagates_as_fetch_failed() { + let http = MockClient { + response: Err(RelatedOriginsError::FetchFailed("simulated".into())), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Err(RelatedOriginsError::FetchFailed(_)))); + } + + #[tokio::test] + async fn no_match_returns_no_matching_origin() { + let http = MockClient { + response: Ok(json_ct(r#"{"origins":["https://elsewhere.com"]}"#)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); + } + + #[tokio::test] + async fn same_origin_with_ipv6_match() { + // IPv6 host has no registrable label, so the loop skips at step 4.c/4.d + // before reaching same-origin. This documents that bare IP-literal + // origins cannot match via related-origins, matching browser behaviour. + let http = MockClient { + response: Ok(json_ct(r#"{"origins":["https://[::1]"]}"#)), + }; + let res = validate_related_origins( + &caller("https://[::1]"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); + } +} From bc8219c046c191e2e233c0c9c6a8782f4f05f7c7 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:49:59 +0100 Subject: [PATCH 08/29] test(webauthn): add related-origins fallback tests for make_credential and get_assertion --- libwebauthn/src/ops/webauthn/get_assertion.rs | 125 ++++++++++++++++ .../src/ops/webauthn/make_credential.rs | 136 ++++++++++++++++++ 2 files changed, 261 insertions(+) diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index dcd49b8f..22043afb 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -657,14 +657,58 @@ impl DowngradableRequest> for GetAssertionRequest { mod tests { use std::time::Duration; + use async_trait::async_trait; use serde_bytes::ByteBuf; use crate::ops::webauthn::psl::MockPublicSuffixList; + use crate::ops::webauthn::related_origins::{ + RelatedOriginsError, RelatedOriginsHttpClient, WellKnownResponse, + }; use crate::ops::webauthn::{GetAssertionRequest, NoRelatedOriginsClient, RequestOrigin}; use crate::proto::ctap2::Ctap2PublicKeyCredentialType; use super::*; + /// Test-only HTTP client backed by a fixed response. `panicking` proves the + /// suffix-check short-circuit by failing the test if the fetch is invoked. + struct MockHttpClient { + response: Option>, + } + + impl MockHttpClient { + fn ok_body(body: &str) -> Self { + Self { + response: Some(Ok(WellKnownResponse { + content_type: Some("application/json".into()), + body: body.as_bytes().to_vec(), + })), + } + } + + fn err(e: RelatedOriginsError) -> Self { + Self { + response: Some(Err(e)), + } + } + + fn panicking() -> Self { + Self { response: None } + } + } + + #[async_trait] + impl RelatedOriginsHttpClient for MockHttpClient { + async fn fetch_well_known( + &self, + _: &RelyingPartyId, + ) -> Result { + match &self.response { + Some(r) => r.clone(), + None => panic!("fetch_well_known should not be called"), + } + } + } + pub const REQUEST_BASE_JSON: &str = r#" { "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu", @@ -820,6 +864,87 @@ mod tests { )); } + // `.de` substituted with `.org` (MockPublicSuffixList lacks `.de`); pattern + // (different eTLD between caller origin and rp.id) is identical. + + #[tokio::test] + async fn related_origins_match_resolves_mismatch() { + let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap(); + let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#); + let http = MockHttpClient::ok_body(r#"{"origins":["https://app.example.org"]}"#); + + let req = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &http, + &req_json, + ) + .await + .unwrap(); + assert_eq!(req.relying_party_id, "example.com"); + } + + #[tokio::test] + async fn related_origins_no_match_keeps_mismatch_error() { + let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap(); + let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#); + let http = MockHttpClient::ok_body(r#"{"origins":["https://other.org"]}"#); + + let result = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &http, + &req_json, + ) + .await; + assert!(matches!( + result, + Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( + _, + _ + )) + )); + } + + #[tokio::test] + async fn related_origins_fetch_error_keeps_mismatch_error() { + let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap(); + let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#); + let http = MockHttpClient::err(RelatedOriginsError::FetchFailed("simulated".into())); + + let result = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &http, + &req_json, + ) + .await; + assert!(matches!( + result, + Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( + _, + _ + )) + )); + } + + #[tokio::test] + async fn related_origins_not_consulted_when_suffix_matches() { + let request_origin: RequestOrigin = "https://login.example.com".parse().unwrap(); + let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#); + let http = MockHttpClient::panicking(); + + let req = GetAssertionRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &http, + &req_json, + ) + .await + .unwrap(); + assert_eq!(req.relying_party_id, "example.com"); + } + #[tokio::test] async fn test_request_from_json_ignore_missing_allow_credentials() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 46775306..57570737 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -699,12 +699,57 @@ impl DowngradableRequest for MakeCredentialRequest { mod tests { use std::time::Duration; + use async_trait::async_trait; + use crate::ops::webauthn::psl::MockPublicSuffixList; + use crate::ops::webauthn::related_origins::{ + RelatedOriginsError, RelatedOriginsHttpClient, WellKnownResponse, + }; use crate::ops::webauthn::{MakeCredentialRequest, NoRelatedOriginsClient, RequestOrigin}; use crate::proto::ctap2::Ctap2PublicKeyCredentialType; use super::*; + /// Test-only HTTP client backed by a fixed response. `panicking` proves the + /// suffix-check short-circuit by failing the test if the fetch is invoked. + struct MockHttpClient { + response: Option>, + } + + impl MockHttpClient { + fn ok_body(body: &str) -> Self { + Self { + response: Some(Ok(WellKnownResponse { + content_type: Some("application/json".into()), + body: body.as_bytes().to_vec(), + })), + } + } + + fn err(e: RelatedOriginsError) -> Self { + Self { + response: Some(Err(e)), + } + } + + fn panicking() -> Self { + Self { response: None } + } + } + + #[async_trait] + impl RelatedOriginsHttpClient for MockHttpClient { + async fn fetch_well_known( + &self, + _: &RelyingPartyId, + ) -> Result { + match &self.response { + Some(r) => r.clone(), + None => panic!("fetch_well_known should not be called"), + } + } + } + pub const REQUEST_BASE_JSON: &str = r#" { "rp": { @@ -1146,6 +1191,97 @@ mod tests { assert_eq!(req.origin, "http://localhost:3000"); } + // `.de` substituted with `.org` (MockPublicSuffixList lacks `.de`); pattern + // (different eTLD between caller origin and rp.id) is identical. + + #[tokio::test] + async fn related_origins_match_resolves_mismatch() { + let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "rp", + r#"{"id": "example.com", "name": "example.com"}"#, + ); + let http = MockHttpClient::ok_body(r#"{"origins":["https://app.example.org"]}"#); + + let req = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &http, + &req_json, + ) + .await + .unwrap(); + assert_eq!(req.relying_party.id, "example.com"); + } + + #[tokio::test] + async fn related_origins_no_match_keeps_mismatch_error() { + let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "rp", + r#"{"id": "example.com", "name": "example.com"}"#, + ); + let http = MockHttpClient::ok_body(r#"{"origins":["https://other.org"]}"#); + + let result = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &http, + &req_json, + ) + .await; + assert!(matches!( + result, + Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _)) + )); + } + + #[tokio::test] + async fn related_origins_fetch_error_keeps_mismatch_error() { + let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "rp", + r#"{"id": "example.com", "name": "example.com"}"#, + ); + let http = MockHttpClient::err(RelatedOriginsError::FetchFailed("simulated".into())); + + let result = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &http, + &req_json, + ) + .await; + assert!(matches!( + result, + Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _)) + )); + } + + #[tokio::test] + async fn related_origins_not_consulted_when_suffix_matches() { + let request_origin: RequestOrigin = "https://login.example.com".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "rp", + r#"{"id": "example.com", "name": "example.com"}"#, + ); + let http = MockHttpClient::panicking(); + + let req = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + &http, + &req_json, + ) + .await + .unwrap(); + assert_eq!(req.relying_party.id, "example.com"); + } + // Tests for response JSON serialization fn create_test_response() -> MakeCredentialResponse { From d375c2d2abb9fa6641a5f9b952fd3d3f23a0bfb7 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:51:27 +0100 Subject: [PATCH 09/29] test(webauthn): add integration test for related-origins end-to-end --- libwebauthn/tests/related_origins.rs | 119 +++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 libwebauthn/tests/related_origins.rs diff --git a/libwebauthn/tests/related_origins.rs b/libwebauthn/tests/related_origins.rs new file mode 100644 index 00000000..2009d658 --- /dev/null +++ b/libwebauthn/tests/related_origins.rs @@ -0,0 +1,119 @@ +//! End-to-end related-origins integration tests (WebAuthn L3 §5.11). +//! +//! Drives `MakeCredentialRequest::from_json` / `GetAssertionRequest::from_json` +//! with a mock HTTP client and a tiny inline PSL impl. No network. + +use async_trait::async_trait; + +use libwebauthn::ops::webauthn::{ + GetAssertionRequest, MakeCredentialRequest, PublicSuffixList, RelatedOriginsError, + RelatedOriginsHttpClient, RelyingPartyId, RequestOrigin, WebAuthnIDL, WellKnownResponse, +}; + +const KNOWN_SUFFIXES: &[&str] = &["com", "org"]; + +/// Minimal PSL recognising only `com` and `org`. Sufficient for these tests. +struct TestPsl; + +impl PublicSuffixList for TestPsl { + fn public_suffix(&self, host: &str) -> Option { + for suffix in KNOWN_SUFFIXES { + if host == *suffix { + return Some((*suffix).to_string()); + } + let needle = format!(".{suffix}"); + if host.ends_with(&needle) { + return Some((*suffix).to_string()); + } + } + None + } +} + +struct StaticHttp { + body: &'static str, +} + +#[async_trait] +impl RelatedOriginsHttpClient for StaticHttp { + async fn fetch_well_known( + &self, + _: &RelyingPartyId, + ) -> Result { + Ok(WellKnownResponse { + content_type: Some("application/json".into()), + body: self.body.as_bytes().to_vec(), + }) + } +} + +const MAKE_CREDENTIAL_JSON: &str = r#" +{ + "rp": {"id": "brand.com", "name": "brand.com"}, + "user": { + "id": "dXNlcmlk", + "name": "mario.rossi", + "displayName": "Mario Rossi" + }, + "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu", + "pubKeyCredParams": [{"type": "public-key", "alg": -7}], + "timeout": 30000, + "excludeCredentials": [], + "authenticatorSelection": { + "residentKey": "discouraged", + "userVerification": "preferred" + }, + "attestation": "none" +} +"#; + +const GET_ASSERTION_JSON: &str = r#" +{ + "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu", + "timeout": 30000, + "rpId": "brand.com", + "allowCredentials": [ + {"type": "public-key", "id": "bXktY3JlZGVudGlhbC1pZA"} + ], + "userVerification": "preferred" +} +"#; + +// `.de` in design §8.3 substituted with `.org` (test PSL knows `.com` and +// `.org`); pattern (different eTLD between caller and rp.id) is identical. +const WELL_KNOWN_BODY: &str = r#"{"origins":["https://app.brand.org","https://brand.com"]}"#; + +#[tokio::test] +async fn end_to_end_mock_match_via_make_credential() { + let request_origin: RequestOrigin = "https://app.brand.org".parse().unwrap(); + let http = StaticHttp { + body: WELL_KNOWN_BODY, + }; + + let req = + MakeCredentialRequest::from_json(&request_origin, &TestPsl, &http, MAKE_CREDENTIAL_JSON) + .await + .unwrap(); + + assert_eq!(req.relying_party.id, "brand.com"); + assert!(req + .client_data_json() + .contains(r#""origin":"https://app.brand.org""#)); +} + +#[tokio::test] +async fn end_to_end_mock_match_via_get_assertion() { + let request_origin: RequestOrigin = "https://app.brand.org".parse().unwrap(); + let http = StaticHttp { + body: WELL_KNOWN_BODY, + }; + + let req = GetAssertionRequest::from_json(&request_origin, &TestPsl, &http, GET_ASSERTION_JSON) + .await + .unwrap(); + + assert_eq!(req.relying_party_id, "brand.com"); + assert!(req + .client_data_json() + .contains(r#""origin":"https://app.brand.org""#)); +} From b4a181f251522f4ffc4d6729778ef5798854478b Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:59:51 +0100 Subject: [PATCH 10/29] fix(webauthn): disable referer header in default related-origins client reqwest's referer() defaults to true, so on any redirect chain it would auto-populate Referer with the previous URL, leaking the RP's well-known URL to the redirect target. The previous empty-valued Referer default header did not disable this. Drop the header insertion and call .referer(false) so no Referer is sent on the initial request or any redirect, matching WebAuthn L3 \xc2\xa75.11.1 step 2 ("without a referrer"). --- libwebauthn/src/ops/webauthn/related_origins/http.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/related_origins/http.rs b/libwebauthn/src/ops/webauthn/related_origins/http.rs index f0974c2a..45f0378a 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/http.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/http.rs @@ -5,7 +5,6 @@ use std::time::Duration; use async_trait::async_trait; use futures::StreamExt; -use reqwest::header::{HeaderMap, HeaderValue, REFERER}; use reqwest::redirect::Policy; use reqwest::{Client, StatusCode}; @@ -51,14 +50,13 @@ impl ReqwestRelatedOriginsClient { } attempt.follow() }); - let mut default_headers = HeaderMap::new(); - default_headers.insert(REFERER, HeaderValue::from_static("")); - // `cookies` feature off, so reqwest holds no cookie jar to disable. + // WebAuthn L3 §5.11.1 step 2: fetch "without a referrer"; `cookies` + // feature is off, so reqwest holds no cookie jar. let client = Client::builder() .https_only(true) .redirect(redirect_policy) + .referer(false) .timeout(policy.request_timeout) - .default_headers(default_headers) .build() .map_err(|e| RelatedOriginsError::FetchFailed(e.to_string()))?; Ok(Self { From a8e06084f5f164fa5e58c7c7ebc46fcfecdeebdc Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:00:01 +0100 Subject: [PATCH 11/29] feat(webauthn): document RelatedOriginsHttpClient trait contract Extend the trait doc to bind impls to status-200-only and unmodified Content-Type reporting, so a third-party client cannot accidentally feed a 404 body or a synthesised application/json type to the validator. --- libwebauthn/src/ops/webauthn/related_origins/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs index 09e316a3..8ca231d0 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/mod.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -30,6 +30,10 @@ pub struct WellKnownResponse { /// Fetcher for `https://{rp_id}/.well-known/webauthn`, per WebAuthn L3 §5.11.1 /// step 2. Implementations MUST send no credentials, no Referer, refuse /// non-`https://` redirects, cap the body size, and bound the request duration. +/// Implementations MUST return `Err(FetchFailed)` for any status code other +/// than 200 (after following redirects). Implementations MUST report the wire +/// `Content-Type` header value unmodified (or `None` if absent) and MUST NOT +/// synthesise an `application/json` content type for non-JSON responses. #[async_trait] pub trait RelatedOriginsHttpClient: Send + Sync { async fn fetch_well_known( From 7e84b8f0d84ff6eeb0048c2c1716f78f2f92ab68 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:00:35 +0100 Subject: [PATCH 12/29] refactor(webauthn): make MAX_REGISTRABLE_LABELS crate-private The cap is hard-coded inside the validator loop, so external callers cannot override it. Reduce to a private const and drop the re-export to avoid committing to the value across breaking changes. --- libwebauthn/src/ops/webauthn/mod.rs | 2 +- libwebauthn/src/ops/webauthn/related_origins/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index d34ba19c..76322983 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -39,7 +39,7 @@ pub use psl::{ }; pub use related_origins::{ validate_related_origins, NoRelatedOriginsClient, RelatedOriginsError, - RelatedOriginsHttpClient, WellKnownResponse, MAX_REGISTRABLE_LABELS, + RelatedOriginsHttpClient, WellKnownResponse, }; use serde::Deserialize; diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs index 8ca231d0..c558cc01 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/mod.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -19,7 +19,7 @@ pub mod http; /// WebAuthn L3 §5.11 requires support for at least 5 registrable origin labels; /// we cap at exactly 5 to bound abuse surface. -pub const MAX_REGISTRABLE_LABELS: usize = 5; +const MAX_REGISTRABLE_LABELS: usize = 5; #[derive(Debug, Clone)] pub struct WellKnownResponse { From 62238695bdd2f272e0b038cf4aff1286108d5576 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:01:31 +0100 Subject: [PATCH 13/29] fix(webauthn): redact related-origins error detail in mismatch log The previous warn!(error = ?err, ...) debug-printed RelatedOriginsError, which can carry reqwest error text (IP/port) and serde_json text (body snippets). Add RelatedOriginsError::kind() that returns a static discriminant and log only that. Downgrade to debug! since most RPs do not host /.well-known/webauthn and the failure is expected noise. --- libwebauthn/src/ops/webauthn/get_assertion.rs | 4 ++-- libwebauthn/src/ops/webauthn/make_credential.rs | 4 ++-- .../src/ops/webauthn/related_origins/mod.rs | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 22043afb..f7d87b60 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, time::Duration}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use tracing::{debug, error, trace, warn}; +use tracing::{debug, error, trace}; use crate::{ fido::AuthenticatorData, @@ -185,7 +185,7 @@ impl FromIdlModel &'static str { + match self { + RelatedOriginsError::FetchFailed(_) => "fetch_failed", + RelatedOriginsError::UnexpectedContentType(_) => "unexpected_content_type", + RelatedOriginsError::MalformedJson(_) => "malformed_json", + RelatedOriginsError::MalformedDocument(_) => "malformed_document", + RelatedOriginsError::NoMatchingOrigin => "no_matching_origin", + } + } +} + pub type RelatedOriginsResult = Result<(), RelatedOriginsError>; #[derive(Debug, Deserialize)] From 6bb487d2519f09d77a640372ef29cf41db8c9579 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:02:26 +0100 Subject: [PATCH 14/29] fix(webauthn): same_origin compares tuple not Origin re-parse The previous impl re-parsed the listed URL through Origin::parse, which rejects userinfo, non-/ paths, queries and fragments. WebAuthn L3 \xc2\xa75.11.1 step 4.f defers to HTML \xc2\xa77.5 same-origin, which compares only scheme, host and port. Compare those three directly so a listed entry like "https://example.com/foo" can match the caller. Add a test. --- .../src/ops/webauthn/related_origins/mod.rs | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs index 96ce5ef3..70f7aa79 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/mod.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -10,7 +10,7 @@ use async_trait::async_trait; use serde::Deserialize; use url::{Host, Url}; -use super::idl::origin::Origin; +use super::idl::origin::{Origin, Scheme}; use super::idl::rpid::RelyingPartyId; use super::psl::PublicSuffixList; @@ -158,13 +158,31 @@ fn effective_domain_of(url: &Url) -> Option { } } -/// WebAuthn L3 §5.11.1 step 4.f: typed-origin equality between the caller's -/// origin and the listed entry's tuple origin. +/// WebAuthn L3 §5.11.1 step 4.f: tuple-origin equality (scheme, host, port) +/// between the caller's origin and the listed entry. The spec defers to HTML +/// §7.5 "same origin", which compares only those three components, so +/// userinfo, paths, queries and fragments on the listed URL are ignored. fn same_origin(caller: &Origin, listed: &Url) -> bool { - let Ok(listed_str) = listed.as_str().parse::() else { + if caller.scheme.as_str() != listed.scheme() { + return false; + } + let Some(listed_host) = effective_domain_of(listed) else { return false; }; - *caller == listed_str + if caller.host.as_str() != listed_host { + return false; + } + let caller_port = caller.port.or_else(|| default_port(caller.scheme)); + caller_port == listed.port_or_known_default() +} + +/// Default port for a WebAuthn scheme, per the WHATWG URL Standard's +/// special-scheme port table. +fn default_port(scheme: Scheme) -> Option { + match scheme { + Scheme::Https => Some(443), + Scheme::Http => Some(80), + } } /// Fetch §2.5 `application/json` essence check: case-insensitive, parameters @@ -669,6 +687,24 @@ mod tests { assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); } + #[tokio::test] + async fn listed_origin_with_path_still_matches() { + // §5.11.1 step 4.f: same-origin compares (scheme, host, port) only, so + // a trailing path on the listed entry must not block the match. + let body = r#"{"origins":["https://example.com/foo"]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Ok(()))); + } + #[tokio::test] async fn same_origin_with_ipv6_match() { // IPv6 host has no registrable label, so the loop skips at step 4.c/4.d From e20b15dada06cd870f878fc56f8a78363504f65e Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:02:53 +0100 Subject: [PATCH 15/29] test(webauthn): pin the 5th-distinct-label cap boundary Symmetric to label_cap_blocks_sixth_distinct_label_match. Asserts that the 5th distinct label still satisfies step 4.e's size < max check, so an off-by-one regression on the cap is caught by tests. --- .../src/ops/webauthn/related_origins/mod.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs index 70f7aa79..081f0c64 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/mod.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -346,6 +346,31 @@ mod tests { assert!(matches!(res, Ok(()))); } + #[tokio::test] + async fn label_cap_allows_fifth_distinct_label_match() { + // §5.11.1 step 4.e: the 5th distinct label still satisfies size < max + // at step 4.g, so it is recorded and the same-origin check at 4.f + // succeeds. Pins the cap boundary at 5. + let body = r#"{"origins":[ + "https://a.com", + "https://b.com", + "https://c.com", + "https://d.com", + "https://example.com" + ]}"#; + let http = MockClient { + response: Ok(json_ct(body)), + }; + let res = validate_related_origins( + &caller("https://example.com"), + &rp("example.com"), + &MockPublicSuffixList, + &http, + ) + .await; + assert!(matches!(res, Ok(()))); + } + #[tokio::test] async fn label_cap_blocks_sixth_distinct_label_match() { // §5.11.1 step 4.e: a sixth distinct label is silently skipped, so the From 2663a05544db43246fa826189672347e26bd24f9 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:03:19 +0100 Subject: [PATCH 16/29] test(webauthn): rename ipv6 test to reflect actual assertion The body asserts that an IPv6 listed entry is silently skipped at step 4.c/4.d (no registrable label), not that same-origin matches. Rename to match and rephrase the comment. --- libwebauthn/src/ops/webauthn/related_origins/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs index 081f0c64..9d248592 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/mod.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -731,10 +731,10 @@ mod tests { } #[tokio::test] - async fn same_origin_with_ipv6_match() { + async fn ipv6_listed_origin_skipped_no_registrable_label() { // IPv6 host has no registrable label, so the loop skips at step 4.c/4.d - // before reaching same-origin. This documents that bare IP-literal - // origins cannot match via related-origins, matching browser behaviour. + // before reaching same-origin. Bare IP-literal origins therefore + // cannot match via related-origins, matching browser behaviour. let http = MockClient { response: Ok(json_ct(r#"{"origins":["https://[::1]"]}"#)), }; From 181ee7ab2a41e046717abd509c249162eee4ab58 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:03:32 +0100 Subject: [PATCH 17/29] refactor(webauthn): re-export ReqwestRelatedOriginsClient from related_origins Mirror NoRelatedOriginsClient's placement: under the same feature gate, expose ReqwestRelatedOriginsClient (and HttpPolicy) at the related_origins module root so consumers do not need the http:: submodule path. --- libwebauthn/src/ops/webauthn/related_origins/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs index 9d248592..a13396b0 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/mod.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -17,6 +17,9 @@ use super::psl::PublicSuffixList; #[cfg(feature = "related-origins-client")] pub mod http; +#[cfg(feature = "related-origins-client")] +pub use http::{HttpPolicy, ReqwestRelatedOriginsClient}; + /// WebAuthn L3 §5.11 requires support for at least 5 registrable origin labels; /// we cap at exactly 5 to bound abuse surface. const MAX_REGISTRABLE_LABELS: usize = 5; From 23c7cfbf9d925182b476370ef52b667d2ce2ae9a Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:12:11 +0100 Subject: [PATCH 18/29] chore(webauthn): trim verbose doc comments in related_origins --- .../src/ops/webauthn/related_origins/mod.rs | 38 +++++-------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs index a13396b0..8e8bdc49 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/mod.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -20,8 +20,7 @@ pub mod http; #[cfg(feature = "related-origins-client")] pub use http::{HttpPolicy, ReqwestRelatedOriginsClient}; -/// WebAuthn L3 §5.11 requires support for at least 5 registrable origin labels; -/// we cap at exactly 5 to bound abuse surface. +/// WebAuthn L3 §5.11 minimum; capped at 5 to bound abuse surface. const MAX_REGISTRABLE_LABELS: usize = 5; #[derive(Debug, Clone)] @@ -62,9 +61,7 @@ pub enum RelatedOriginsError { } impl RelatedOriginsError { - /// Static, log-safe variant discriminant. Use in place of the `Debug` / - /// `Display` impls when the error may carry reqwest- or serde-supplied - /// text (IPs, body snippets) that should not reach operator logs. + /// Log-safe variant discriminant; `Debug`/`Display` may carry reqwest/serde text with IPs or body snippets. pub fn kind(&self) -> &'static str { match self { RelatedOriginsError::FetchFailed(_) => "fetch_failed", @@ -84,8 +81,6 @@ struct WellKnownDocument { } /// Runs the WebAuthn L3 §5.11.1 related-origins validation procedure. -/// Returns `Ok(())` when a listed origin matches `caller_origin`, otherwise -/// returns the first fetch/parse error or [`RelatedOriginsError::NoMatchingOrigin`]. pub async fn validate_related_origins( caller_origin: &Origin, rp_id: &RelyingPartyId, @@ -140,8 +135,7 @@ pub async fn validate_related_origins( Err(RelatedOriginsError::NoMatchingOrigin) } -/// First label of `host`'s registrable domain (eTLD+1), or `None` when the host -/// has no registrable domain (e.g. bare eTLD, IP literal, unknown TLD). +/// First label of `host`'s registrable domain (eTLD+1), or `None` if `host` has no registrable domain. pub(crate) fn registrable_origin_label(host: &str, psl: &dyn PublicSuffixList) -> Option { let registrable = psl.registrable_domain(host)?; let label = registrable.split('.').next()?; @@ -151,8 +145,7 @@ pub(crate) fn registrable_origin_label(host: &str, psl: &dyn PublicSuffixList) - Some(label.to_string()) } -/// Effective domain of a URL per HTML §6.2: domain hosts and IP literals; opaque -/// hosts and host-less URLs return `None`. +/// Effective domain of `url` per HTML §6.2; `None` for opaque or host-less URLs. fn effective_domain_of(url: &Url) -> Option { match url.host()? { Host::Domain(d) => Some(d.to_string()), @@ -161,10 +154,7 @@ fn effective_domain_of(url: &Url) -> Option { } } -/// WebAuthn L3 §5.11.1 step 4.f: tuple-origin equality (scheme, host, port) -/// between the caller's origin and the listed entry. The spec defers to HTML -/// §7.5 "same origin", which compares only those three components, so -/// userinfo, paths, queries and fragments on the listed URL are ignored. +/// Tuple-origin equality (scheme, host, port) per WebAuthn L3 §5.11.1 step 4.f and HTML §7.5; userinfo, path, query and fragment on `listed` are ignored. fn same_origin(caller: &Origin, listed: &Url) -> bool { if caller.scheme.as_str() != listed.scheme() { return false; @@ -179,8 +169,7 @@ fn same_origin(caller: &Origin, listed: &Url) -> bool { caller_port == listed.port_or_known_default() } -/// Default port for a WebAuthn scheme, per the WHATWG URL Standard's -/// special-scheme port table. +/// Default port per the WHATWG URL Standard special-scheme port table. fn default_port(scheme: Scheme) -> Option { match scheme { Scheme::Https => Some(443), @@ -188,16 +177,13 @@ fn default_port(scheme: Scheme) -> Option { } } -/// Fetch §2.5 `application/json` essence check: case-insensitive, parameters -/// ignored. Used for WebAuthn L3 §5.11.1 step 2.a. +/// Fetch §2.5 `application/json` essence check; used for WebAuthn L3 §5.11.1 step 2.a. fn is_application_json(value: &str) -> bool { let essence = value.split(';').next().unwrap_or("").trim(); essence.eq_ignore_ascii_case("application/json") } -/// `RelatedOriginsHttpClient` that always refuses; preserves today's -/// "mismatching rp.id is a hard error" semantics for callers that do not opt -/// into related-origin fetches. +/// `RelatedOriginsHttpClient` that always refuses; preserves strict rp.id matching when callers do not opt into related-origin fetches. #[derive(Debug, Clone, Copy, Default)] pub struct NoRelatedOriginsClient; @@ -351,9 +337,7 @@ mod tests { #[tokio::test] async fn label_cap_allows_fifth_distinct_label_match() { - // §5.11.1 step 4.e: the 5th distinct label still satisfies size < max - // at step 4.g, so it is recorded and the same-origin check at 4.f - // succeeds. Pins the cap boundary at 5. + // §5.11.1 step 4.e: the 5th distinct label is still within the cap. let body = r#"{"origins":[ "https://a.com", "https://b.com", @@ -735,9 +719,7 @@ mod tests { #[tokio::test] async fn ipv6_listed_origin_skipped_no_registrable_label() { - // IPv6 host has no registrable label, so the loop skips at step 4.c/4.d - // before reaching same-origin. Bare IP-literal origins therefore - // cannot match via related-origins, matching browser behaviour. + // IPv6 host has no registrable label; loop skips at step 4.c/4.d. let http = MockClient { response: Ok(json_ct(r#"{"origins":["https://[::1]"]}"#)), }; From a6affbc2049fe21d3ec183e682a4e4a1bdbc5ec9 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Mon, 18 May 2026 19:40:19 +0100 Subject: [PATCH 19/29] feat(examples): switch webauthn ceremony examples to ReqwestRelatedOriginsClient Using NoRelatedOriginsClient in the bundled examples taught readers the wrong default. Wire up the reqwest-backed convenience client instead, gate the three webauthn ceremony examples on the related-origins-client feature, and update the README run commands. Also re-exports HttpPolicy and ReqwestRelatedOriginsClient at ops::webauthn so examples import from a single path. --- README.md | 16 +++++----- libwebauthn/Cargo.toml | 6 +++- libwebauthn/examples/ceremony/webauthn_ble.rs | 28 +++++++---------- .../examples/ceremony/webauthn_cable.rs | 7 +++-- libwebauthn/examples/ceremony/webauthn_hid.rs | 30 ++++++++----------- libwebauthn/examples/ceremony/webauthn_nfc.rs | 10 ++++--- libwebauthn/src/ops/webauthn/mod.rs | 2 ++ 7 files changed, 48 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 5bc43093..a55caad3 100644 --- a/README.md +++ b/README.md @@ -68,16 +68,18 @@ $ git submodule update --init The basic ceremony examples (register + authenticate) cover all transports. The WebAuthn examples consume and emit JSON per the [WebAuthn IDL][webauthn]. -| Transport | FIDO U2F | WebAuthn (FIDO2) | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | -| **USB (HID)** | `cargo run --example u2f_hid` | `cargo run --example webauthn_hid` | -| **Bluetooth (BLE)** | `cargo run --example u2f_ble` | `cargo run --example webauthn_ble` | -| **NFC** [^nfc] | `cargo run --features nfc-backend-pcsc --example u2f_nfc`
`cargo run --features nfc-backend-libnfc --example u2f_nfc` | `cargo run --features nfc-backend-pcsc --example webauthn_nfc`
`cargo run --features nfc-backend-libnfc --example webauthn_nfc` | -| **Hybrid (caBLE v2 + CTAP 2.3)** | — | `cargo run --example webauthn_cable` | -| **Hybrid (caBLE v2)** | — | `cargo run --example webauthn_cable_wss` | +| Transport | FIDO U2F | WebAuthn (FIDO2) [^ro] | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **USB (HID)** | `cargo run --example u2f_hid` | `cargo run --features related-origins-client --example webauthn_hid` | +| **Bluetooth (BLE)** | `cargo run --example u2f_ble` | `cargo run --features related-origins-client --example webauthn_ble` | +| **NFC** [^nfc] | `cargo run --features nfc-backend-pcsc --example u2f_nfc`
`cargo run --features nfc-backend-libnfc --example u2f_nfc` | `cargo run --features nfc-backend-pcsc,related-origins-client --example webauthn_nfc`
`cargo run --features nfc-backend-libnfc,related-origins-client --example webauthn_nfc` | +| **Hybrid (caBLE v2 + CTAP 2.3)** | — | `cargo run --features related-origins-client --example webauthn_cable` | +| **Hybrid (caBLE v2)** | — | `cargo run --features related-origins-client --example webauthn_cable_wss` | [^nfc]: `nfc-backend-pcsc` is pure userspace and recommended on most systems. `nfc-backend-libnfc` requires the `libnfc` system library. Both can be enabled together; the first FIDO device found by either backend is used. +[^ro]: The WebAuthn ceremony examples wire up the bundled reqwest-backed [related-origins](https://www.w3.org/TR/webauthn-3/#sctn-related-origins) client, which lives behind the optional `related-origins-client` feature. Consumers that already ship their own HTTP stack can implement `RelatedOriginsHttpClient` directly and omit the feature. + Additional HID-only examples cover specific FIDO2 features and authenticator management: ``` diff --git a/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml index b20dbec5..e6d5c919 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -141,23 +141,27 @@ required-features = ["nfc"] [[example]] name = "webauthn_hid" path = "examples/ceremony/webauthn_hid.rs" +required-features = ["related-origins-client"] [[example]] name = "webauthn_ble" path = "examples/ceremony/webauthn_ble.rs" +required-features = ["related-origins-client"] [[example]] name = "webauthn_nfc" path = "examples/ceremony/webauthn_nfc.rs" -required-features = ["nfc"] +required-features = ["nfc", "related-origins-client"] [[example]] name = "webauthn_cable" path = "examples/ceremony/webauthn_cable.rs" +required-features = ["related-origins-client"] [[example]] name = "webauthn_cable_wss" path = "examples/ceremony/webauthn_cable_wss.rs" +required-features = ["related-origins-client"] [[example]] name = "webauthn_extensions_hid" diff --git a/libwebauthn/examples/ceremony/webauthn_ble.rs b/libwebauthn/examples/ceremony/webauthn_ble.rs index 14681548..916531e4 100644 --- a/libwebauthn/examples/ceremony/webauthn_ble.rs +++ b/libwebauthn/examples/ceremony/webauthn_ble.rs @@ -1,8 +1,8 @@ use std::error::Error; use libwebauthn::ops::webauthn::{ - DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, - NoRelatedOriginsClient, RequestOrigin, WebAuthnIDL as _, WebAuthnIDLResponse as _, + DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, + ReqwestRelatedOriginsClient, WebAuthnIDL as _, WebAuthnIDLResponse as _, }; use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; use libwebauthn::transport::ble::list_devices; @@ -52,15 +52,11 @@ pub async fn main() -> Result<(), Box> { "attestation": "none" } "#; + let related_origins = ReqwestRelatedOriginsClient::new()?; let make_credentials_request: MakeCredentialRequest = - MakeCredentialRequest::from_json( - &request_origin, - &psl, - &NoRelatedOriginsClient, - request_json, - ) - .await - .expect("Failed to parse request JSON"); + MakeCredentialRequest::from_json(&request_origin, &psl, &related_origins, request_json) + .await + .expect("Failed to parse request JSON"); println!( "WebAuthn MakeCredential request: {:?}", make_credentials_request @@ -102,14 +98,10 @@ pub async fn main() -> Result<(), Box> { }} "# ); - let get_assertion: GetAssertionRequest = GetAssertionRequest::from_json( - &request_origin, - &psl, - &NoRelatedOriginsClient, - &request_json, - ) - .await - .expect("Failed to parse request JSON"); + let get_assertion: GetAssertionRequest = + GetAssertionRequest::from_json(&request_origin, &psl, &related_origins, &request_json) + .await + .expect("Failed to parse request JSON"); println!("WebAuthn GetAssertion request: {:?}", get_assertion); let response = retry_user_errors!(channel.webauthn_get_assertion(&get_assertion)).unwrap(); diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index 46aa04dd..7f7ae5bd 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -11,8 +11,8 @@ use qrcode::render::unicode; use qrcode::QrCode; use libwebauthn::ops::webauthn::{ - DatFilePublicSuffixList, JsonFormat, MakeCredentialRequest, NoRelatedOriginsClient, - RequestOrigin, WebAuthnIDL as _, WebAuthnIDLResponse as _, + DatFilePublicSuffixList, JsonFormat, MakeCredentialRequest, RequestOrigin, + ReqwestRelatedOriginsClient, WebAuthnIDL as _, WebAuthnIDLResponse as _, }; use libwebauthn::transport::{Channel as _, Device}; use libwebauthn::webauthn::WebAuthn; @@ -58,6 +58,7 @@ pub async fn main() -> Result<(), Box> { let psl = DatFilePublicSuffixList::from_system_file().expect( "PSL not available; install the publicsuffix-list package or pass an explicit path", ); + let related_origins = ReqwestRelatedOriginsClient::new()?; let mut device: CableQrCodeDevice = CableQrCodeDevice::new_transient( QrCodeOperationHint::MakeCredential, @@ -82,7 +83,7 @@ pub async fn main() -> Result<(), Box> { let request = MakeCredentialRequest::from_json( &request_origin, &psl, - &NoRelatedOriginsClient, + &related_origins, MAKE_CREDENTIAL_REQUEST, ) .await diff --git a/libwebauthn/examples/ceremony/webauthn_hid.rs b/libwebauthn/examples/ceremony/webauthn_hid.rs index 18e0d139..cbddd2ab 100644 --- a/libwebauthn/examples/ceremony/webauthn_hid.rs +++ b/libwebauthn/examples/ceremony/webauthn_hid.rs @@ -2,8 +2,9 @@ use std::error::Error; use std::time::Duration; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, NoRelatedOriginsClient, RequestOrigin, - SystemPublicSuffixList, WebAuthnIDL as _, WebAuthnIDLResponse as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, + ReqwestRelatedOriginsClient, SystemPublicSuffixList, WebAuthnIDL as _, + WebAuthnIDLResponse as _, }; use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; use libwebauthn::transport::hid::list_devices; @@ -32,6 +33,7 @@ pub async fn main() -> Result<(), Box> { let psl = SystemPublicSuffixList::auto().expect( "PSL not available; install the publicsuffix-list (or publicsuffix-list-dafsa) package, or pass an explicit path", ); + let related_origins = ReqwestRelatedOriginsClient::new()?; let request_json = r#" { "rp": { @@ -56,14 +58,10 @@ pub async fn main() -> Result<(), Box> { "attestation": "none" } "#; - let make_credentials_request: MakeCredentialRequest = MakeCredentialRequest::from_json( - &request_origin, - &psl, - &NoRelatedOriginsClient, - request_json, - ) - .await - .expect("Failed to parse request JSON"); + let make_credentials_request: MakeCredentialRequest = + MakeCredentialRequest::from_json(&request_origin, &psl, &related_origins, request_json) + .await + .expect("Failed to parse request JSON"); println!( "WebAuthn MakeCredential request: {:?}", make_credentials_request @@ -105,14 +103,10 @@ pub async fn main() -> Result<(), Box> { }} "# ); - let get_assertion: GetAssertionRequest = GetAssertionRequest::from_json( - &request_origin, - &psl, - &NoRelatedOriginsClient, - &request_json, - ) - .await - .expect("Failed to parse request JSON"); + let get_assertion: GetAssertionRequest = + GetAssertionRequest::from_json(&request_origin, &psl, &related_origins, &request_json) + .await + .expect("Failed to parse request JSON"); println!("WebAuthn GetAssertion request: {:?}", get_assertion); let response = retry_user_errors!(channel.webauthn_get_assertion(&get_assertion)).unwrap(); diff --git a/libwebauthn/examples/ceremony/webauthn_nfc.rs b/libwebauthn/examples/ceremony/webauthn_nfc.rs index 5f4ebd03..cdfc0595 100644 --- a/libwebauthn/examples/ceremony/webauthn_nfc.rs +++ b/libwebauthn/examples/ceremony/webauthn_nfc.rs @@ -1,8 +1,9 @@ use std::error::Error; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, NoRelatedOriginsClient, RequestOrigin, - SystemPublicSuffixList, WebAuthnIDL as _, WebAuthnIDLResponse as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, + ReqwestRelatedOriginsClient, SystemPublicSuffixList, WebAuthnIDL as _, + WebAuthnIDLResponse as _, }; use libwebauthn::transport::nfc::{get_nfc_device, is_nfc_available}; use libwebauthn::transport::{Channel as _, Device}; @@ -30,10 +31,11 @@ pub async fn main() -> Result<(), Box> { let psl = SystemPublicSuffixList::auto().expect( "PSL not available; install the publicsuffix-list (or publicsuffix-list-dafsa) package, or pass an explicit path", ); + let related_origins = ReqwestRelatedOriginsClient::new()?; let make_credentials_request = MakeCredentialRequest::from_json( &request_origin, &psl, - &NoRelatedOriginsClient, + &related_origins, r#" { "rp": { @@ -79,7 +81,7 @@ pub async fn main() -> Result<(), Box> { let get_assertion = GetAssertionRequest::from_json( &request_origin, &psl, - &NoRelatedOriginsClient, + &related_origins, r#" { "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu", diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 76322983..52197011 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -41,6 +41,8 @@ pub use related_origins::{ validate_related_origins, NoRelatedOriginsClient, RelatedOriginsError, RelatedOriginsHttpClient, WellKnownResponse, }; +#[cfg(feature = "related-origins-client")] +pub use related_origins::{HttpPolicy, ReqwestRelatedOriginsClient}; use serde::Deserialize; #[derive(Debug, Clone, Copy, Deserialize, PartialEq)] From e73052ea51c37ddaeb677c60e8bff5b9c49e4ef9 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Mon, 18 May 2026 19:40:22 +0100 Subject: [PATCH 20/29] test(webauthn): use reserved example.* domains in related_origins integration test Swap brand.com/app.brand.org for example.org/app.example.com. RFC 2606 reserves example.* for documentation, so it cannot accidentally collide with a real party. The two-eTLD shape that exercises the related-origins fetch path is preserved. --- libwebauthn/tests/related_origins.rs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/libwebauthn/tests/related_origins.rs b/libwebauthn/tests/related_origins.rs index 2009d658..8d8c4888 100644 --- a/libwebauthn/tests/related_origins.rs +++ b/libwebauthn/tests/related_origins.rs @@ -49,7 +49,7 @@ impl RelatedOriginsHttpClient for StaticHttp { const MAKE_CREDENTIAL_JSON: &str = r#" { - "rp": {"id": "brand.com", "name": "brand.com"}, + "rp": {"id": "example.org", "name": "example.org"}, "user": { "id": "dXNlcmlk", "name": "mario.rossi", @@ -71,7 +71,7 @@ const GET_ASSERTION_JSON: &str = r#" { "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu", "timeout": 30000, - "rpId": "brand.com", + "rpId": "example.org", "allowCredentials": [ {"type": "public-key", "id": "bXktY3JlZGVudGlhbC1pZA"} ], @@ -79,13 +79,14 @@ const GET_ASSERTION_JSON: &str = r#" } "#; -// `.de` in design §8.3 substituted with `.org` (test PSL knows `.com` and -// `.org`); pattern (different eTLD between caller and rp.id) is identical. -const WELL_KNOWN_BODY: &str = r#"{"origins":["https://app.brand.org","https://brand.com"]}"#; +// Caller and rp.id sit on different eTLDs (`example.com` vs `example.org`), +// matching the §8.3 design example so the related-origins fetch path is +// actually exercised. +const WELL_KNOWN_BODY: &str = r#"{"origins":["https://app.example.com","https://example.org"]}"#; #[tokio::test] async fn end_to_end_mock_match_via_make_credential() { - let request_origin: RequestOrigin = "https://app.brand.org".parse().unwrap(); + let request_origin: RequestOrigin = "https://app.example.com".parse().unwrap(); let http = StaticHttp { body: WELL_KNOWN_BODY, }; @@ -95,15 +96,15 @@ async fn end_to_end_mock_match_via_make_credential() { .await .unwrap(); - assert_eq!(req.relying_party.id, "brand.com"); + assert_eq!(req.relying_party.id, "example.org"); assert!(req .client_data_json() - .contains(r#""origin":"https://app.brand.org""#)); + .contains(r#""origin":"https://app.example.com""#)); } #[tokio::test] async fn end_to_end_mock_match_via_get_assertion() { - let request_origin: RequestOrigin = "https://app.brand.org".parse().unwrap(); + let request_origin: RequestOrigin = "https://app.example.com".parse().unwrap(); let http = StaticHttp { body: WELL_KNOWN_BODY, }; @@ -112,8 +113,8 @@ async fn end_to_end_mock_match_via_get_assertion() { .await .unwrap(); - assert_eq!(req.relying_party_id, "brand.com"); + assert_eq!(req.relying_party_id, "example.org"); assert!(req .client_data_json() - .contains(r#""origin":"https://app.brand.org""#)); + .contains(r#""origin":"https://app.example.com""#)); } From 7af1ad0dfcd4ac72ad990d0467f445b91b1fce19 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Mon, 18 May 2026 19:47:34 +0100 Subject: [PATCH 21/29] refactor(webauthn): narrow RelatedOriginsHttpClient error to WellKnownFetchError The trait's old return type was the full RelatedOriginsError, but four of its five variants (UnexpectedContentType, MalformedJson, MalformedDocument, NoMatchingOrigin) are produced inside validate_related_origins after the fetch returns. Implementers had no reason to ever emit them. Introduce WellKnownFetchError with the variants a fetcher can actually emit (Transport, Status, BodyTooLarge, NotSupported) and let RelatedOriginsError wrap it via a Fetch variant with #[from]. The reqwest client now distinguishes non-200 status from transport faults and from body-cap hits without stringifying everything. Also drops RelatedOriginsError::kind(); the two debug! call sites switch to logging the Display form of the error directly. --- libwebauthn/src/ops/webauthn/get_assertion.rs | 12 ++-- .../src/ops/webauthn/make_credential.rs | 12 ++-- libwebauthn/src/ops/webauthn/mod.rs | 2 +- .../src/ops/webauthn/related_origins/http.rs | 23 +++---- .../src/ops/webauthn/related_origins/mod.rs | 65 +++++++++++-------- libwebauthn/tests/related_origins.rs | 6 +- 6 files changed, 62 insertions(+), 58 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index f7d87b60..cd5d05d5 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -185,7 +185,7 @@ impl FromIdlModel>, + response: Option>, } impl MockHttpClient { @@ -685,7 +685,7 @@ mod tests { } } - fn err(e: RelatedOriginsError) -> Self { + fn err(e: WellKnownFetchError) -> Self { Self { response: Some(Err(e)), } @@ -701,7 +701,7 @@ mod tests { async fn fetch_well_known( &self, _: &RelyingPartyId, - ) -> Result { + ) -> Result { match &self.response { Some(r) => r.clone(), None => panic!("fetch_well_known should not be called"), @@ -910,7 +910,7 @@ mod tests { async fn related_origins_fetch_error_keeps_mismatch_error() { let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#); - let http = MockHttpClient::err(RelatedOriginsError::FetchFailed("simulated".into())); + let http = MockHttpClient::err(WellKnownFetchError::Transport("simulated".into())); let result = GetAssertionRequest::from_json( &request_origin, diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 26e16f81..2d894363 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -385,7 +385,7 @@ impl FromIdlModel>, + response: Option>, } impl MockHttpClient { @@ -726,7 +726,7 @@ mod tests { } } - fn err(e: RelatedOriginsError) -> Self { + fn err(e: WellKnownFetchError) -> Self { Self { response: Some(Err(e)), } @@ -742,7 +742,7 @@ mod tests { async fn fetch_well_known( &self, _: &RelyingPartyId, - ) -> Result { + ) -> Result { match &self.response { Some(r) => r.clone(), None => panic!("fetch_well_known should not be called"), @@ -1246,7 +1246,7 @@ mod tests { "rp", r#"{"id": "example.com", "name": "example.com"}"#, ); - let http = MockHttpClient::err(RelatedOriginsError::FetchFailed("simulated".into())); + let http = MockHttpClient::err(WellKnownFetchError::Transport("simulated".into())); let result = MakeCredentialRequest::from_json( &request_origin, diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 52197011..16ddd77d 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -39,7 +39,7 @@ pub use psl::{ }; pub use related_origins::{ validate_related_origins, NoRelatedOriginsClient, RelatedOriginsError, - RelatedOriginsHttpClient, WellKnownResponse, + RelatedOriginsHttpClient, WellKnownFetchError, WellKnownResponse, }; #[cfg(feature = "related-origins-client")] pub use related_origins::{HttpPolicy, ReqwestRelatedOriginsClient}; diff --git a/libwebauthn/src/ops/webauthn/related_origins/http.rs b/libwebauthn/src/ops/webauthn/related_origins/http.rs index 45f0378a..74443a53 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/http.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/http.rs @@ -8,7 +8,7 @@ use futures::StreamExt; use reqwest::redirect::Policy; use reqwest::{Client, StatusCode}; -use super::{RelatedOriginsError, RelatedOriginsHttpClient, WellKnownResponse}; +use super::{RelatedOriginsHttpClient, WellKnownFetchError, WellKnownResponse}; use crate::ops::webauthn::idl::rpid::RelyingPartyId; #[derive(Debug, Clone)] @@ -35,11 +35,11 @@ pub struct ReqwestRelatedOriginsClient { } impl ReqwestRelatedOriginsClient { - pub fn new() -> Result { + pub fn new() -> Result { Self::with_policy(HttpPolicy::default()) } - pub fn with_policy(policy: HttpPolicy) -> Result { + pub fn with_policy(policy: HttpPolicy) -> Result { let max_redirects = policy.max_redirects; let redirect_policy = Policy::custom(move |attempt| { if attempt.previous().len() >= max_redirects { @@ -58,7 +58,7 @@ impl ReqwestRelatedOriginsClient { .referer(false) .timeout(policy.request_timeout) .build() - .map_err(|e| RelatedOriginsError::FetchFailed(e.to_string()))?; + .map_err(|e| WellKnownFetchError::Transport(e.to_string()))?; Ok(Self { client, max_body_bytes: policy.max_body_bytes, @@ -71,19 +71,16 @@ impl RelatedOriginsHttpClient for ReqwestRelatedOriginsClient { async fn fetch_well_known( &self, rp_id: &RelyingPartyId, - ) -> Result { + ) -> Result { let url = format!("https://{}/.well-known/webauthn", rp_id.0); let response = self .client .get(&url) .send() .await - .map_err(|e| RelatedOriginsError::FetchFailed(e.to_string()))?; + .map_err(|e| WellKnownFetchError::Transport(e.to_string()))?; if response.status() != StatusCode::OK { - return Err(RelatedOriginsError::FetchFailed(format!( - "status {}", - response.status() - ))); + return Err(WellKnownFetchError::Status(response.status().as_u16())); } let content_type = response .headers() @@ -94,11 +91,9 @@ impl RelatedOriginsHttpClient for ReqwestRelatedOriginsClient { let mut body = Vec::with_capacity(8 * 1024); let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| RelatedOriginsError::FetchFailed(e.to_string()))?; + let chunk = chunk.map_err(|e| WellKnownFetchError::Transport(e.to_string()))?; if body.len() + chunk.len() > self.max_body_bytes { - return Err(RelatedOriginsError::FetchFailed( - "body exceeded size cap".into(), - )); + return Err(WellKnownFetchError::BodyTooLarge); } body.extend_from_slice(&chunk); } diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs index 8e8bdc49..f692dc4e 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/mod.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -32,22 +32,41 @@ pub struct WellKnownResponse { /// Fetcher for `https://{rp_id}/.well-known/webauthn`, per WebAuthn L3 §5.11.1 /// step 2. Implementations MUST send no credentials, no Referer, refuse /// non-`https://` redirects, cap the body size, and bound the request duration. -/// Implementations MUST return `Err(FetchFailed)` for any status code other -/// than 200 (after following redirects). Implementations MUST report the wire -/// `Content-Type` header value unmodified (or `None` if absent) and MUST NOT -/// synthesise an `application/json` content type for non-JSON responses. +/// Implementations MUST return `Err(WellKnownFetchError::Status)` for any +/// status code other than 200 (after following redirects). Implementations MUST +/// report the wire `Content-Type` header value unmodified (or `None` if absent) +/// and MUST NOT synthesise an `application/json` content type for non-JSON +/// responses. #[async_trait] pub trait RelatedOriginsHttpClient: Send + Sync { async fn fetch_well_known( &self, rp_id: &RelyingPartyId, - ) -> Result; + ) -> Result; +} + +/// Failure modes for [`RelatedOriginsHttpClient::fetch_well_known`]. +#[derive(thiserror::Error, Debug, Clone)] +pub enum WellKnownFetchError { + /// Transport-level failure: TLS, DNS, timeout, rejected redirect, body + /// stream interrupt, client build error, etc. + #[error("transport error: {0}")] + Transport(String), + /// Endpoint replied with a non-200 status (after following redirects). + #[error("HTTP status {0}")] + Status(u16), + /// Body exceeded the implementation's configured size cap before completion. + #[error("body exceeded configured size cap")] + BodyTooLarge, + /// Implementation does not perform fetches (see [`NoRelatedOriginsClient`]). + #[error("client does not support related-origin fetches")] + NotSupported, } #[derive(thiserror::Error, Debug, Clone)] pub enum RelatedOriginsError { #[error("well-known fetch failed: {0}")] - FetchFailed(String), + Fetch(#[from] WellKnownFetchError), #[error("unexpected content type: {0:?}")] UnexpectedContentType(Option), /// Step 2.b: body did not decode as JSON. @@ -60,19 +79,6 @@ pub enum RelatedOriginsError { NoMatchingOrigin, } -impl RelatedOriginsError { - /// Log-safe variant discriminant; `Debug`/`Display` may carry reqwest/serde text with IPs or body snippets. - pub fn kind(&self) -> &'static str { - match self { - RelatedOriginsError::FetchFailed(_) => "fetch_failed", - RelatedOriginsError::UnexpectedContentType(_) => "unexpected_content_type", - RelatedOriginsError::MalformedJson(_) => "malformed_json", - RelatedOriginsError::MalformedDocument(_) => "malformed_document", - RelatedOriginsError::NoMatchingOrigin => "no_matching_origin", - } - } -} - pub type RelatedOriginsResult = Result<(), RelatedOriginsError>; #[derive(Debug, Deserialize)] @@ -192,10 +198,8 @@ impl RelatedOriginsHttpClient for NoRelatedOriginsClient { async fn fetch_well_known( &self, _: &RelyingPartyId, - ) -> Result { - Err(RelatedOriginsError::FetchFailed( - "this client does not support related origin requests".into(), - )) + ) -> Result { + Err(WellKnownFetchError::NotSupported) } } @@ -205,7 +209,7 @@ mod tests { use super::*; struct MockClient { - response: Result, + response: Result, } #[async_trait] @@ -213,7 +217,7 @@ mod tests { async fn fetch_well_known( &self, _: &RelyingPartyId, - ) -> Result { + ) -> Result { self.response.clone() } } @@ -670,9 +674,9 @@ mod tests { } #[tokio::test] - async fn fetch_error_propagates_as_fetch_failed() { + async fn fetch_error_propagates_as_fetch() { let http = MockClient { - response: Err(RelatedOriginsError::FetchFailed("simulated".into())), + response: Err(WellKnownFetchError::Transport("simulated".into())), }; let res = validate_related_origins( &caller("https://example.com"), @@ -681,7 +685,12 @@ mod tests { &http, ) .await; - assert!(matches!(res, Err(RelatedOriginsError::FetchFailed(_)))); + assert!(matches!( + res, + Err(RelatedOriginsError::Fetch(WellKnownFetchError::Transport( + _ + ))) + )); } #[tokio::test] diff --git a/libwebauthn/tests/related_origins.rs b/libwebauthn/tests/related_origins.rs index 8d8c4888..024ac22b 100644 --- a/libwebauthn/tests/related_origins.rs +++ b/libwebauthn/tests/related_origins.rs @@ -6,8 +6,8 @@ use async_trait::async_trait; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, MakeCredentialRequest, PublicSuffixList, RelatedOriginsError, - RelatedOriginsHttpClient, RelyingPartyId, RequestOrigin, WebAuthnIDL, WellKnownResponse, + GetAssertionRequest, MakeCredentialRequest, PublicSuffixList, RelatedOriginsHttpClient, + RelyingPartyId, RequestOrigin, WebAuthnIDL, WellKnownFetchError, WellKnownResponse, }; const KNOWN_SUFFIXES: &[&str] = &["com", "org"]; @@ -39,7 +39,7 @@ impl RelatedOriginsHttpClient for StaticHttp { async fn fetch_well_known( &self, _: &RelyingPartyId, - ) -> Result { + ) -> Result { Ok(WellKnownResponse { content_type: Some("application/json".into()), body: self.body.as_bytes().to_vec(), From fc8ebc47d4f60ed0c79beab7f8c76721e47faa3d Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 28 May 2026 23:38:01 +0100 Subject: [PATCH 22/29] refactor(webauthn): pluggable HttpClient and RelatedOriginsSource --- Cargo.lock | 1 + libwebauthn/Cargo.toml | 19 +- .../src/ops/webauthn/related_origins/http.rs | 102 -- .../src/ops/webauthn/related_origins/mod.rs | 927 ++++++++---------- .../webauthn/related_origins/reqwest_impl.rs | 118 +++ 5 files changed, 561 insertions(+), 606 deletions(-) delete mode 100644 libwebauthn/src/ops/webauthn/related_origins/http.rs create mode 100644 libwebauthn/src/ops/webauthn/related_origins/reqwest_impl.rs diff --git a/Cargo.lock b/Cargo.lock index c35ac071..5a4a754d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1965,6 +1965,7 @@ dependencies = [ "hidapi", "hkdf", "hmac 0.12.1", + "http", "idna", "maplit", "mockall", diff --git a/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml index e6d5c919..4be55763 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -39,10 +39,10 @@ nfc-backend-libnfc = [ # external crates (e.g. libwebauthn-tests) can plug in a virtual HID transport # for end-to-end tests. virt = [] -# Provides the reqwest-backed default RelatedOriginsHttpClient. Off by default so -# the core crate stays HTTP-client-free; consumers that want the default impl -# opt in, others bring their own client. -related-origins-client = ["dep:reqwest"] +# Provides the reqwest-backed HttpClient and ReqwestRelatedOriginsSource. Off by +# default so the core crate stays HTTP-client-free. Consumers that want the +# default fetch opt in, others implement HttpClient or RelatedOriginsSource. +reqwest-related-origins-source = ["dep:reqwest"] [dependencies] base64-url = "3.0.0" @@ -51,6 +51,7 @@ tracing = "0.1.29" idna = "1.0.3" publicsuffix = "2.3" url = "2.5" +http = "1" maplit = "1.0.2" sha2 = "0.10.2" uuid = { version = "1.5.0", features = ["serde", "v4"] } @@ -141,27 +142,27 @@ required-features = ["nfc"] [[example]] name = "webauthn_hid" path = "examples/ceremony/webauthn_hid.rs" -required-features = ["related-origins-client"] +required-features = ["reqwest-related-origins-source"] [[example]] name = "webauthn_ble" path = "examples/ceremony/webauthn_ble.rs" -required-features = ["related-origins-client"] +required-features = ["reqwest-related-origins-source"] [[example]] name = "webauthn_nfc" path = "examples/ceremony/webauthn_nfc.rs" -required-features = ["nfc", "related-origins-client"] +required-features = ["nfc", "reqwest-related-origins-source"] [[example]] name = "webauthn_cable" path = "examples/ceremony/webauthn_cable.rs" -required-features = ["related-origins-client"] +required-features = ["reqwest-related-origins-source"] [[example]] name = "webauthn_cable_wss" path = "examples/ceremony/webauthn_cable_wss.rs" -required-features = ["related-origins-client"] +required-features = ["reqwest-related-origins-source"] [[example]] name = "webauthn_extensions_hid" diff --git a/libwebauthn/src/ops/webauthn/related_origins/http.rs b/libwebauthn/src/ops/webauthn/related_origins/http.rs deleted file mode 100644 index 74443a53..00000000 --- a/libwebauthn/src/ops/webauthn/related_origins/http.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! reqwest-backed [`RelatedOriginsHttpClient`]. Gated by the -//! `related-origins-client` cargo feature. - -use std::time::Duration; - -use async_trait::async_trait; -use futures::StreamExt; -use reqwest::redirect::Policy; -use reqwest::{Client, StatusCode}; - -use super::{RelatedOriginsHttpClient, WellKnownFetchError, WellKnownResponse}; -use crate::ops::webauthn::idl::rpid::RelyingPartyId; - -#[derive(Debug, Clone)] -pub struct HttpPolicy { - pub request_timeout: Duration, - pub max_body_bytes: usize, - pub max_redirects: usize, -} - -impl Default for HttpPolicy { - fn default() -> Self { - Self { - request_timeout: Duration::from_secs(10), - max_body_bytes: 256 * 1024, - max_redirects: 5, - } - } -} - -#[derive(Debug, Clone)] -pub struct ReqwestRelatedOriginsClient { - client: Client, - max_body_bytes: usize, -} - -impl ReqwestRelatedOriginsClient { - pub fn new() -> Result { - Self::with_policy(HttpPolicy::default()) - } - - pub fn with_policy(policy: HttpPolicy) -> Result { - let max_redirects = policy.max_redirects; - let redirect_policy = Policy::custom(move |attempt| { - if attempt.previous().len() >= max_redirects { - return attempt.error("redirect limit exceeded"); - } - if attempt.url().scheme() != "https" { - return attempt.error("non-https redirect"); - } - attempt.follow() - }); - // WebAuthn L3 §5.11.1 step 2: fetch "without a referrer"; `cookies` - // feature is off, so reqwest holds no cookie jar. - let client = Client::builder() - .https_only(true) - .redirect(redirect_policy) - .referer(false) - .timeout(policy.request_timeout) - .build() - .map_err(|e| WellKnownFetchError::Transport(e.to_string()))?; - Ok(Self { - client, - max_body_bytes: policy.max_body_bytes, - }) - } -} - -#[async_trait] -impl RelatedOriginsHttpClient for ReqwestRelatedOriginsClient { - async fn fetch_well_known( - &self, - rp_id: &RelyingPartyId, - ) -> Result { - let url = format!("https://{}/.well-known/webauthn", rp_id.0); - let response = self - .client - .get(&url) - .send() - .await - .map_err(|e| WellKnownFetchError::Transport(e.to_string()))?; - if response.status() != StatusCode::OK { - return Err(WellKnownFetchError::Status(response.status().as_u16())); - } - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .map(str::to_owned); - - let mut body = Vec::with_capacity(8 * 1024); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| WellKnownFetchError::Transport(e.to_string()))?; - if body.len() + chunk.len() > self.max_body_bytes { - return Err(WellKnownFetchError::BodyTooLarge); - } - body.extend_from_slice(&chunk); - } - Ok(WellKnownResponse { content_type, body }) - } -} diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs index f692dc4e..53bd833d 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/mod.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -1,8 +1,7 @@ -//! Related-origins validation (WebAuthn L3 §5.11). -//! -//! The HTTP fetch of the `webauthn` well-known document is abstracted behind -//! [`RelatedOriginsHttpClient`]; a reqwest-backed default lives in [`http`] -//! behind the `related-origins-client` cargo feature. +//! Related-origins validation (WebAuthn L3 §5.11). Matching lives here in the +//! core. The allowed origins for an rp.id come from a [`RelatedOriginsSource`], +//! the default [`WellKnownRelatedOriginsSource`] fetching the well-known +//! document over an [`HttpClient`]. use std::collections::BTreeSet; @@ -14,59 +13,95 @@ use super::idl::origin::{Origin, Scheme}; use super::idl::rpid::RelyingPartyId; use super::psl::PublicSuffixList; -#[cfg(feature = "related-origins-client")] -pub mod http; +#[cfg(feature = "reqwest-related-origins-source")] +mod reqwest_impl; -#[cfg(feature = "related-origins-client")] -pub use http::{HttpPolicy, ReqwestRelatedOriginsClient}; +#[cfg(feature = "reqwest-related-origins-source")] +pub use reqwest_impl::{HttpPolicy, ReqwestHttpClient, ReqwestRelatedOriginsSource}; -/// WebAuthn L3 §5.11 minimum; capped at 5 to bound abuse surface. -const MAX_REGISTRABLE_LABELS: usize = 5; +/// Cap on distinct registrable-domain labels considered during matching +/// (WebAuthn L3 §5.11). Cannot hold a value below the spec floor of five. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MaxRegistrableLabels(usize); -#[derive(Debug, Clone)] -pub struct WellKnownResponse { - pub content_type: Option, - pub body: Vec, +impl MaxRegistrableLabels { + /// Absolute cap, or `None` if below the floor of five. + pub const fn new(n: usize) -> Option { + if n >= 5 { + Some(Self(n)) + } else { + None + } + } + + /// `n` labels beyond the floor of five. + pub const fn extra(n: usize) -> Self { + Self(5usize.saturating_add(n)) + } } -/// Fetcher for `https://{rp_id}/.well-known/webauthn`, per WebAuthn L3 §5.11.1 -/// step 2. Implementations MUST send no credentials, no Referer, refuse -/// non-`https://` redirects, cap the body size, and bound the request duration. -/// Implementations MUST return `Err(WellKnownFetchError::Status)` for any -/// status code other than 200 (after following redirects). Implementations MUST -/// report the wire `Content-Type` header value unmodified (or `None` if absent) -/// and MUST NOT synthesise an `application/json` content type for non-JSON -/// responses. -#[async_trait] -pub trait RelatedOriginsHttpClient: Send + Sync { - async fn fetch_well_known( - &self, - rp_id: &RelyingPartyId, - ) -> Result; +impl Default for MaxRegistrableLabels { + fn default() -> Self { + Self(5) + } +} + +impl From for usize { + fn from(value: MaxRegistrableLabels) -> Self { + value.0 + } } -/// Failure modes for [`RelatedOriginsHttpClient::fetch_well_known`]. +/// Transport failure modes for [`HttpClient::get`]. #[derive(thiserror::Error, Debug, Clone)] -pub enum WellKnownFetchError { - /// Transport-level failure: TLS, DNS, timeout, rejected redirect, body - /// stream interrupt, client build error, etc. +pub enum HttpClientError { + /// TLS, DNS, timeout, rejected redirect, stream interrupt, client build, etc. #[error("transport error: {0}")] Transport(String), - /// Endpoint replied with a non-200 status (after following redirects). - #[error("HTTP status {0}")] - Status(u16), /// Body exceeded the implementation's configured size cap before completion. - #[error("body exceeded configured size cap")] + #[error("response body exceeded the configured size cap")] BodyTooLarge, - /// Implementation does not perform fetches (see [`NoRelatedOriginsClient`]). - #[error("client does not support related-origin fetches")] - NotSupported, } +/// Minimal HTTP GET for fetching the well-known document. Implementations MUST +/// send no credentials or Referer, follow only `https://` redirects, and cap the +/// body size and duration. Status is returned as data, the body unparsed. +#[async_trait] +pub trait HttpClient: Send + Sync { + async fn get(&self, url: &Url) -> Result>, HttpClientError>; +} + +/// Supplies the set of allowed origins for an RP id (WebAuthn L3 §5.11). +/// [`WellKnownRelatedOriginsSource`] is the default, fetching the well-known +/// document; a caller that already holds the list may implement this directly. +#[async_trait] +pub trait RelatedOriginsSource: Send + Sync { + async fn allowed_origins( + &self, + rp_id: &RelyingPartyId, + ) -> Result, RelatedOriginsError>; +} + +/// How related-origins is handled for a request. +pub enum RelatedOrigins<'a> { + /// Do not consult related origins; a mismatching rp.id is rejected. + Disabled, + /// Resolve allowed origins through `source` and match, considering at most + /// `max_labels` distinct registrable-domain labels. + Enabled { + source: &'a dyn RelatedOriginsSource, + max_labels: MaxRegistrableLabels, + }, +} + +/// Failure modes when resolving or matching related origins. #[derive(thiserror::Error, Debug, Clone)] pub enum RelatedOriginsError { - #[error("well-known fetch failed: {0}")] - Fetch(#[from] WellKnownFetchError), + #[error("http error: {0}")] + Http(#[from] HttpClientError), + /// Endpoint replied with a non-200 status (after following redirects). + #[error("unexpected HTTP status {0}")] + UnexpectedStatus(u16), #[error("unexpected content type: {0:?}")] UnexpectedContentType(Option), /// Step 2.b: body did not decode as JSON. @@ -86,37 +121,70 @@ struct WellKnownDocument { origins: Vec, } -/// Runs the WebAuthn L3 §5.11.1 related-origins validation procedure. -pub async fn validate_related_origins( - caller_origin: &Origin, - rp_id: &RelyingPartyId, - psl: &dyn PublicSuffixList, - http: &dyn RelatedOriginsHttpClient, -) -> RelatedOriginsResult { - let resp = http.fetch_well_known(rp_id).await?; - let content_type_ok = resp - .content_type - .as_deref() - .map(is_application_json) - .unwrap_or(false); - if !content_type_ok { - return Err(RelatedOriginsError::UnexpectedContentType( - resp.content_type, - )); +/// [`RelatedOriginsSource`] that fetches `https://{rp_id}/.well-known/webauthn` +/// over an [`HttpClient`] and returns its `origins` array (WebAuthn L3 §5.11.1 +/// step 2). Generic over the transport. +pub struct WellKnownRelatedOriginsSource { + http: C, +} + +impl WellKnownRelatedOriginsSource { + /// Wrap an [`HttpClient`] as a well-known related-origins source. + pub fn from_client(http: C) -> Self { + Self { http } } +} - let value: serde_json::Value = serde_json::from_slice(&resp.body) - .map_err(|e| RelatedOriginsError::MalformedJson(e.to_string()))?; - if !value.is_object() { - return Err(RelatedOriginsError::MalformedJson( - "top-level value is not a JSON object".into(), - )); +#[async_trait] +impl RelatedOriginsSource for WellKnownRelatedOriginsSource { + async fn allowed_origins( + &self, + rp_id: &RelyingPartyId, + ) -> Result, RelatedOriginsError> { + let url = Url::parse(&format!("https://{}/.well-known/webauthn", rp_id.0)) + .map_err(|e| RelatedOriginsError::MalformedDocument(format!("invalid rp id: {e}")))?; + let resp = self.http.get(&url).await?; + if resp.status() != http::StatusCode::OK { + return Err(RelatedOriginsError::UnexpectedStatus(resp.status().as_u16())); + } + let content_type = resp + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let content_type_ok = content_type + .as_deref() + .map(is_application_json) + .unwrap_or(false); + if !content_type_ok { + return Err(RelatedOriginsError::UnexpectedContentType(content_type)); + } + + let value: serde_json::Value = serde_json::from_slice(resp.body()) + .map_err(|e| RelatedOriginsError::MalformedJson(e.to_string()))?; + if !value.is_object() { + return Err(RelatedOriginsError::MalformedJson( + "top-level value is not a JSON object".into(), + )); + } + let doc: WellKnownDocument = serde_json::from_value(value) + .map_err(|e| RelatedOriginsError::MalformedDocument(e.to_string()))?; + Ok(doc.origins) } - let doc: WellKnownDocument = serde_json::from_value(value) - .map_err(|e| RelatedOriginsError::MalformedDocument(e.to_string()))?; +} +/// Runs the WebAuthn L3 §5.11.1 matching procedure: returns `Ok(())` if +/// `caller_origin` is same-origin with one of `origins`, considering at most +/// `max_labels` distinct registrable-domain labels. +pub fn validate_related_origins( + caller_origin: &Origin, + origins: &[String], + psl: &dyn PublicSuffixList, + max_labels: MaxRegistrableLabels, +) -> RelatedOriginsResult { + let cap: usize = max_labels.into(); let mut labels_seen: BTreeSet = BTreeSet::new(); - for origin_item in &doc.origins { + for origin_item in origins { let Ok(url) = Url::parse(origin_item) else { continue; }; @@ -127,13 +195,13 @@ pub async fn validate_related_origins( Some(l) if !l.is_empty() => l, _ => continue, }; - if labels_seen.len() >= MAX_REGISTRABLE_LABELS && !labels_seen.contains(&label) { + if labels_seen.len() >= cap && !labels_seen.contains(&label) { continue; } if same_origin(caller_origin, &url) { return Ok(()); } - if labels_seen.len() < MAX_REGISTRABLE_LABELS { + if labels_seen.len() < cap { labels_seen.insert(label); } } @@ -151,7 +219,7 @@ pub(crate) fn registrable_origin_label(host: &str, psl: &dyn PublicSuffixList) - Some(label.to_string()) } -/// Effective domain of `url` per HTML §6.2; `None` for opaque or host-less URLs. +/// Effective domain of `url` per HTML §6.2, or `None` for opaque or host-less URLs. fn effective_domain_of(url: &Url) -> Option { match url.host()? { Host::Domain(d) => Some(d.to_string()), @@ -160,7 +228,7 @@ fn effective_domain_of(url: &Url) -> Option { } } -/// Tuple-origin equality (scheme, host, port) per WebAuthn L3 §5.11.1 step 4.f and HTML §7.5; userinfo, path, query and fragment on `listed` are ignored. +/// Tuple-origin (scheme, host, port) equality per WebAuthn L3 §5.11.1 step 4.f. fn same_origin(caller: &Origin, listed: &Url) -> bool { if caller.scheme.as_str() != listed.scheme() { return false; @@ -189,54 +257,48 @@ fn is_application_json(value: &str) -> bool { essence.eq_ignore_ascii_case("application/json") } -/// `RelatedOriginsHttpClient` that always refuses; preserves strict rp.id matching when callers do not opt into related-origin fetches. -#[derive(Debug, Clone, Copy, Default)] -pub struct NoRelatedOriginsClient; - -#[async_trait] -impl RelatedOriginsHttpClient for NoRelatedOriginsClient { - async fn fetch_well_known( - &self, - _: &RelyingPartyId, - ) -> Result { - Err(WellKnownFetchError::NotSupported) - } -} - #[cfg(test)] mod tests { use super::super::psl::MockPublicSuffixList; use super::*; - struct MockClient { - response: Result, + fn caller(s: &str) -> Origin { + Origin::try_from(s).unwrap() } - #[async_trait] - impl RelatedOriginsHttpClient for MockClient { - async fn fetch_well_known( - &self, - _: &RelyingPartyId, - ) -> Result { - self.response.clone() - } + fn rp(s: &str) -> RelyingPartyId { + RelyingPartyId::try_from(s).unwrap() } - fn json_ct(body: &str) -> WellKnownResponse { - WellKnownResponse { - content_type: Some("application/json".into()), - body: body.as_bytes().to_vec(), - } + fn origins(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() } - fn caller(s: &str) -> Origin { - Origin::try_from(s).unwrap() + /// Matches a caller origin against `items` with the default cap (5). + fn validate(caller_s: &str, items: &[&str]) -> RelatedOriginsResult { + validate_related_origins( + &caller(caller_s), + &origins(items), + &MockPublicSuffixList, + MaxRegistrableLabels::default(), + ) } - fn rp(s: &str) -> RelyingPartyId { - RelyingPartyId::try_from(s).unwrap() + // ---- MaxRegistrableLabels ---- + + #[test] + fn max_registrable_labels_enforces_floor() { + assert_eq!(MaxRegistrableLabels::new(4), None); + assert_eq!(MaxRegistrableLabels::new(0), None); + assert_eq!(usize::from(MaxRegistrableLabels::new(5).unwrap()), 5); + assert_eq!(usize::from(MaxRegistrableLabels::new(50).unwrap()), 50); + assert_eq!(usize::from(MaxRegistrableLabels::extra(0)), 5); + assert_eq!(usize::from(MaxRegistrableLabels::extra(3)), 8); + assert_eq!(usize::from(MaxRegistrableLabels::default()), 5); } + // ---- registrable_origin_label ---- + #[test] fn registrable_origin_label_basic() { let psl = MockPublicSuffixList; @@ -258,487 +320,362 @@ mod tests { assert_eq!(registrable_origin_label("127.0.0.1", &psl), None); } - #[tokio::test] - async fn same_origin_caller_listed_first() { - let body = r#"{"origins":["https://example.com"]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Ok(()))); + // ---- matching (validate_related_origins) ---- + + #[test] + fn same_origin_caller_listed_first() { + assert!(matches!( + validate("https://example.com", &["https://example.com"]), + Ok(()) + )); } - #[tokio::test] - async fn same_origin_with_port_match() { - let body = r#"{"origins":["https://example.com:8443"]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; - let res = validate_related_origins( - &caller("https://example.com:8443"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Ok(()))); + #[test] + fn same_origin_with_port_match() { + assert!(matches!( + validate("https://example.com:8443", &["https://example.com:8443"]), + Ok(()) + )); } - #[tokio::test] - async fn same_origin_with_port_mismatch_rejected() { - let body = r#"{"origins":["https://example.com:8443"]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); + #[test] + fn same_origin_with_port_mismatch_rejected() { + assert!(matches!( + validate("https://example.com", &["https://example.com:8443"]), + Err(RelatedOriginsError::NoMatchingOrigin) + )); } - #[tokio::test] - async fn same_origin_default_port_normalised() { - let body = r#"{"origins":["https://example.com:443"]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Ok(()))); + #[test] + fn same_origin_default_port_normalised() { + assert!(matches!( + validate("https://example.com", &["https://example.com:443"]), + Ok(()) + )); } - #[tokio::test] - async fn caller_listed_after_other_origins() { + #[test] + fn caller_listed_after_other_origins() { // Substituted `.de` with `.net` (MockPublicSuffixList lacks `.de`). - let body = r#"{"origins":["https://other.net","https://example.com"]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Ok(()))); + assert!(matches!( + validate( + "https://example.com", + &["https://other.net", "https://example.com"] + ), + Ok(()) + )); } - #[tokio::test] - async fn label_cap_allows_fifth_distinct_label_match() { - // §5.11.1 step 4.e: the 5th distinct label is still within the cap. - let body = r#"{"origins":[ - "https://a.com", - "https://b.com", - "https://c.com", - "https://d.com", - "https://example.com" - ]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; + #[test] + fn label_cap_allows_fifth_distinct_label_match() { + // The 5th distinct label is still within the cap. + assert!(matches!( + validate( + "https://example.com", + &[ + "https://a.com", + "https://b.com", + "https://c.com", + "https://d.com", + "https://example.com", + ] + ), + Ok(()) + )); + } + + #[test] + fn label_cap_blocks_sixth_distinct_label_match() { + // A sixth distinct label is silently skipped, so the would-be match + // never reaches the same-origin check. + assert!(matches!( + validate( + "https://example.com", + &[ + "https://a.com", + "https://b.com", + "https://c.com", + "https://d.com", + "https://e.com", + "https://example.com", + ] + ), + Err(RelatedOriginsError::NoMatchingOrigin) + )); + } + + #[test] + fn higher_cap_allows_sixth_distinct_label_match() { + // With a cap of 6 the previously-blocked sixth label matches. let res = validate_related_origins( &caller("https://example.com"), - &rp("example.com"), + &origins(&[ + "https://a.com", + "https://b.com", + "https://c.com", + "https://d.com", + "https://e.com", + "https://example.com", + ]), &MockPublicSuffixList, - &http, - ) - .await; + MaxRegistrableLabels::extra(1), + ); assert!(matches!(res, Ok(()))); } - #[tokio::test] - async fn label_cap_blocks_sixth_distinct_label_match() { - // §5.11.1 step 4.e: a sixth distinct label is silently skipped, so the - // would-be match never reaches step 4.f. - let body = r#"{"origins":[ - "https://a.com", - "https://b.com", - "https://c.com", - "https://d.com", - "https://e.com", - "https://example.com" - ]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); + #[test] + fn label_cap_allows_repeats_of_seen_label() { + // Once `example` has been recorded, further `example`-label origins + // still proceed to the same-origin check. + assert!(matches!( + validate( + "https://login.example.com", + &[ + "https://a.example.com", + "https://b.example.com", + "https://c.example.com", + "https://d.example.com", + "https://e.example.com", + "https://login.example.com", + ] + ), + Ok(()) + )); } - #[tokio::test] - async fn label_cap_allows_repeats_of_seen_label() { - // §5.11.1 step 4.e "contains label" exception: once `example` has been - // recorded, further `example`-label origins still proceed to step 4.f. - let body = r#"{"origins":[ - "https://a.example.com", - "https://b.example.com", - "https://c.example.com", - "https://d.example.com", - "https://e.example.com", - "https://login.example.com" - ]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; - let res = validate_related_origins( - &caller("https://login.example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Ok(()))); + #[test] + fn https_caller_vs_http_listed_rejected() { + // Scheme differs, so the listed http origin is never same-origin. + assert!(matches!( + validate("https://example.com", &["http://example.com"]), + Err(RelatedOriginsError::NoMatchingOrigin) + )); } - #[tokio::test] - async fn same_origin_https_vs_http_rejected() { - // `http://example.com` is rejected by `Origin::try_from` (non-localhost), - // so the listed entry can never be same-origin with the https caller. - let body = r#"{"origins":["http://example.com"]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); + #[test] + fn unparseable_origin_item_skipped() { + assert!(matches!( + validate( + "https://example.com", + &["not a url", "https://example.com"] + ), + Ok(()) + )); } - #[tokio::test] - async fn unparseable_origin_item_skipped() { - let body = r#"{"origins":["not a url","https://example.com"]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Ok(()))); + #[test] + fn non_https_origin_item_skipped_not_rejected() { + assert!(matches!( + validate( + "https://example.com", + &["data:text/plain,foo", "https://example.com"] + ), + Ok(()) + )); } - #[tokio::test] - async fn non_https_origin_item_skipped_not_rejected() { - let body = r#"{"origins":["data:text/plain,foo","https://example.com"]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Ok(()))); + #[test] + fn unknown_suffix_origin_skipped() { + // `internal.localhost` has no registrable domain in MockPSL. + assert!(matches!( + validate( + "https://example.com", + &["https://internal.localhost", "https://example.com"] + ), + Ok(()) + )); + } + + #[test] + fn bare_etld_origin_skipped() { + // Registrable origin label is None for `co.uk`. + assert!(matches!( + validate( + "https://example.co.uk", + &["https://co.uk", "https://example.co.uk"] + ), + Ok(()) + )); + } + + #[test] + fn no_match_returns_no_matching_origin() { + assert!(matches!( + validate("https://example.com", &["https://elsewhere.com"]), + Err(RelatedOriginsError::NoMatchingOrigin) + )); + } + + #[test] + fn empty_origins_no_match() { + assert!(matches!( + validate("https://example.com", &[]), + Err(RelatedOriginsError::NoMatchingOrigin) + )); + } + + #[test] + fn listed_origin_with_path_still_matches() { + // Same-origin compares (scheme, host, port) only, so a trailing path on + // the listed entry must not block the match. + assert!(matches!( + validate("https://example.com", &["https://example.com/foo"]), + Ok(()) + )); + } + + #[test] + fn ipv6_listed_origin_skipped_no_registrable_label() { + // IPv6 host has no registrable label; the loop skips it. + assert!(matches!( + validate("https://[::1]", &["https://[::1]"]), + Err(RelatedOriginsError::NoMatchingOrigin) + )); + } + + // ---- well-known source fetch/parse ---- + + struct MockHttpClient { + status: u16, + content_type: Option, + body: Vec, + } + + #[async_trait] + impl HttpClient for MockHttpClient { + async fn get(&self, _url: &Url) -> Result>, HttpClientError> { + let mut builder = http::Response::builder().status(self.status); + if let Some(ct) = &self.content_type { + builder = builder.header(http::header::CONTENT_TYPE, ct); + } + Ok(builder.body(self.body.clone()).unwrap()) + } + } + + struct ErrHttpClient(HttpClientError); + + #[async_trait] + impl HttpClient for ErrHttpClient { + async fn get(&self, _url: &Url) -> Result>, HttpClientError> { + Err(self.0.clone()) + } + } + + async fn fetch( + status: u16, + content_type: Option<&str>, + body: &str, + ) -> Result, RelatedOriginsError> { + let source = WellKnownRelatedOriginsSource::from_client(MockHttpClient { + status, + content_type: content_type.map(str::to_owned), + body: body.as_bytes().to_vec(), + }); + source.allowed_origins(&rp("example.com")).await } #[tokio::test] - async fn unknown_suffix_origin_skipped() { - // `internal.localhost` has no registrable domain in MockPSL. - let body = r#"{"origins":["https://internal.localhost","https://example.com"]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Ok(()))); + async fn well_known_returns_origins() { + let res = fetch(200, Some("application/json"), r#"{"origins":["https://example.com"]}"#).await; + assert_eq!(res.unwrap(), vec!["https://example.com".to_string()]); } #[tokio::test] - async fn bare_etld_origin_skipped() { - // §5.11.1 step 4.c returns None for `co.uk`. - let body = r#"{"origins":["https://co.uk","https://example.co.uk"]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; - let res = validate_related_origins( - &caller("https://example.co.uk"), - &rp("example.co.uk"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Ok(()))); + async fn non_200_status_rejected() { + assert!(matches!( + fetch(404, Some("application/json"), r#"{"origins":[]}"#).await, + Err(RelatedOriginsError::UnexpectedStatus(404)) + )); } #[tokio::test] async fn wrong_content_type_rejected() { - let http = MockClient { - response: Ok(WellKnownResponse { - content_type: Some("text/html".into()), - body: b"{\"origins\":[]}".to_vec(), - }), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; assert!(matches!( - res, + fetch(200, Some("text/html"), r#"{"origins":[]}"#).await, Err(RelatedOriginsError::UnexpectedContentType(_)) )); } #[tokio::test] async fn missing_content_type_rejected() { - let http = MockClient { - response: Ok(WellKnownResponse { - content_type: None, - body: b"{\"origins\":[]}".to_vec(), - }), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; assert!(matches!( - res, + fetch(200, None, r#"{"origins":[]}"#).await, Err(RelatedOriginsError::UnexpectedContentType(None)) )); } #[tokio::test] async fn content_type_with_charset_accepted() { - let http = MockClient { - response: Ok(WellKnownResponse { - content_type: Some("application/json; charset=utf-8".into()), - body: br#"{"origins":["https://elsewhere.com"]}"#.to_vec(), - }), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, + let res = fetch( + 200, + Some("application/json; charset=utf-8"), + r#"{"origins":["https://elsewhere.com"]}"#, ) .await; - assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); + assert_eq!(res.unwrap(), vec!["https://elsewhere.com".to_string()]); } #[tokio::test] async fn content_type_case_insensitive() { - let http = MockClient { - response: Ok(WellKnownResponse { - content_type: Some("Application/JSON".into()), - body: br#"{"origins":["https://example.com"]}"#.to_vec(), - }), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, + let res = fetch( + 200, + Some("Application/JSON"), + r#"{"origins":["https://example.com"]}"#, ) .await; - assert!(matches!(res, Ok(()))); + assert_eq!(res.unwrap(), vec!["https://example.com".to_string()]); } #[tokio::test] async fn malformed_json_rejected() { - let http = MockClient { - response: Ok(json_ct("{not json}")), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Err(RelatedOriginsError::MalformedJson(_)))); + assert!(matches!( + fetch(200, Some("application/json"), "{not json}").await, + Err(RelatedOriginsError::MalformedJson(_)) + )); } #[tokio::test] async fn non_object_json_rejected() { - let http = MockClient { - response: Ok(json_ct("[1,2,3]")), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Err(RelatedOriginsError::MalformedJson(_)))); + assert!(matches!( + fetch(200, Some("application/json"), "[1,2,3]").await, + Err(RelatedOriginsError::MalformedJson(_)) + )); } #[tokio::test] async fn missing_origins_key_rejected() { - let http = MockClient { - response: Ok(json_ct("{}")), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; assert!(matches!( - res, + fetch(200, Some("application/json"), "{}").await, Err(RelatedOriginsError::MalformedDocument(_)) )); } #[tokio::test] async fn origins_not_array_rejected() { - let http = MockClient { - response: Ok(json_ct(r#"{"origins":"https://example.com"}"#)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; assert!(matches!( - res, + fetch(200, Some("application/json"), r#"{"origins":"https://example.com"}"#).await, Err(RelatedOriginsError::MalformedDocument(_)) )); } #[tokio::test] async fn origins_array_of_non_strings_rejected() { - let http = MockClient { - response: Ok(json_ct(r#"{"origins":[1,2,3]}"#)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; assert!(matches!( - res, + fetch(200, Some("application/json"), r#"{"origins":[1,2,3]}"#).await, Err(RelatedOriginsError::MalformedDocument(_)) )); } #[tokio::test] - async fn empty_origins_array_no_match() { - let http = MockClient { - response: Ok(json_ct(r#"{"origins":[]}"#)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); - } - - #[tokio::test] - async fn fetch_error_propagates_as_fetch() { - let http = MockClient { - response: Err(WellKnownFetchError::Transport("simulated".into())), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; + async fn transport_error_propagates_as_http() { + let source = + WellKnownRelatedOriginsSource::from_client(ErrHttpClient(HttpClientError::Transport( + "simulated".into(), + ))); + let res = source.allowed_origins(&rp("example.com")).await; assert!(matches!( res, - Err(RelatedOriginsError::Fetch(WellKnownFetchError::Transport( - _ - ))) + Err(RelatedOriginsError::Http(HttpClientError::Transport(_))) )); } - - #[tokio::test] - async fn no_match_returns_no_matching_origin() { - let http = MockClient { - response: Ok(json_ct(r#"{"origins":["https://elsewhere.com"]}"#)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); - } - - #[tokio::test] - async fn listed_origin_with_path_still_matches() { - // §5.11.1 step 4.f: same-origin compares (scheme, host, port) only, so - // a trailing path on the listed entry must not block the match. - let body = r#"{"origins":["https://example.com/foo"]}"#; - let http = MockClient { - response: Ok(json_ct(body)), - }; - let res = validate_related_origins( - &caller("https://example.com"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Ok(()))); - } - - #[tokio::test] - async fn ipv6_listed_origin_skipped_no_registrable_label() { - // IPv6 host has no registrable label; loop skips at step 4.c/4.d. - let http = MockClient { - response: Ok(json_ct(r#"{"origins":["https://[::1]"]}"#)), - }; - let res = validate_related_origins( - &caller("https://[::1]"), - &rp("example.com"), - &MockPublicSuffixList, - &http, - ) - .await; - assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin))); - } } diff --git a/libwebauthn/src/ops/webauthn/related_origins/reqwest_impl.rs b/libwebauthn/src/ops/webauthn/related_origins/reqwest_impl.rs new file mode 100644 index 00000000..e62bfff6 --- /dev/null +++ b/libwebauthn/src/ops/webauthn/related_origins/reqwest_impl.rs @@ -0,0 +1,118 @@ +//! reqwest-backed [`HttpClient`] and the convenience [`ReqwestRelatedOriginsSource`]. +//! Gated by the `reqwest-related-origins-source` cargo feature. + +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use http::{HeaderMap, Response, StatusCode}; +use reqwest::redirect::Policy; +use reqwest::Client; +use url::Url; + +use super::{HttpClient, HttpClientError, WellKnownRelatedOriginsSource}; + +#[derive(Debug, Clone)] +pub struct HttpPolicy { + pub request_timeout: Duration, + pub max_body_bytes: usize, + pub max_redirects: usize, +} + +impl Default for HttpPolicy { + fn default() -> Self { + Self { + request_timeout: Duration::from_secs(10), + max_body_bytes: 256 * 1024, + max_redirects: 5, + } + } +} + +/// reqwest-backed [`HttpClient`]. Enforces https-only requests and redirects, +/// sends no credentials or Referer, caps the body size, and bounds the request +/// duration. +#[derive(Debug, Clone)] +pub struct ReqwestHttpClient { + client: Client, + max_body_bytes: usize, +} + +impl ReqwestHttpClient { + pub fn new() -> Result { + Self::with_policy(HttpPolicy::default()) + } + + pub fn with_policy(policy: HttpPolicy) -> Result { + let max_redirects = policy.max_redirects; + let redirect_policy = Policy::custom(move |attempt| { + if attempt.previous().len() >= max_redirects { + return attempt.error("redirect limit exceeded"); + } + if attempt.url().scheme() != "https" { + return attempt.error("non-https redirect"); + } + attempt.follow() + }); + // WebAuthn L3 §5.11.1 step 2: no referrer. The `cookies` feature is off, + // so reqwest sends no credentials. + let client = Client::builder() + .https_only(true) + .redirect(redirect_policy) + .referer(false) + .timeout(policy.request_timeout) + .build() + .map_err(|e| HttpClientError::Transport(e.to_string()))?; + Ok(Self { + client, + max_body_bytes: policy.max_body_bytes, + }) + } +} + +#[async_trait] +impl HttpClient for ReqwestHttpClient { + async fn get(&self, url: &Url) -> Result>, HttpClientError> { + let response = self + .client + .get(url.clone()) + .send() + .await + .map_err(|e| HttpClientError::Transport(e.to_string()))?; + let status: StatusCode = response.status(); + let headers: HeaderMap = response.headers().clone(); + + let mut body = Vec::with_capacity(8 * 1024); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| HttpClientError::Transport(e.to_string()))?; + if body.len() + chunk.len() > self.max_body_bytes { + return Err(HttpClientError::BodyTooLarge); + } + body.extend_from_slice(&chunk); + } + + let mut out = Response::new(body); + *out.status_mut() = status; + *out.headers_mut() = headers; + Ok(out) + } +} + +/// reqwest-backed [`RelatedOriginsSource`]: a [`WellKnownRelatedOriginsSource`] +/// over a [`ReqwestHttpClient`]. +/// +/// [`RelatedOriginsSource`]: super::RelatedOriginsSource +pub type ReqwestRelatedOriginsSource = WellKnownRelatedOriginsSource; + +impl WellKnownRelatedOriginsSource { + /// Build with the default [`HttpPolicy`]. + pub fn new() -> Result { + Ok(Self::from_client(ReqwestHttpClient::new()?)) + } + + /// Build with a custom [`HttpPolicy`]. + pub fn with_policy(policy: HttpPolicy) -> Result { + Ok(Self::from_client(ReqwestHttpClient::with_policy(policy)?)) + } +} From 0617c6c3022bdc11b5d0d8d47997057f820b0eeb Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 28 May 2026 23:38:01 +0100 Subject: [PATCH 23/29] feat(webauthn): prepare() request builder with RequestSettings --- libwebauthn/src/ops/webauthn/get_assertion.rs | 228 +++++++++-------- libwebauthn/src/ops/webauthn/idl/mod.rs | 81 +++--- .../src/ops/webauthn/make_credential.rs | 230 ++++++++++-------- libwebauthn/src/ops/webauthn/mod.rs | 19 +- 4 files changed, 317 insertions(+), 241 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index cd5d05d5..76d0fe01 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -14,17 +14,14 @@ use crate::{ HmacGetSecretInputJson, LargeBlobInputJson, PrfInputJson, PublicKeyCredentialRequestOptionsJSON, }, - origin::is_registrable_domain_suffix_or_equal, response::{ AuthenticationExtensionsClientOutputsJSON, AuthenticationResponseJSON, AuthenticatorAssertionResponseJSON, HMACGetSecretOutputJSON, LargeBlobOutputJSON, PRFOutputJSON, PRFValuesJSON, ResponseSerializationError, WebAuthnIDLResponse, }, - Base64UrlString, FromIdlModel, JsonError, + rp_id_authorised, Base64UrlString, FromIdlModel, JsonError, RequestSettings, }, - psl::PublicSuffixList, - related_origins::{validate_related_origins, RelatedOriginsHttpClient}, - Operation, WebAuthnIDL, + Operation, }, pin::PinUvAuthProtocol, proto::ctap2::{ @@ -111,7 +108,7 @@ impl GetAssertionRequest { } #[derive(thiserror::Error, Debug)] -pub enum GetAssertionRequestParsingError { +pub enum GetAssertionPrepareError { /// The client must throw an "EncodingError" DOMException. #[error("Invalid JSON format: {0}")] EncodingError(#[from] JsonError), @@ -137,24 +134,32 @@ pub enum GetAssertionRequestParsingError { /// Per spec the AppID should be a same-site URL (typically `https:///...`). /// Full same-site validation against the rpId is not yet implemented; for now /// we require non-empty input, an absolute URL form, and the `https` scheme. -fn validate_appid(appid: &str) -> Result { +fn validate_appid(appid: &str) -> Result { if appid.is_empty() { - return Err(GetAssertionRequestParsingError::InvalidAppId( + return Err(GetAssertionPrepareError::InvalidAppId( "appid must not be empty".to_string(), )); } // Sanity check: must be an https URL. if !appid.starts_with("https://") { - return Err(GetAssertionRequestParsingError::InvalidAppId(format!( + return Err(GetAssertionPrepareError::InvalidAppId(format!( "appid must be an https URL, got: {appid}" ))); } Ok(appid.to_string()) } -impl WebAuthnIDL for GetAssertionRequest { - type Error = GetAssertionRequestParsingError; - type IdlModel = PublicKeyCredentialRequestOptionsJSON; +impl GetAssertionRequest { + /// Builds a [`GetAssertionRequest`] from its WebAuthn IDL JSON, validating + /// the caller origin against rp.id per `settings`. + pub async fn prepare( + request_origin: &RequestOrigin, + json: &str, + settings: &RequestSettings<'_>, + ) -> Result { + let model: PublicKeyCredentialRequestOptionsJSON = serde_json::from_str(json)?; + Self::from_idl_model(request_origin, settings, model).await + } } /** dictionary PublicKeyCredentialRequestOptionsJSON { @@ -167,30 +172,24 @@ impl WebAuthnIDL for GetAssertionRequest { AuthenticationExtensionsClientInputsJSON extensions; }; */ #[async_trait] -impl FromIdlModel - for GetAssertionRequest -{ +impl FromIdlModel for GetAssertionRequest { + type Error = GetAssertionPrepareError; + async fn from_idl_model( request_origin: &RequestOrigin, - psl: &dyn PublicSuffixList, - http: &dyn RelatedOriginsHttpClient, + settings: &RequestSettings<'_>, inner: PublicKeyCredentialRequestOptionsJSON, - ) -> Result { + ) -> Result { let effective_rp_id = request_origin.origin.host.as_str(); let resolved_rp_id = if let Some(relying_party_id) = inner.relying_party_id.as_deref() { let parsed = RelyingPartyId::try_from(relying_party_id).map_err(|err| { - GetAssertionRequestParsingError::InvalidRelyingPartyId(err.to_string()) + GetAssertionPrepareError::InvalidRelyingPartyId(err.to_string()) })?; - if !is_registrable_domain_suffix_or_equal(&parsed.0, effective_rp_id, psl) { - if let Err(err) = - validate_related_origins(&request_origin.origin, &parsed, psl, http).await - { - debug!(rp_id = %parsed.0, %err, "Related-origins validation failed"); - return Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( - parsed.0, - effective_rp_id.to_string(), - )); - } + if !rp_id_authorised(request_origin, &parsed, settings).await { + return Err(GetAssertionPrepareError::MismatchingRelyingPartyId( + parsed.0, + effective_rp_id.to_string(), + )); } parsed.0 } else { @@ -262,7 +261,7 @@ pub struct PrfInput { } impl TryFrom for PrfInput { - type Error = GetAssertionRequestParsingError; + type Error = GetAssertionPrepareError; fn try_from(value: PrfInputJson) -> Result { let eval = value.eval.map(|v| PrfInputValue { @@ -306,18 +305,18 @@ pub struct HMACGetSecretInput { } impl TryFrom for HMACGetSecretInput { - type Error = GetAssertionRequestParsingError; + type Error = GetAssertionPrepareError; fn try_from(value: HmacGetSecretInputJson) -> Result { let salt1 = value.salt1.as_slice().try_into().map_err(|_| { - GetAssertionRequestParsingError::UnexpectedLengthError( + GetAssertionPrepareError::UnexpectedLengthError( "extensions.hmacCreateSecret.salt1".to_string(), value.salt1.as_slice().len(), ) })?; let salt2 = match value.salt2 { Some(s) => Some(s.as_slice().try_into().map_err(|_| { - GetAssertionRequestParsingError::UnexpectedLengthError( + GetAssertionPrepareError::UnexpectedLengthError( "extensions.hmacCreateSecret.salt2".to_string(), s.as_slice().len(), ) @@ -336,15 +335,15 @@ pub enum GetAssertionLargeBlobExtension { } impl TryFrom for GetAssertionLargeBlobExtension { - type Error = GetAssertionRequestParsingError; + type Error = GetAssertionPrepareError; fn try_from(value: LargeBlobInputJson) -> Result { match value.read { Some(true) => Ok(GetAssertionLargeBlobExtension::Read), - Some(false) => Err(GetAssertionRequestParsingError::NotSupported( + Some(false) => Err(GetAssertionPrepareError::NotSupported( "largeBlob writes not supported".to_string(), )), - None => Err(GetAssertionRequestParsingError::NotSupported( + None => Err(GetAssertionPrepareError::NotSupported( "largeBlob read not requested".to_string(), )), } @@ -660,55 +659,68 @@ mod tests { use async_trait::async_trait; use serde_bytes::ByteBuf; - use crate::ops::webauthn::psl::MockPublicSuffixList; + use crate::ops::webauthn::psl::{MockPublicSuffixList, PublicSuffixList}; use crate::ops::webauthn::related_origins::{ - RelatedOriginsHttpClient, WellKnownFetchError, WellKnownResponse, + HttpClientError, MaxRegistrableLabels, RelatedOrigins, RelatedOriginsError, + RelatedOriginsSource, }; - use crate::ops::webauthn::{GetAssertionRequest, NoRelatedOriginsClient, RequestOrigin}; + use crate::ops::webauthn::{GetAssertionRequest, RequestOrigin}; use crate::proto::ctap2::Ctap2PublicKeyCredentialType; use super::*; - /// Test-only HTTP client backed by a fixed response. `panicking` proves the - /// suffix-check short-circuit by failing the test if the fetch is invoked. - struct MockHttpClient { - response: Option>, + // Fixed-result source; `panicking` proves the suffix-check short-circuit by + // failing if consulted. + struct MockSource { + result: Option, RelatedOriginsError>>, } - impl MockHttpClient { - fn ok_body(body: &str) -> Self { + impl MockSource { + fn origins(items: &[&str]) -> Self { Self { - response: Some(Ok(WellKnownResponse { - content_type: Some("application/json".into()), - body: body.as_bytes().to_vec(), - })), + result: Some(Ok(items.iter().map(|s| s.to_string()).collect())), } } - fn err(e: WellKnownFetchError) -> Self { - Self { - response: Some(Err(e)), - } + fn err(e: RelatedOriginsError) -> Self { + Self { result: Some(Err(e)) } } fn panicking() -> Self { - Self { response: None } + Self { result: None } } } #[async_trait] - impl RelatedOriginsHttpClient for MockHttpClient { - async fn fetch_well_known( + impl RelatedOriginsSource for MockSource { + async fn allowed_origins( &self, _: &RelyingPartyId, - ) -> Result { - match &self.response { + ) -> Result, RelatedOriginsError> { + match &self.result { Some(r) => r.clone(), - None => panic!("fetch_well_known should not be called"), + None => panic!("allowed_origins should not be called"), } } } + async fn from_json( + origin: &RequestOrigin, + psl: &dyn PublicSuffixList, + related_origins: RelatedOrigins<'_>, + json: &str, + ) -> Result { + GetAssertionRequest::prepare( + origin, + json, + &RequestSettings { + public_suffix_list: psl, + related_origins, + }, + ) + .await + } + pub const REQUEST_BASE_JSON: &str = r#" { "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu", @@ -758,10 +770,10 @@ mod tests { #[tokio::test] async fn test_request_from_json_base() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); - let req: GetAssertionRequest = GetAssertionRequest::from_json( + let req: GetAssertionRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, REQUEST_BASE_JSON, ) .await @@ -774,10 +786,10 @@ mod tests { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_rm(REQUEST_BASE_JSON, "rpId"); - let req: GetAssertionRequest = GetAssertionRequest::from_json( + let req: GetAssertionRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -790,16 +802,16 @@ mod tests { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.org.""#); - let result = GetAssertionRequest::from_json( + let result = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await; assert!(matches!( result, - Err(GetAssertionRequestParsingError::InvalidRelyingPartyId(_)) + Err(GetAssertionPrepareError::InvalidRelyingPartyId(_)) )); } @@ -808,16 +820,16 @@ mod tests { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""other.example.org""#); - let result = GetAssertionRequest::from_json( + let result = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await; assert!(matches!( result, - Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( + Err(GetAssertionPrepareError::MismatchingRelyingPartyId( _, _ )) @@ -830,10 +842,10 @@ mod tests { let request_origin: RequestOrigin = "https://login.example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.org""#); - let req = GetAssertionRequest::from_json( + let req = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -848,16 +860,16 @@ mod tests { let request_origin: RequestOrigin = "https://example.co.uk".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""co.uk""#); - let result = GetAssertionRequest::from_json( + let result = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await; assert!(matches!( result, - Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( + Err(GetAssertionPrepareError::MismatchingRelyingPartyId( _, _ )) @@ -871,12 +883,15 @@ mod tests { async fn related_origins_match_resolves_mismatch() { let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#); - let http = MockHttpClient::ok_body(r#"{"origins":["https://app.example.org"]}"#); + let source = MockSource::origins(&["https://app.example.org"]); - let req = GetAssertionRequest::from_json( + let req = from_json( &request_origin, &MockPublicSuffixList, - &http, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, &req_json, ) .await @@ -888,18 +903,21 @@ mod tests { async fn related_origins_no_match_keeps_mismatch_error() { let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#); - let http = MockHttpClient::ok_body(r#"{"origins":["https://other.org"]}"#); + let source = MockSource::origins(&["https://other.org"]); - let result = GetAssertionRequest::from_json( + let result = from_json( &request_origin, &MockPublicSuffixList, - &http, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, &req_json, ) .await; assert!(matches!( result, - Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( + Err(GetAssertionPrepareError::MismatchingRelyingPartyId( _, _ )) @@ -910,18 +928,23 @@ mod tests { async fn related_origins_fetch_error_keeps_mismatch_error() { let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#); - let http = MockHttpClient::err(WellKnownFetchError::Transport("simulated".into())); + let source = MockSource::err(RelatedOriginsError::Http(HttpClientError::Transport( + "simulated".into(), + ))); - let result = GetAssertionRequest::from_json( + let result = from_json( &request_origin, &MockPublicSuffixList, - &http, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, &req_json, ) .await; assert!(matches!( result, - Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( + Err(GetAssertionPrepareError::MismatchingRelyingPartyId( _, _ )) @@ -932,12 +955,15 @@ mod tests { async fn related_origins_not_consulted_when_suffix_matches() { let request_origin: RequestOrigin = "https://login.example.com".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#); - let http = MockHttpClient::panicking(); + let source = MockSource::panicking(); - let req = GetAssertionRequest::from_json( + let req = from_json( &request_origin, &MockPublicSuffixList, - &http, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, &req_json, ) .await @@ -950,10 +976,10 @@ mod tests { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_rm(REQUEST_BASE_JSON, "allowCredentials"); - let req: GetAssertionRequest = GetAssertionRequest::from_json( + let req: GetAssertionRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -972,10 +998,10 @@ mod tests { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_rm(REQUEST_BASE_JSON, "timeout"); - let req: GetAssertionRequest = GetAssertionRequest::from_json( + let req: GetAssertionRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -991,10 +1017,10 @@ mod tests { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", r#"{}"#); - let req: GetAssertionRequest = GetAssertionRequest::from_json( + let req: GetAssertionRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -1014,10 +1040,10 @@ mod tests { r#"{"appid":"https://www.example.org/u2f/origins.json"}"#, ); - let req: GetAssertionRequest = GetAssertionRequest::from_json( + let req: GetAssertionRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -1038,16 +1064,16 @@ mod tests { r#"{"appid":"http://www.example.org/u2f/origins.json"}"#, ); - let res = GetAssertionRequest::from_json( + let res = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await; assert!(matches!( res, - Err(GetAssertionRequestParsingError::InvalidAppId(_)) + Err(GetAssertionPrepareError::InvalidAppId(_)) )); } @@ -1099,10 +1125,10 @@ mod tests { async fn parse_prf(extensions_json: &str) -> PrfInput { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", extensions_json); - let req = GetAssertionRequest::from_json( + let req = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await diff --git a/libwebauthn/src/ops/webauthn/idl/mod.rs b/libwebauthn/src/ops/webauthn/idl/mod.rs index 7a366f09..239b1d35 100644 --- a/libwebauthn/src/ops/webauthn/idl/mod.rs +++ b/libwebauthn/src/ops/webauthn/idl/mod.rs @@ -15,47 +15,72 @@ pub use response::{ }; use async_trait::async_trait; -use origin::RequestOrigin; use serde::de::DeserializeOwned; -use serde_json; +use tracing::debug; + +use origin::{is_registrable_domain_suffix_or_equal, RequestOrigin}; +use rpid::RelyingPartyId; use super::psl::PublicSuffixList; -use super::related_origins::RelatedOriginsHttpClient; +use super::related_origins::{validate_related_origins, RelatedOrigins}; pub type JsonError = serde_json::Error; -#[async_trait] -pub trait WebAuthnIDL: Sized -where - E: std::error::Error, - Self: FromIdlModel, -{ - type Error: std::error::Error + From + From; - type IdlModel: DeserializeOwned + Send; - - async fn from_json( - request_origin: &RequestOrigin, - psl: &dyn PublicSuffixList, - http: &dyn RelatedOriginsHttpClient, - json: &str, - ) -> Result { - let idl_model: Self::IdlModel = serde_json::from_str(json)?; - Self::from_idl_model(request_origin, psl, http, idl_model) - .await - .map_err(From::from) - } +/// Dependencies for origin validation: the Public Suffix List (rp.id suffix +/// check and related-origins matching) and the related-origins policy. +pub struct RequestSettings<'a> { + pub public_suffix_list: &'a dyn PublicSuffixList, + pub related_origins: RelatedOrigins<'a>, } +/// Builds a request from its parsed IDL model, validating origin against rp.id. #[async_trait] -pub trait FromIdlModel: Sized +pub(crate) trait FromIdlModel: Sized where T: DeserializeOwned + Send, - E: std::error::Error, { + type Error: std::error::Error + From; + async fn from_idl_model( request_origin: &RequestOrigin, - psl: &dyn PublicSuffixList, - http: &dyn RelatedOriginsHttpClient, + settings: &RequestSettings<'_>, model: T, - ) -> Result; + ) -> Result; +} + +/// Whether `request_origin` may act for `rp_id`: a registrable suffix of the +/// caller's effective domain, or a matching related origin when enabled. +pub(crate) async fn rp_id_authorised( + request_origin: &RequestOrigin, + rp_id: &RelyingPartyId, + settings: &RequestSettings<'_>, +) -> bool { + let effective_rp_id = request_origin.origin.host.as_str(); + if is_registrable_domain_suffix_or_equal(&rp_id.0, effective_rp_id, settings.public_suffix_list) + { + return true; + } + match &settings.related_origins { + RelatedOrigins::Disabled => false, + RelatedOrigins::Enabled { source, max_labels } => { + match source.allowed_origins(rp_id).await { + Err(err) => { + debug!(rp_id = %rp_id.0, %err, "Related-origins resolution failed"); + false + } + Ok(origins) => match validate_related_origins( + &request_origin.origin, + &origins, + settings.public_suffix_list, + *max_labels, + ) { + Ok(()) => true, + Err(err) => { + debug!(rp_id = %rp_id.0, %err, "Related-origins match failed"); + false + } + }, + } + } + } } diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 2d894363..5fb17845 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -13,16 +13,13 @@ use crate::{ idl::{ create::PublicKeyCredentialCreationOptionsJSON, get::PrfValuesJson, - origin::is_registrable_domain_suffix_or_equal, response::{ AuthenticationExtensionsClientOutputsJSON, AuthenticatorAttestationResponseJSON, CredentialPropertiesOutputJSON, LargeBlobOutputJSON, PRFOutputJSON, PRFValuesJSON, RegistrationResponseJSON, ResponseSerializationError, WebAuthnIDLResponse, }, - Base64UrlString, FromIdlModel, JsonError, WebAuthnIDL, + rp_id_authorised, Base64UrlString, FromIdlModel, JsonError, RequestSettings, }, - psl::PublicSuffixList, - related_origins::{validate_related_origins, RelatedOriginsHttpClient}, Operation, PrfInputValue, PrfOutputValue, RelyingPartyId, RequestOrigin, }, proto::{ @@ -368,31 +365,23 @@ impl MakeCredentialRequest { } #[async_trait] -impl FromIdlModel - for MakeCredentialRequest -{ +impl FromIdlModel for MakeCredentialRequest { + type Error = MakeCredentialPrepareError; + async fn from_idl_model( request_origin: &RequestOrigin, - psl: &dyn PublicSuffixList, - http: &dyn RelatedOriginsHttpClient, + settings: &RequestSettings<'_>, inner: PublicKeyCredentialCreationOptionsJSON, - ) -> Result { + ) -> Result { let effective_rp_id = request_origin.origin.host.as_str(); let rp_id = RelyingPartyId::try_from(inner.rp.id.as_str()).map_err(|err| { - MakeCredentialRequestParsingError::InvalidRelyingPartyId(err.to_string()) + MakeCredentialPrepareError::InvalidRelyingPartyId(err.to_string()) })?; - if !is_registrable_domain_suffix_or_equal(&rp_id.0, effective_rp_id, psl) { - if let Err(err) = - validate_related_origins(&request_origin.origin, &rp_id, psl, http).await - { - debug!(rp_id = %rp_id.0, %err, "Related-origins validation failed"); - return Err( - MakeCredentialRequestParsingError::MismatchingRelyingPartyId( - rp_id.0, - effective_rp_id.to_string(), - ), - ); - } + if !rp_id_authorised(request_origin, &rp_id, settings).await { + return Err(MakeCredentialPrepareError::MismatchingRelyingPartyId( + rp_id.0, + effective_rp_id.to_string(), + )); } let mut relying_party = inner.rp; relying_party.id = rp_id.0; @@ -443,7 +432,7 @@ impl FromIdlModel for MakeCredentialRequest { - type Error = MakeCredentialRequestParsingError; - type IdlModel = PublicKeyCredentialCreationOptionsJSON; +impl MakeCredentialRequest { + /// Builds a [`MakeCredentialRequest`] from its WebAuthn IDL JSON, validating + /// the caller origin against rp.id per `settings`. + pub async fn prepare( + request_origin: &RequestOrigin, + json: &str, + settings: &RequestSettings<'_>, + ) -> Result { + let model: PublicKeyCredentialCreationOptionsJSON = serde_json::from_str(json)?; + Self::from_idl_model(request_origin, settings, model).await + } } #[derive(Debug, Default, Clone, Deserialize, PartialEq)] @@ -701,55 +698,68 @@ mod tests { use async_trait::async_trait; - use crate::ops::webauthn::psl::MockPublicSuffixList; + use crate::ops::webauthn::psl::{MockPublicSuffixList, PublicSuffixList}; use crate::ops::webauthn::related_origins::{ - RelatedOriginsHttpClient, WellKnownFetchError, WellKnownResponse, + HttpClientError, MaxRegistrableLabels, RelatedOrigins, RelatedOriginsError, + RelatedOriginsSource, }; - use crate::ops::webauthn::{MakeCredentialRequest, NoRelatedOriginsClient, RequestOrigin}; + use crate::ops::webauthn::{MakeCredentialRequest, RequestOrigin}; use crate::proto::ctap2::Ctap2PublicKeyCredentialType; use super::*; - /// Test-only HTTP client backed by a fixed response. `panicking` proves the - /// suffix-check short-circuit by failing the test if the fetch is invoked. - struct MockHttpClient { - response: Option>, + // Fixed-result source; `panicking` proves the suffix-check short-circuit by + // failing if consulted. + struct MockSource { + result: Option, RelatedOriginsError>>, } - impl MockHttpClient { - fn ok_body(body: &str) -> Self { + impl MockSource { + fn origins(items: &[&str]) -> Self { Self { - response: Some(Ok(WellKnownResponse { - content_type: Some("application/json".into()), - body: body.as_bytes().to_vec(), - })), + result: Some(Ok(items.iter().map(|s| s.to_string()).collect())), } } - fn err(e: WellKnownFetchError) -> Self { - Self { - response: Some(Err(e)), - } + fn err(e: RelatedOriginsError) -> Self { + Self { result: Some(Err(e)) } } fn panicking() -> Self { - Self { response: None } + Self { result: None } } } #[async_trait] - impl RelatedOriginsHttpClient for MockHttpClient { - async fn fetch_well_known( + impl RelatedOriginsSource for MockSource { + async fn allowed_origins( &self, _: &RelyingPartyId, - ) -> Result { - match &self.response { + ) -> Result, RelatedOriginsError> { + match &self.result { Some(r) => r.clone(), - None => panic!("fetch_well_known should not be called"), + None => panic!("allowed_origins should not be called"), } } } + async fn from_json( + origin: &RequestOrigin, + psl: &dyn PublicSuffixList, + related_origins: RelatedOrigins<'_>, + json: &str, + ) -> Result { + MakeCredentialRequest::prepare( + origin, + json, + &RequestSettings { + public_suffix_list: psl, + related_origins, + }, + ) + .await + } + pub const REQUEST_BASE_JSON: &str = r#" { "rp": { @@ -813,26 +823,26 @@ mod tests { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_rm(REQUEST_BASE_JSON, field); - let result = MakeCredentialRequest::from_json( + let result = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await; assert!(matches!( result, - Err(MakeCredentialRequestParsingError::EncodingError(_)) + Err(MakeCredentialPrepareError::EncodingError(_)) )); } #[tokio::test] async fn test_request_from_json_base() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); - let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + let req: MakeCredentialRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, REQUEST_BASE_JSON, ) .await @@ -867,16 +877,16 @@ mod tests { let req_json: String = json_field_rm(REQUEST_BASE_JSON, "challenge"); let req_json = json_field_add(&req_json, "challenge", r#""""#); - let result = MakeCredentialRequest::from_json( + let result = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await; assert!(matches!( result, - Err(MakeCredentialRequestParsingError::EncodingError(_)) + Err(MakeCredentialPrepareError::EncodingError(_)) )); } @@ -888,10 +898,10 @@ mod tests { let ext = format!(r#"{{"prf": {{"eval": {{"first": "{first}", "second": "{second}"}}}}}}"#); let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext); - let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + let req: MakeCredentialRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -911,10 +921,10 @@ mod tests { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", r#"{"prf": {}}"#); - let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + let req: MakeCredentialRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -931,10 +941,10 @@ mod tests { let ext = format!(r#"{{"prf": {{"eval": {{"first": "{short}"}}}}}}"#); let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext); - let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + let req: MakeCredentialRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -958,10 +968,10 @@ mod tests { r#"{"appidExclude": "https://www.example.org/u2f/origins.json"}"#, ); - let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + let req: MakeCredentialRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -981,10 +991,10 @@ mod tests { "pubKeyCredParams", r#"[{"type": "something", "alg": -12345}]"#, ); - let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + let req: MakeCredentialRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -1003,10 +1013,10 @@ mod tests { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_rm(REQUEST_BASE_JSON, "timeout"); - let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + let req: MakeCredentialRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -1021,10 +1031,10 @@ mod tests { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_rm(REQUEST_BASE_JSON, "authenticatorSelection"); - let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + let req: MakeCredentialRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -1048,10 +1058,10 @@ mod tests { r#"{"residentKey": "discouraged"}"#, ); - let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + let req: MakeCredentialRequest = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -1071,16 +1081,16 @@ mod tests { r#"{"id": "example.org.", "name": "example.org"}"#, ); - let result = MakeCredentialRequest::from_json( + let result = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await; assert!(matches!( result, - Err(MakeCredentialRequestParsingError::InvalidRelyingPartyId(_)) + Err(MakeCredentialPrepareError::InvalidRelyingPartyId(_)) )); } @@ -1093,16 +1103,16 @@ mod tests { r#"{"id": "other.example.org", "name": "example.org"}"#, ); - let result = MakeCredentialRequest::from_json( + let result = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await; assert!(matches!( result, - Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _)) + Err(MakeCredentialPrepareError::MismatchingRelyingPartyId(_, _)) )); } @@ -1115,10 +1125,10 @@ mod tests { r#"{"id": "example.org", "name": "example.org"}"#, ); - let req = MakeCredentialRequest::from_json( + let req = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -1136,16 +1146,16 @@ mod tests { r#"{"id": "co.uk", "name": "co.uk"}"#, ); - let result = MakeCredentialRequest::from_json( + let result = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await; assert!(matches!( result, - Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _)) + Err(MakeCredentialPrepareError::MismatchingRelyingPartyId(_, _)) )); } @@ -1158,10 +1168,10 @@ mod tests { r#"{"id": "localhost", "name": "localhost"}"#, ); - let req = MakeCredentialRequest::from_json( + let req = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -1179,10 +1189,10 @@ mod tests { r#"{"id": "localhost", "name": "localhost"}"#, ); - let req = MakeCredentialRequest::from_json( + let req = from_json( &request_origin, &MockPublicSuffixList, - &NoRelatedOriginsClient, + RelatedOrigins::Disabled, &req_json, ) .await @@ -1202,12 +1212,15 @@ mod tests { "rp", r#"{"id": "example.com", "name": "example.com"}"#, ); - let http = MockHttpClient::ok_body(r#"{"origins":["https://app.example.org"]}"#); + let source = MockSource::origins(&["https://app.example.org"]); - let req = MakeCredentialRequest::from_json( + let req = from_json( &request_origin, &MockPublicSuffixList, - &http, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, &req_json, ) .await @@ -1223,18 +1236,21 @@ mod tests { "rp", r#"{"id": "example.com", "name": "example.com"}"#, ); - let http = MockHttpClient::ok_body(r#"{"origins":["https://other.org"]}"#); + let source = MockSource::origins(&["https://other.org"]); - let result = MakeCredentialRequest::from_json( + let result = from_json( &request_origin, &MockPublicSuffixList, - &http, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, &req_json, ) .await; assert!(matches!( result, - Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _)) + Err(MakeCredentialPrepareError::MismatchingRelyingPartyId(_, _)) )); } @@ -1246,18 +1262,23 @@ mod tests { "rp", r#"{"id": "example.com", "name": "example.com"}"#, ); - let http = MockHttpClient::err(WellKnownFetchError::Transport("simulated".into())); + let source = MockSource::err(RelatedOriginsError::Http(HttpClientError::Transport( + "simulated".into(), + ))); - let result = MakeCredentialRequest::from_json( + let result = from_json( &request_origin, &MockPublicSuffixList, - &http, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, &req_json, ) .await; assert!(matches!( result, - Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _)) + Err(MakeCredentialPrepareError::MismatchingRelyingPartyId(_, _)) )); } @@ -1269,12 +1290,15 @@ mod tests { "rp", r#"{"id": "example.com", "name": "example.com"}"#, ); - let http = MockHttpClient::panicking(); + let source = MockSource::panicking(); - let req = MakeCredentialRequest::from_json( + let req = from_json( &request_origin, &MockPublicSuffixList, - &http, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, &req_json, ) .await diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 16ddd77d..cfad5c75 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -11,8 +11,8 @@ use crate::webauthn::CtapError; pub use client_data::ClientData; pub use get_assertion::{ Assertion, Ctap2HMACGetSecretOutput, GetAssertionHmacOrPrfInput, - GetAssertionLargeBlobExtension, GetAssertionLargeBlobExtensionOutput, GetAssertionPrfOutput, - GetAssertionRequest, GetAssertionRequestExtensions, GetAssertionResponse, + GetAssertionLargeBlobExtension, GetAssertionLargeBlobExtensionOutput, GetAssertionPrepareError, + GetAssertionPrfOutput, GetAssertionRequest, GetAssertionRequestExtensions, GetAssertionResponse, GetAssertionResponseExtensions, GetAssertionResponseUnsignedExtensions, HMACGetSecretInput, HMACGetSecretOutput, PrfInput, PrfInputValue, PrfOutputValue, }; @@ -21,14 +21,15 @@ pub use idl::{ rpid::RelyingPartyId, AuthenticationExtensionsClientOutputsJSON, AuthenticationResponseJSON, AuthenticatorAssertionResponseJSON, AuthenticatorAttestationResponseJSON, Base64UrlString, - JsonFormat, RegistrationResponseJSON, ResponseSerializationError, WebAuthnIDL, + JsonFormat, RegistrationResponseJSON, RequestSettings, ResponseSerializationError, WebAuthnIDLResponse, }; pub use make_credential::{ CredentialPropsExtension, CredentialProtectionExtension, CredentialProtectionPolicy, MakeCredentialLargeBlobExtension, MakeCredentialLargeBlobExtensionInput, - MakeCredentialLargeBlobExtensionOutput, MakeCredentialPrfInput, MakeCredentialPrfOutput, - MakeCredentialRequest, MakeCredentialResponse, MakeCredentialsRequestExtensions, + MakeCredentialLargeBlobExtensionOutput, MakeCredentialPrepareError, MakeCredentialPrfInput, + MakeCredentialPrfOutput, MakeCredentialRequest, MakeCredentialResponse, + MakeCredentialsRequestExtensions, MakeCredentialsResponseExtensions, MakeCredentialsResponseUnsignedExtensions, ResidentKeyRequirement, }; @@ -38,11 +39,11 @@ pub use psl::{ SYSTEM_PSL_PATH, }; pub use related_origins::{ - validate_related_origins, NoRelatedOriginsClient, RelatedOriginsError, - RelatedOriginsHttpClient, WellKnownFetchError, WellKnownResponse, + validate_related_origins, HttpClient, HttpClientError, MaxRegistrableLabels, RelatedOrigins, + RelatedOriginsError, RelatedOriginsSource, WellKnownRelatedOriginsSource, }; -#[cfg(feature = "related-origins-client")] -pub use related_origins::{HttpPolicy, ReqwestRelatedOriginsClient}; +#[cfg(feature = "reqwest-related-origins-source")] +pub use related_origins::{HttpPolicy, ReqwestHttpClient, ReqwestRelatedOriginsSource}; use serde::Deserialize; #[derive(Debug, Clone, Copy, Deserialize, PartialEq)] From c3d0826d234c7b8e1be5a7b558fca680254b395b Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 28 May 2026 23:38:01 +0100 Subject: [PATCH 24/29] test(webauthn): adopt prepare() in examples, integration test, and README --- README.md | 12 +-- libwebauthn/examples/ceremony/webauthn_ble.rs | 18 +++-- .../examples/ceremony/webauthn_cable.rs | 19 +++-- .../examples/ceremony/webauthn_cable_wss.rs | 20 +++-- libwebauthn/examples/ceremony/webauthn_hid.rs | 17 ++-- libwebauthn/examples/ceremony/webauthn_nfc.rs | 23 +++--- libwebauthn/tests/related_origins.rs | 79 +++++++++++-------- 7 files changed, 115 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index a55caad3..4f1c54c1 100644 --- a/README.md +++ b/README.md @@ -70,15 +70,15 @@ WebAuthn examples consume and emit JSON per the [WebAuthn IDL][webauthn]. | Transport | FIDO U2F | WebAuthn (FIDO2) [^ro] | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **USB (HID)** | `cargo run --example u2f_hid` | `cargo run --features related-origins-client --example webauthn_hid` | -| **Bluetooth (BLE)** | `cargo run --example u2f_ble` | `cargo run --features related-origins-client --example webauthn_ble` | -| **NFC** [^nfc] | `cargo run --features nfc-backend-pcsc --example u2f_nfc`
`cargo run --features nfc-backend-libnfc --example u2f_nfc` | `cargo run --features nfc-backend-pcsc,related-origins-client --example webauthn_nfc`
`cargo run --features nfc-backend-libnfc,related-origins-client --example webauthn_nfc` | -| **Hybrid (caBLE v2 + CTAP 2.3)** | — | `cargo run --features related-origins-client --example webauthn_cable` | -| **Hybrid (caBLE v2)** | — | `cargo run --features related-origins-client --example webauthn_cable_wss` | +| **USB (HID)** | `cargo run --example u2f_hid` | `cargo run --features reqwest-related-origins-source --example webauthn_hid` | +| **Bluetooth (BLE)** | `cargo run --example u2f_ble` | `cargo run --features reqwest-related-origins-source --example webauthn_ble` | +| **NFC** [^nfc] | `cargo run --features nfc-backend-pcsc --example u2f_nfc`
`cargo run --features nfc-backend-libnfc --example u2f_nfc` | `cargo run --features nfc-backend-pcsc,reqwest-related-origins-source --example webauthn_nfc`
`cargo run --features nfc-backend-libnfc,reqwest-related-origins-source --example webauthn_nfc` | +| **Hybrid (caBLE v2 + CTAP 2.3)** | — | `cargo run --features reqwest-related-origins-source --example webauthn_cable` | +| **Hybrid (caBLE v2)** | — | `cargo run --features reqwest-related-origins-source --example webauthn_cable_wss` | [^nfc]: `nfc-backend-pcsc` is pure userspace and recommended on most systems. `nfc-backend-libnfc` requires the `libnfc` system library. Both can be enabled together; the first FIDO device found by either backend is used. -[^ro]: The WebAuthn ceremony examples wire up the bundled reqwest-backed [related-origins](https://www.w3.org/TR/webauthn-3/#sctn-related-origins) client, which lives behind the optional `related-origins-client` feature. Consumers that already ship their own HTTP stack can implement `RelatedOriginsHttpClient` directly and omit the feature. +[^ro]: The WebAuthn ceremony examples wire up the bundled reqwest-backed [related-origins](https://www.w3.org/TR/webauthn-3/#sctn-related-origins) source, which lives behind the optional `reqwest-related-origins-source` feature. Consumers that already ship their own HTTP stack can implement `HttpClient` or `RelatedOriginsSource` directly and omit the feature. Additional HID-only examples cover specific FIDO2 features and authenticator management: diff --git a/libwebauthn/examples/ceremony/webauthn_ble.rs b/libwebauthn/examples/ceremony/webauthn_ble.rs index 916531e4..afe9d02a 100644 --- a/libwebauthn/examples/ceremony/webauthn_ble.rs +++ b/libwebauthn/examples/ceremony/webauthn_ble.rs @@ -1,8 +1,9 @@ use std::error::Error; use libwebauthn::ops::webauthn::{ - DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, - ReqwestRelatedOriginsClient, WebAuthnIDL as _, WebAuthnIDLResponse as _, + DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, + MaxRegistrableLabels, RelatedOrigins, RequestOrigin, RequestSettings, + ReqwestRelatedOriginsSource, WebAuthnIDLResponse as _, }; use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; use libwebauthn::transport::ble::list_devices; @@ -52,9 +53,16 @@ pub async fn main() -> Result<(), Box> { "attestation": "none" } "#; - let related_origins = ReqwestRelatedOriginsClient::new()?; + let related_origins = ReqwestRelatedOriginsSource::new()?; + let settings = RequestSettings { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Enabled { + source: &related_origins, + max_labels: MaxRegistrableLabels::default(), + }, + }; let make_credentials_request: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &psl, &related_origins, request_json) + MakeCredentialRequest::prepare(&request_origin, request_json, &settings) .await .expect("Failed to parse request JSON"); println!( @@ -99,7 +107,7 @@ pub async fn main() -> Result<(), Box> { "# ); let get_assertion: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &psl, &related_origins, &request_json) + GetAssertionRequest::prepare(&request_origin, &request_json, &settings) .await .expect("Failed to parse request JSON"); println!("WebAuthn GetAssertion request: {:?}", get_assertion); diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index 7f7ae5bd..85a0bb72 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -11,8 +11,9 @@ use qrcode::render::unicode; use qrcode::QrCode; use libwebauthn::ops::webauthn::{ - DatFilePublicSuffixList, JsonFormat, MakeCredentialRequest, RequestOrigin, - ReqwestRelatedOriginsClient, WebAuthnIDL as _, WebAuthnIDLResponse as _, + DatFilePublicSuffixList, JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, + RelatedOrigins, RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, + WebAuthnIDLResponse as _, }; use libwebauthn::transport::{Channel as _, Device}; use libwebauthn::webauthn::WebAuthn; @@ -58,7 +59,14 @@ pub async fn main() -> Result<(), Box> { let psl = DatFilePublicSuffixList::from_system_file().expect( "PSL not available; install the publicsuffix-list package or pass an explicit path", ); - let related_origins = ReqwestRelatedOriginsClient::new()?; + let related_origins = ReqwestRelatedOriginsSource::new()?; + let settings = RequestSettings { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Enabled { + source: &related_origins, + max_labels: MaxRegistrableLabels::default(), + }, + }; let mut device: CableQrCodeDevice = CableQrCodeDevice::new_transient( QrCodeOperationHint::MakeCredential, @@ -80,11 +88,10 @@ pub async fn main() -> Result<(), Box> { let state_recv = channel.get_ux_update_receiver(); tokio::spawn(common::handle_cable_updates(state_recv)); - let request = MakeCredentialRequest::from_json( + let request = MakeCredentialRequest::prepare( &request_origin, - &psl, - &related_origins, MAKE_CREDENTIAL_REQUEST, + &settings, ) .await .expect("Failed to parse request JSON"); diff --git a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs index a35f5faf..a86a2bff 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs @@ -14,8 +14,8 @@ use qrcode::QrCode; use tokio::time::sleep; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, NoRelatedOriginsClient, RequestOrigin, - SystemPublicSuffixList, WebAuthnIDL as _, WebAuthnIDLResponse as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, RelatedOrigins, RequestOrigin, + RequestSettings, SystemPublicSuffixList, WebAuthnIDLResponse as _, }; use libwebauthn::transport::cable::channel::CableChannel; use libwebauthn::transport::{Channel as _, Device}; @@ -95,11 +95,13 @@ pub async fn main() -> Result<(), Box> { let state_recv = channel.get_ux_update_receiver(); tokio::spawn(common::handle_cable_updates(state_recv)); - let request = MakeCredentialRequest::from_json( + let request = MakeCredentialRequest::prepare( &request_origin, - &psl, - &NoRelatedOriginsClient, MAKE_CREDENTIAL_REQUEST, + &RequestSettings { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Disabled, + }, ) .await .expect("Failed to parse request JSON"); @@ -160,11 +162,13 @@ async fn run_get_assertion( let state_recv = channel.get_ux_update_receiver(); tokio::spawn(common::handle_cable_updates(state_recv)); - let request = GetAssertionRequest::from_json( + let request = GetAssertionRequest::prepare( request_origin, - psl, - &NoRelatedOriginsClient, GET_ASSERTION_REQUEST, + &RequestSettings { + public_suffix_list: psl, + related_origins: RelatedOrigins::Disabled, + }, ) .await .expect("Failed to parse request JSON"); diff --git a/libwebauthn/examples/ceremony/webauthn_hid.rs b/libwebauthn/examples/ceremony/webauthn_hid.rs index cbddd2ab..2028d711 100644 --- a/libwebauthn/examples/ceremony/webauthn_hid.rs +++ b/libwebauthn/examples/ceremony/webauthn_hid.rs @@ -2,8 +2,8 @@ use std::error::Error; use std::time::Duration; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, - ReqwestRelatedOriginsClient, SystemPublicSuffixList, WebAuthnIDL as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, RelatedOrigins, + RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, SystemPublicSuffixList, WebAuthnIDLResponse as _, }; use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; @@ -33,7 +33,14 @@ pub async fn main() -> Result<(), Box> { let psl = SystemPublicSuffixList::auto().expect( "PSL not available; install the publicsuffix-list (or publicsuffix-list-dafsa) package, or pass an explicit path", ); - let related_origins = ReqwestRelatedOriginsClient::new()?; + let related_origins = ReqwestRelatedOriginsSource::new()?; + let settings = RequestSettings { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Enabled { + source: &related_origins, + max_labels: MaxRegistrableLabels::default(), + }, + }; let request_json = r#" { "rp": { @@ -59,7 +66,7 @@ pub async fn main() -> Result<(), Box> { } "#; let make_credentials_request: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &psl, &related_origins, request_json) + MakeCredentialRequest::prepare(&request_origin, request_json, &settings) .await .expect("Failed to parse request JSON"); println!( @@ -104,7 +111,7 @@ pub async fn main() -> Result<(), Box> { "# ); let get_assertion: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &psl, &related_origins, &request_json) + GetAssertionRequest::prepare(&request_origin, &request_json, &settings) .await .expect("Failed to parse request JSON"); println!("WebAuthn GetAssertion request: {:?}", get_assertion); diff --git a/libwebauthn/examples/ceremony/webauthn_nfc.rs b/libwebauthn/examples/ceremony/webauthn_nfc.rs index cdfc0595..89013fb2 100644 --- a/libwebauthn/examples/ceremony/webauthn_nfc.rs +++ b/libwebauthn/examples/ceremony/webauthn_nfc.rs @@ -1,8 +1,8 @@ use std::error::Error; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, - ReqwestRelatedOriginsClient, SystemPublicSuffixList, WebAuthnIDL as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, RelatedOrigins, + RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, SystemPublicSuffixList, WebAuthnIDLResponse as _, }; use libwebauthn::transport::nfc::{get_nfc_device, is_nfc_available}; @@ -31,11 +31,16 @@ pub async fn main() -> Result<(), Box> { let psl = SystemPublicSuffixList::auto().expect( "PSL not available; install the publicsuffix-list (or publicsuffix-list-dafsa) package, or pass an explicit path", ); - let related_origins = ReqwestRelatedOriginsClient::new()?; - let make_credentials_request = MakeCredentialRequest::from_json( + let related_origins = ReqwestRelatedOriginsSource::new()?; + let settings = RequestSettings { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Enabled { + source: &related_origins, + max_labels: MaxRegistrableLabels::default(), + }, + }; + let make_credentials_request = MakeCredentialRequest::prepare( &request_origin, - &psl, - &related_origins, r#" { "rp": { @@ -60,6 +65,7 @@ pub async fn main() -> Result<(), Box> { "attestation": "none" } "#, + &settings, ) .await .expect("Failed to parse request JSON"); @@ -78,10 +84,8 @@ pub async fn main() -> Result<(), Box> { .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); - let get_assertion = GetAssertionRequest::from_json( + let get_assertion = GetAssertionRequest::prepare( &request_origin, - &psl, - &related_origins, r#" { "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu", @@ -90,6 +94,7 @@ pub async fn main() -> Result<(), Box> { "userVerification": "discouraged" } "#, + &settings, ) .await .expect("Failed to parse request JSON"); diff --git a/libwebauthn/tests/related_origins.rs b/libwebauthn/tests/related_origins.rs index 024ac22b..4f980b73 100644 --- a/libwebauthn/tests/related_origins.rs +++ b/libwebauthn/tests/related_origins.rs @@ -1,13 +1,12 @@ -//! End-to-end related-origins integration tests (WebAuthn L3 §5.11). -//! -//! Drives `MakeCredentialRequest::from_json` / `GetAssertionRequest::from_json` -//! with a mock HTTP client and a tiny inline PSL impl. No network. +//! End-to-end related-origins integration tests (WebAuthn L3 §5.11). Drives +//! `prepare` with a mock HTTP client behind the well-known source and a tiny +//! inline PSL. No network. use async_trait::async_trait; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, MakeCredentialRequest, PublicSuffixList, RelatedOriginsHttpClient, - RelyingPartyId, RequestOrigin, WebAuthnIDL, WellKnownFetchError, WellKnownResponse, + GetAssertionRequest, HttpClient, HttpClientError, MakeCredentialRequest, MaxRegistrableLabels, + PublicSuffixList, RelatedOrigins, RequestOrigin, RequestSettings, WellKnownRelatedOriginsSource, }; const KNOWN_SUFFIXES: &[&str] = &["com", "org"]; @@ -35,15 +34,13 @@ struct StaticHttp { } #[async_trait] -impl RelatedOriginsHttpClient for StaticHttp { - async fn fetch_well_known( - &self, - _: &RelyingPartyId, - ) -> Result { - Ok(WellKnownResponse { - content_type: Some("application/json".into()), - body: self.body.as_bytes().to_vec(), - }) +impl HttpClient for StaticHttp { + async fn get(&self, _: &url::Url) -> Result>, HttpClientError> { + Ok(http::Response::builder() + .status(200) + .header(http::header::CONTENT_TYPE, "application/json") + .body(self.body.as_bytes().to_vec()) + .unwrap()) } } @@ -52,8 +49,8 @@ const MAKE_CREDENTIAL_JSON: &str = r#" "rp": {"id": "example.org", "name": "example.org"}, "user": { "id": "dXNlcmlk", - "name": "mario.rossi", - "displayName": "Mario Rossi" + "name": "alice", + "displayName": "Alice" }, "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu", "pubKeyCredParams": [{"type": "public-key", "alg": -7}], @@ -79,22 +76,33 @@ const GET_ASSERTION_JSON: &str = r#" } "#; -// Caller and rp.id sit on different eTLDs (`example.com` vs `example.org`), -// matching the §8.3 design example so the related-origins fetch path is -// actually exercised. +// Caller and rp.id sit on different eTLDs (`example.com` vs `example.org`) so the +// related-origins fetch path is actually exercised. const WELL_KNOWN_BODY: &str = r#"{"origins":["https://app.example.com","https://example.org"]}"#; +fn settings<'a>(psl: &'a TestPsl, source: &'a WellKnownRelatedOriginsSource) -> RequestSettings<'a> { + RequestSettings { + public_suffix_list: psl, + related_origins: RelatedOrigins::Enabled { + source, + max_labels: MaxRegistrableLabels::default(), + }, + } +} + #[tokio::test] async fn end_to_end_mock_match_via_make_credential() { let request_origin: RequestOrigin = "https://app.example.com".parse().unwrap(); - let http = StaticHttp { - body: WELL_KNOWN_BODY, - }; + let psl = TestPsl; + let source = WellKnownRelatedOriginsSource::from_client(StaticHttp { body: WELL_KNOWN_BODY }); - let req = - MakeCredentialRequest::from_json(&request_origin, &TestPsl, &http, MAKE_CREDENTIAL_JSON) - .await - .unwrap(); + let req = MakeCredentialRequest::prepare( + &request_origin, + MAKE_CREDENTIAL_JSON, + &settings(&psl, &source), + ) + .await + .unwrap(); assert_eq!(req.relying_party.id, "example.org"); assert!(req @@ -105,13 +113,16 @@ async fn end_to_end_mock_match_via_make_credential() { #[tokio::test] async fn end_to_end_mock_match_via_get_assertion() { let request_origin: RequestOrigin = "https://app.example.com".parse().unwrap(); - let http = StaticHttp { - body: WELL_KNOWN_BODY, - }; - - let req = GetAssertionRequest::from_json(&request_origin, &TestPsl, &http, GET_ASSERTION_JSON) - .await - .unwrap(); + let psl = TestPsl; + let source = WellKnownRelatedOriginsSource::from_client(StaticHttp { body: WELL_KNOWN_BODY }); + + let req = GetAssertionRequest::prepare( + &request_origin, + GET_ASSERTION_JSON, + &settings(&psl, &source), + ) + .await + .unwrap(); assert_eq!(req.relying_party_id, "example.org"); assert!(req From 902ee8aa265f8f3d3fe44b1f95a6a1a630629882 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sat, 30 May 2026 12:07:25 +0100 Subject: [PATCH 25/29] feat(webauthn): add StaticRelatedOriginsSource for caller-provided origin lists --- libwebauthn/src/ops/webauthn/mod.rs | 14 +-- .../src/ops/webauthn/related_origins/mod.rs | 93 ++++++++++++++++--- 2 files changed, 88 insertions(+), 19 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index cfad5c75..63c45b2a 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -12,9 +12,9 @@ pub use client_data::ClientData; pub use get_assertion::{ Assertion, Ctap2HMACGetSecretOutput, GetAssertionHmacOrPrfInput, GetAssertionLargeBlobExtension, GetAssertionLargeBlobExtensionOutput, GetAssertionPrepareError, - GetAssertionPrfOutput, GetAssertionRequest, GetAssertionRequestExtensions, GetAssertionResponse, - GetAssertionResponseExtensions, GetAssertionResponseUnsignedExtensions, HMACGetSecretInput, - HMACGetSecretOutput, PrfInput, PrfInputValue, PrfOutputValue, + GetAssertionPrfOutput, GetAssertionRequest, GetAssertionRequestExtensions, + GetAssertionResponse, GetAssertionResponseExtensions, GetAssertionResponseUnsignedExtensions, + HMACGetSecretInput, HMACGetSecretOutput, PrfInput, PrfInputValue, PrfOutputValue, }; pub use idl::{ origin::{HostParseError, Origin, OriginHost, OriginParseError, RequestOrigin, Scheme}, @@ -29,9 +29,8 @@ pub use make_credential::{ MakeCredentialLargeBlobExtension, MakeCredentialLargeBlobExtensionInput, MakeCredentialLargeBlobExtensionOutput, MakeCredentialPrepareError, MakeCredentialPrfInput, MakeCredentialPrfOutput, MakeCredentialRequest, MakeCredentialResponse, - MakeCredentialsRequestExtensions, - MakeCredentialsResponseExtensions, MakeCredentialsResponseUnsignedExtensions, - ResidentKeyRequirement, + MakeCredentialsRequestExtensions, MakeCredentialsResponseExtensions, + MakeCredentialsResponseUnsignedExtensions, ResidentKeyRequirement, }; pub use psl::{ DafsaFileLoadError, DafsaFilePublicSuffixList, DatFileLoadError, DatFilePublicSuffixList, @@ -40,7 +39,8 @@ pub use psl::{ }; pub use related_origins::{ validate_related_origins, HttpClient, HttpClientError, MaxRegistrableLabels, RelatedOrigins, - RelatedOriginsError, RelatedOriginsSource, WellKnownRelatedOriginsSource, + RelatedOriginsError, RelatedOriginsSource, StaticRelatedOriginsSource, + WellKnownRelatedOriginsSource, }; #[cfg(feature = "reqwest-related-origins-source")] pub use related_origins::{HttpPolicy, ReqwestHttpClient, ReqwestRelatedOriginsSource}; diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs index 53bd833d..2d2ab1e6 100644 --- a/libwebauthn/src/ops/webauthn/related_origins/mod.rs +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -3,7 +3,7 @@ //! the default [`WellKnownRelatedOriginsSource`] fetching the well-known //! document over an [`HttpClient`]. -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use async_trait::async_trait; use serde::Deserialize; @@ -145,7 +145,9 @@ impl RelatedOriginsSource for WellKnownRelatedOriginsSource { .map_err(|e| RelatedOriginsError::MalformedDocument(format!("invalid rp id: {e}")))?; let resp = self.http.get(&url).await?; if resp.status() != http::StatusCode::OK { - return Err(RelatedOriginsError::UnexpectedStatus(resp.status().as_u16())); + return Err(RelatedOriginsError::UnexpectedStatus( + resp.status().as_u16(), + )); } let content_type = resp .headers() @@ -173,6 +175,43 @@ impl RelatedOriginsSource for WellKnownRelatedOriginsSource { } } +/// [`RelatedOriginsSource`] backed by an in-memory map of rp.id to its known +/// related origins, for callers that already hold the list and want no fetch. +pub struct StaticRelatedOriginsSource { + by_rp_id: HashMap>, +} + +impl StaticRelatedOriginsSource { + /// Source for a single rp.id and its known related origins. + pub fn new( + rp_id: impl Into, + origins: impl IntoIterator>, + ) -> Self { + let mut by_rp_id = HashMap::new(); + by_rp_id.insert(rp_id.into(), origins.into_iter().map(Into::into).collect()); + Self { by_rp_id } + } + + /// Source backed by a map of rp.id to its known related origins. + pub fn from_map(by_rp_id: HashMap>) -> Self { + Self { by_rp_id } + } +} + +#[async_trait] +impl RelatedOriginsSource for StaticRelatedOriginsSource { + async fn allowed_origins( + &self, + rp_id: &RelyingPartyId, + ) -> Result, RelatedOriginsError> { + Ok(self + .by_rp_id + .get(rp_id.0.as_str()) + .cloned() + .unwrap_or_default()) + } +} + /// Runs the WebAuthn L3 §5.11.1 matching procedure: returns `Ok(())` if /// `caller_origin` is same-origin with one of `origins`, considering at most /// `max_labels` distinct registrable-domain labels. @@ -455,10 +494,7 @@ mod tests { #[test] fn unparseable_origin_item_skipped() { assert!(matches!( - validate( - "https://example.com", - &["not a url", "https://example.com"] - ), + validate("https://example.com", &["not a url", "https://example.com"]), Ok(()) )); } @@ -576,7 +612,12 @@ mod tests { #[tokio::test] async fn well_known_returns_origins() { - let res = fetch(200, Some("application/json"), r#"{"origins":["https://example.com"]}"#).await; + let res = fetch( + 200, + Some("application/json"), + r#"{"origins":["https://example.com"]}"#, + ) + .await; assert_eq!(res.unwrap(), vec!["https://example.com".to_string()]); } @@ -653,7 +694,12 @@ mod tests { #[tokio::test] async fn origins_not_array_rejected() { assert!(matches!( - fetch(200, Some("application/json"), r#"{"origins":"https://example.com"}"#).await, + fetch( + 200, + Some("application/json"), + r#"{"origins":"https://example.com"}"# + ) + .await, Err(RelatedOriginsError::MalformedDocument(_)) )); } @@ -668,14 +714,37 @@ mod tests { #[tokio::test] async fn transport_error_propagates_as_http() { - let source = - WellKnownRelatedOriginsSource::from_client(ErrHttpClient(HttpClientError::Transport( - "simulated".into(), - ))); + let source = WellKnownRelatedOriginsSource::from_client(ErrHttpClient( + HttpClientError::Transport("simulated".into()), + )); let res = source.allowed_origins(&rp("example.com")).await; assert!(matches!( res, Err(RelatedOriginsError::Http(HttpClientError::Transport(_))) )); } + + #[tokio::test] + async fn static_source_returns_listed_origins() { + let single = StaticRelatedOriginsSource::new("example.com", ["https://app.example.org"]); + assert_eq!( + single.allowed_origins(&rp("example.com")).await.unwrap(), + vec!["https://app.example.org".to_string()] + ); + assert!(single + .allowed_origins(&rp("other.com")) + .await + .unwrap() + .is_empty()); + + let multi = StaticRelatedOriginsSource::from_map( + [("a.com".to_string(), vec!["https://x.org".to_string()])] + .into_iter() + .collect(), + ); + assert_eq!( + multi.allowed_origins(&rp("a.com")).await.unwrap(), + vec!["https://x.org".to_string()] + ); + } } From f66a8605ba8179b4960429d3123007e6e6cdbebe Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sat, 30 May 2026 12:07:25 +0100 Subject: [PATCH 26/29] style(webauthn): apply rustfmt --- .../examples/ceremony/webauthn_cable.rs | 11 +++---- libwebauthn/src/ops/webauthn/get_assertion.rs | 29 ++++++------------- .../src/ops/webauthn/make_credential.rs | 9 +++--- libwebauthn/tests/related_origins.rs | 16 +++++++--- 4 files changed, 30 insertions(+), 35 deletions(-) diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index 85a0bb72..26ab076e 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -88,13 +88,10 @@ pub async fn main() -> Result<(), Box> { let state_recv = channel.get_ux_update_receiver(); tokio::spawn(common::handle_cable_updates(state_recv)); - let request = MakeCredentialRequest::prepare( - &request_origin, - MAKE_CREDENTIAL_REQUEST, - &settings, - ) - .await - .expect("Failed to parse request JSON"); + let request = + MakeCredentialRequest::prepare(&request_origin, MAKE_CREDENTIAL_REQUEST, &settings) + .await + .expect("Failed to parse request JSON"); let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); let response_json = response diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 76d0fe01..a17e4982 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -182,9 +182,8 @@ impl FromIdlModel for GetAssertionRequest ) -> Result { let effective_rp_id = request_origin.origin.host.as_str(); let resolved_rp_id = if let Some(relying_party_id) = inner.relying_party_id.as_deref() { - let parsed = RelyingPartyId::try_from(relying_party_id).map_err(|err| { - GetAssertionPrepareError::InvalidRelyingPartyId(err.to_string()) - })?; + let parsed = RelyingPartyId::try_from(relying_party_id) + .map_err(|err| GetAssertionPrepareError::InvalidRelyingPartyId(err.to_string()))?; if !rp_id_authorised(request_origin, &parsed, settings).await { return Err(GetAssertionPrepareError::MismatchingRelyingPartyId( parsed.0, @@ -683,7 +682,9 @@ mod tests { } fn err(e: RelatedOriginsError) -> Self { - Self { result: Some(Err(e)) } + Self { + result: Some(Err(e)), + } } fn panicking() -> Self { @@ -829,10 +830,7 @@ mod tests { .await; assert!(matches!( result, - Err(GetAssertionPrepareError::MismatchingRelyingPartyId( - _, - _ - )) + Err(GetAssertionPrepareError::MismatchingRelyingPartyId(_, _)) )); } @@ -869,10 +867,7 @@ mod tests { .await; assert!(matches!( result, - Err(GetAssertionPrepareError::MismatchingRelyingPartyId( - _, - _ - )) + Err(GetAssertionPrepareError::MismatchingRelyingPartyId(_, _)) )); } @@ -917,10 +912,7 @@ mod tests { .await; assert!(matches!( result, - Err(GetAssertionPrepareError::MismatchingRelyingPartyId( - _, - _ - )) + Err(GetAssertionPrepareError::MismatchingRelyingPartyId(_, _)) )); } @@ -944,10 +936,7 @@ mod tests { .await; assert!(matches!( result, - Err(GetAssertionPrepareError::MismatchingRelyingPartyId( - _, - _ - )) + Err(GetAssertionPrepareError::MismatchingRelyingPartyId(_, _)) )); } diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 5fb17845..53cb67b0 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -374,9 +374,8 @@ impl FromIdlModel for MakeCredentialRequ inner: PublicKeyCredentialCreationOptionsJSON, ) -> Result { let effective_rp_id = request_origin.origin.host.as_str(); - let rp_id = RelyingPartyId::try_from(inner.rp.id.as_str()).map_err(|err| { - MakeCredentialPrepareError::InvalidRelyingPartyId(err.to_string()) - })?; + let rp_id = RelyingPartyId::try_from(inner.rp.id.as_str()) + .map_err(|err| MakeCredentialPrepareError::InvalidRelyingPartyId(err.to_string()))?; if !rp_id_authorised(request_origin, &rp_id, settings).await { return Err(MakeCredentialPrepareError::MismatchingRelyingPartyId( rp_id.0, @@ -722,7 +721,9 @@ mod tests { } fn err(e: RelatedOriginsError) -> Self { - Self { result: Some(Err(e)) } + Self { + result: Some(Err(e)), + } } fn panicking() -> Self { diff --git a/libwebauthn/tests/related_origins.rs b/libwebauthn/tests/related_origins.rs index 4f980b73..cf6ac0af 100644 --- a/libwebauthn/tests/related_origins.rs +++ b/libwebauthn/tests/related_origins.rs @@ -6,7 +6,8 @@ use async_trait::async_trait; use libwebauthn::ops::webauthn::{ GetAssertionRequest, HttpClient, HttpClientError, MakeCredentialRequest, MaxRegistrableLabels, - PublicSuffixList, RelatedOrigins, RequestOrigin, RequestSettings, WellKnownRelatedOriginsSource, + PublicSuffixList, RelatedOrigins, RequestOrigin, RequestSettings, + WellKnownRelatedOriginsSource, }; const KNOWN_SUFFIXES: &[&str] = &["com", "org"]; @@ -80,7 +81,10 @@ const GET_ASSERTION_JSON: &str = r#" // related-origins fetch path is actually exercised. const WELL_KNOWN_BODY: &str = r#"{"origins":["https://app.example.com","https://example.org"]}"#; -fn settings<'a>(psl: &'a TestPsl, source: &'a WellKnownRelatedOriginsSource) -> RequestSettings<'a> { +fn settings<'a>( + psl: &'a TestPsl, + source: &'a WellKnownRelatedOriginsSource, +) -> RequestSettings<'a> { RequestSettings { public_suffix_list: psl, related_origins: RelatedOrigins::Enabled { @@ -94,7 +98,9 @@ fn settings<'a>(psl: &'a TestPsl, source: &'a WellKnownRelatedOriginsSource Date: Sat, 30 May 2026 17:13:57 +0100 Subject: [PATCH 27/29] docs: add module-level documentation (#237) Adds a short module-level overview to the crate root and each top-level module. Each one is one or two paragraphs describing the module's purpose and its main public types, so the docs.rs landing page and every module page open with context instead of a bare list of items. This is documentation only, with no code changes. --- libwebauthn/src/fido.rs | 14 +++++++ libwebauthn/src/lib.rs | 67 ++++++++++++++++++++++++++++++++ libwebauthn/src/management.rs | 13 +++++++ libwebauthn/src/ops/mod.rs | 23 +++++++++++ libwebauthn/src/pin.rs | 15 +++++++ libwebauthn/src/proto/mod.rs | 13 +++++++ libwebauthn/src/transport/mod.rs | 13 +++++++ libwebauthn/src/u2f.rs | 10 +++++ libwebauthn/src/webauthn.rs | 15 +++++++ 9 files changed, 183 insertions(+) diff --git a/libwebauthn/src/fido.rs b/libwebauthn/src/fido.rs index 68b4e16b..1d5f2cf3 100644 --- a/libwebauthn/src/fido.rs +++ b/libwebauthn/src/fido.rs @@ -1,3 +1,17 @@ +//! Protocol abstractions shared between FIDO2 (CTAP2) and FIDO U2F (CTAP1). +//! This module models the common protocol surface of the two standards, +//! including protocol negotiation via [`FidoProtocol`], revision tracking via +//! [`FidoRevision`], and the [`AuthenticatorData`] structure returned by a +//! device during authentication and attestation ceremonies. +//! +//! [`AuthenticatorData`] is the central type. It carries the relying party ID +//! hash, the user presence and verification flags, the signature counter, +//! optional attested credential data (the device AAGUID, credential ID, and +//! credential public key), and any protocol extension outputs. The module +//! serializes and deserializes these responses per the CTAP2 specification and +//! preserves the COSE key bytes verbatim, so the device signatures a relying +//! party verifies stay intact. + use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use serde::{ de::{DeserializeOwned, Error as DesError, Visitor}, diff --git a/libwebauthn/src/lib.rs b/libwebauthn/src/lib.rs index d7f4abd2..86658692 100644 --- a/libwebauthn/src/lib.rs +++ b/libwebauthn/src/lib.rs @@ -1,3 +1,70 @@ +//! libwebauthn is a Linux platform library implementing the FIDO2/WebAuthn and +//! FIDO U2F specifications. It provides traits for performing WebAuthn +//! operations (make credential and get assertion) and U2F operations (register +//! and sign) against authenticator devices connected over USB, Bluetooth, or +//! optionally NFC. The core abstractions are the [`WebAuthn`](webauthn::WebAuthn) +//! and [`U2F`](u2f::U2F) traits that drive the protocol logic, and the +//! [`Channel`](transport::Channel) trait that abstracts communication with a +//! physical authenticator. +//! +//! While an operation runs, the library may need user verification. It surfaces +//! this through the [`UvUpdate`] enum and related types such as +//! [`PinRequiredUpdate`] and [`PinNotSetUpdate`], which let an application +//! collect input (PIN entry or a presence confirmation) and feed it back into +//! the ongoing operation. The [`transport`] module manages device discovery and +//! communication, and the [`pin`] module handles the cryptographic side of PIN +//! and user verification. +//! +//! # Getting started +//! +//! A typical flow is to enumerate devices on your transport of choice, open a +//! [`Channel`](transport::Channel) to each one, and run a ceremony on that +//! channel. The ceremony traits are blanket-implemented for every channel: +//! [`WebAuthn`](webauthn::WebAuthn) for make-credential and get-assertion, and +//! the [`management`] traits ([`CredentialManagement`](management::CredentialManagement), +//! [`AuthenticatorConfig`](management::AuthenticatorConfig), +//! [`BioEnrollment`](management::BioEnrollment)) for the administrative +//! ceremonies. The make-credential and get-assertion requests are built from +//! their WebAuthn IDL (the JSON the browser API speaks) with +//! [`MakeCredentialRequest::prepare`](ops::webauthn::MakeCredentialRequest::prepare) +//! and [`GetAssertionRequest::prepare`](ops::webauthn::GetAssertionRequest::prepare), +//! which validate the caller origin against the relying party ID. +//! +//! ```no_run +//! use libwebauthn::ops::webauthn::{ +//! MakeCredentialRequest, RelatedOrigins, RequestOrigin, RequestSettings, +//! SystemPublicSuffixList, +//! }; +//! use libwebauthn::transport::hid::list_devices; +//! use libwebauthn::transport::Device; +//! use libwebauthn::webauthn::WebAuthn; +//! +//! # async fn run() -> Result<(), Box> { +//! // 1. Enumerate authenticators on your transport of choice (HID shown here). +//! let devices = list_devices().await?; +//! +//! for mut device in devices { +//! // 2. Open a channel to the device. +//! let mut channel = device.channel().await?; +//! +//! // 3. Build a request from its WebAuthn IDL JSON. +//! let origin: RequestOrigin = "https://example.org".try_into().expect("invalid origin"); +//! let psl = SystemPublicSuffixList::auto().expect("public suffix list unavailable"); +//! let settings = RequestSettings { +//! public_suffix_list: &psl, +//! related_origins: RelatedOrigins::Disabled, +//! }; +//! let request_json = r#"{ "rp": { "id": "example.org", "name": "Example" } }"#; // abbreviated +//! let request = +//! MakeCredentialRequest::prepare(&origin, request_json, &settings).await?; +//! +//! // 4. Run the ceremony on the channel. +//! let _response = channel.webauthn_make_credential(&request).await?; +//! } +//! # Ok(()) +//! # } +//! ``` + // Production code must not panic. Tests keep unwrap/expect/panic latitude // through `not(test)`, and the virt test-utility code through local allows. #![cfg_attr(not(test), deny(clippy::unwrap_used))] diff --git a/libwebauthn/src/management.rs b/libwebauthn/src/management.rs index 8ff3f9da..9534ef3f 100644 --- a/libwebauthn/src/management.rs +++ b/libwebauthn/src/management.rs @@ -1,3 +1,16 @@ +//! Administration interfaces for CTAP2 authenticators. This module exposes +//! traits for managing device configuration, biometric enrollment, and stored +//! credentials over any [`Channel`](crate::transport::Channel) transport. These +//! operations require user verification through a PIN or biometric factor, and +//! the protocol handles token acquisition and retry internally. +//! +//! Use [`CredentialManagement`] to enumerate and delete resident credentials, +//! [`AuthenticatorConfig`] to adjust device settings such as PIN policy and +//! enterprise attestation, and [`BioEnrollment`] to manage biometric templates. +//! Each trait is blanket-implemented for any +//! [`Channel`](crate::transport::Channel), so the same API works across every +//! transport. + mod bio_enrollment; pub use bio_enrollment::BioEnrollment; diff --git a/libwebauthn/src/ops/mod.rs b/libwebauthn/src/ops/mod.rs index 73931b52..df610d27 100644 --- a/libwebauthn/src/ops/mod.rs +++ b/libwebauthn/src/ops/mod.rs @@ -1,2 +1,25 @@ +//! Request and response types for WebAuthn registration and authentication +//! ceremonies, alongside the legacy FIDO U2F operation types. The main types are +//! [`MakeCredentialRequest`](webauthn::MakeCredentialRequest) and +//! [`GetAssertionRequest`](webauthn::GetAssertionRequest), which describe the +//! parameters for creating and asserting a credential, and the matching +//! [`MakeCredentialResponse`](webauthn::MakeCredentialResponse) and +//! [`GetAssertionResponse`](webauthn::GetAssertionResponse) that carry the +//! outcome. The request types also cover WebAuthn extensions such as PRF, +//! HMAC secret, credProtect, and large blob storage. +//! +//! Always build these requests from their WebAuthn IDL JSON with +//! [`MakeCredentialRequest::prepare`](webauthn::MakeCredentialRequest::prepare) +//! and [`GetAssertionRequest::prepare`](webauthn::GetAssertionRequest::prepare): +//! `prepare` validates the caller origin against the relying party ID, so it is +//! the only safe way to construct an operation. +//! +//! The module also defines the operation kinds and user verification +//! requirements, client data hashing helpers, and conversions between the +//! WebAuthn IDL (JSON) representation and the internal types. A U2F response can +//! be promoted to its WebAuthn equivalent through the +//! [`UpgradableResponse`](u2f::UpgradableResponse) trait, which bridges legacy +//! credentials into the WebAuthn world. + pub mod u2f; pub mod webauthn; diff --git a/libwebauthn/src/pin.rs b/libwebauthn/src/pin.rs index 3c022fa2..ffb5b86f 100644 --- a/libwebauthn/src/pin.rs +++ b/libwebauthn/src/pin.rs @@ -1,3 +1,18 @@ +//! PIN and user-verification interaction for CTAP2 authenticators. This module +//! implements the PIN/UV authentication protocols (`PinUvAuthProtocolOne` and +//! `PinUvAuthProtocolTwo`), which perform ECDH key agreement and AES-CBC +//! encryption with HMAC authentication. These are the primitives used to derive +//! a shared secret, encrypt a PIN, and produce the authentication parameters +//! sent during registration and assertion. +//! +//! The public surface exposes [`PinRequestReason`] and [`PinNotSetReason`] so a +//! caller can tell why a PIN is being requested or why setting one failed, and +//! [`PinManagement`] for initiating PIN changes. In normal use a caller does not +//! touch this module directly. PIN requests reach the application through the +//! [`UvUpdate`](crate::UvUpdate) flow in the crate root, delivered as +//! [`PinRequiredUpdate`](crate::PinRequiredUpdate) and +//! [`PinNotSetUpdate`](crate::PinNotSetUpdate). + use std::time::Duration; use aes::cipher::{block_padding::NoPadding, BlockDecryptMut}; diff --git a/libwebauthn/src/proto/mod.rs b/libwebauthn/src/proto/mod.rs index f7080d93..46506aa8 100644 --- a/libwebauthn/src/proto/mod.rs +++ b/libwebauthn/src/proto/mod.rs @@ -1,3 +1,16 @@ +//! The wire protocol layer for FIDO2/CTAP2 and FIDO U2F/CTAP1 authenticators. +//! This module defines how device commands and responses are encoded and +//! decoded: the CBOR request and response structures for CTAP2, the APDU frames +//! for CTAP1, COSE keys, attestation statements, and the protocol status codes +//! in [`CtapError`]. The [`Ctap1`](ctap1::Ctap1) and [`Ctap2`](ctap2::Ctap2) +//! handlers drive these exchanges at the wire level. +//! +//! Alongside the core commands it covers CTAP2 preflight (checking which +//! credentials a device holds before a ceremony), the PIN/UV authentication +//! protocols, client PIN management, biometric enrollment, and on-device +//! credential management. The encodings follow the FIDO Alliance specifications +//! and account for the difference between APDU-based CTAP1 and CBOR-based CTAP2. + mod error; pub mod ctap1; diff --git a/libwebauthn/src/transport/mod.rs b/libwebauthn/src/transport/mod.rs index 1e294b06..8f6c0306 100644 --- a/libwebauthn/src/transport/mod.rs +++ b/libwebauthn/src/transport/mod.rs @@ -1,3 +1,16 @@ +//! Transport layer that carries CTAP messages to FIDO2/WebAuthn authenticators. +//! It abstracts over several physical media: HID (USB), BLE (Bluetooth Low +//! Energy), caBLE (the cable/hybrid mode), and NFC (available when the +//! `nfc-backend-pcsc` or `nfc-backend-libnfc` feature is enabled). The two core +//! abstractions are [`Channel`], an open session with an authenticator that +//! sends and receives CTAP messages, and [`Device`], a discovered authenticator +//! from which a [`Channel`] can be opened. +//! +//! Supporting pieces include the [`Transport`] trait that identifies a specific +//! transport implementation and the [`Ctap2AuthTokenStore`] trait for caching +//! PIN/UV authentication tokens across operations. Each medium provides its own +//! channel and device adapters suited to its protocol and hardware constraints. + pub(crate) mod error; pub mod ble; diff --git a/libwebauthn/src/u2f.rs b/libwebauthn/src/u2f.rs index c448ad64..79481e6b 100644 --- a/libwebauthn/src/u2f.rs +++ b/libwebauthn/src/u2f.rs @@ -1,3 +1,13 @@ +//! High-level FIDO U2F (CTAP1) client API for registering and authenticating +//! against an authenticator device. The [`U2F`] trait is blanket-implemented for +//! any [`Channel`] and offers three async operations: protocol negotiation, +//! registration, and signing. +//! +//! [`RegisterRequest`] and [`SignRequest`] describe the inputs, while the +//! corresponding response types carry the results back to the caller. The trait +//! handles the full lifecycle of a U2F exchange, from negotiating the device +//! protocol through running the request with error handling. + use async_trait::async_trait; use tracing::{instrument, warn}; diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index ca537a06..f4031fec 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -1,3 +1,18 @@ +//! High-level FIDO2 (CTAP2) client API for WebAuthn ceremonies. The [`WebAuthn`] +//! trait is blanket-implemented for any [`Channel`]. Its +//! [`webauthn_make_credential`](WebAuthn::webauthn_make_credential) and +//! [`webauthn_get_assertion`](WebAuthn::webauthn_get_assertion) methods run the +//! full CTAP2 make-credential and get-assertion ceremonies, including the user +//! verification flow, PIN and biometric token handling, credential filtering via +//! preflight, and extension support. When a device does not support FIDO2, the +//! ceremony falls back to U2F (CTAP1). +//! +//! User verification is handled internally by the [`pin_uv_auth_token`] module, +//! which manages PIN and biometric UV, reuse of a cached pinUvAuthToken, shared +//! secret establishment, and the fallback from biometric to PIN. Failures are +//! reported as [`Error`], which distinguishes CTAP protocol errors +//! ([`CtapError`]), transport errors, and platform errors. + pub mod error; pub mod pin_uv_auth_token; From fcfce6b4cbb4522abe98a8bc62dc341beeaa5955 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sat, 30 May 2026 16:51:46 +0100 Subject: [PATCH 28/29] feat(webauthn): add OriginValidation::Trust to bypass the rp.id check --- libwebauthn/examples/ceremony/webauthn_ble.rs | 12 +++-- .../examples/ceremony/webauthn_cable.rs | 12 +++-- .../examples/ceremony/webauthn_cable_wss.rs | 16 +++--- libwebauthn/examples/ceremony/webauthn_hid.rs | 16 +++--- libwebauthn/examples/ceremony/webauthn_nfc.rs | 16 +++--- libwebauthn/src/lib.rs | 8 +-- libwebauthn/src/ops/webauthn/get_assertion.rs | 24 +++++++-- libwebauthn/src/ops/webauthn/idl/mod.rs | 39 +++++++++++---- .../src/ops/webauthn/make_credential.rs | 50 +++++++++++++++++-- libwebauthn/src/ops/webauthn/mod.rs | 4 +- libwebauthn/tests/related_origins.rs | 12 +++-- 11 files changed, 153 insertions(+), 56 deletions(-) diff --git a/libwebauthn/examples/ceremony/webauthn_ble.rs b/libwebauthn/examples/ceremony/webauthn_ble.rs index afe9d02a..e44ac390 100644 --- a/libwebauthn/examples/ceremony/webauthn_ble.rs +++ b/libwebauthn/examples/ceremony/webauthn_ble.rs @@ -2,7 +2,7 @@ use std::error::Error; use libwebauthn::ops::webauthn::{ DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, - MaxRegistrableLabels, RelatedOrigins, RequestOrigin, RequestSettings, + MaxRegistrableLabels, OriginValidation, RelatedOrigins, RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, WebAuthnIDLResponse as _, }; use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; @@ -55,10 +55,12 @@ pub async fn main() -> Result<(), Box> { "#; let related_origins = ReqwestRelatedOriginsSource::new()?; let settings = RequestSettings { - public_suffix_list: &psl, - related_origins: RelatedOrigins::Enabled { - source: &related_origins, - max_labels: MaxRegistrableLabels::default(), + origin: OriginValidation::Validate { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Enabled { + source: &related_origins, + max_labels: MaxRegistrableLabels::default(), + }, }, }; let make_credentials_request: MakeCredentialRequest = diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index 26ab076e..9bf2e431 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -12,7 +12,7 @@ use qrcode::QrCode; use libwebauthn::ops::webauthn::{ DatFilePublicSuffixList, JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, - RelatedOrigins, RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, + OriginValidation, RelatedOrigins, RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, WebAuthnIDLResponse as _, }; use libwebauthn::transport::{Channel as _, Device}; @@ -61,10 +61,12 @@ pub async fn main() -> Result<(), Box> { ); let related_origins = ReqwestRelatedOriginsSource::new()?; let settings = RequestSettings { - public_suffix_list: &psl, - related_origins: RelatedOrigins::Enabled { - source: &related_origins, - max_labels: MaxRegistrableLabels::default(), + origin: OriginValidation::Validate { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Enabled { + source: &related_origins, + max_labels: MaxRegistrableLabels::default(), + }, }, }; diff --git a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs index a86a2bff..9c5b3876 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs @@ -14,8 +14,8 @@ use qrcode::QrCode; use tokio::time::sleep; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, RelatedOrigins, RequestOrigin, - RequestSettings, SystemPublicSuffixList, WebAuthnIDLResponse as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, OriginValidation, RelatedOrigins, + RequestOrigin, RequestSettings, SystemPublicSuffixList, WebAuthnIDLResponse as _, }; use libwebauthn::transport::cable::channel::CableChannel; use libwebauthn::transport::{Channel as _, Device}; @@ -99,8 +99,10 @@ pub async fn main() -> Result<(), Box> { &request_origin, MAKE_CREDENTIAL_REQUEST, &RequestSettings { - public_suffix_list: &psl, - related_origins: RelatedOrigins::Disabled, + origin: OriginValidation::Validate { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Disabled, + }, }, ) .await @@ -166,8 +168,10 @@ async fn run_get_assertion( request_origin, GET_ASSERTION_REQUEST, &RequestSettings { - public_suffix_list: psl, - related_origins: RelatedOrigins::Disabled, + origin: OriginValidation::Validate { + public_suffix_list: psl, + related_origins: RelatedOrigins::Disabled, + }, }, ) .await diff --git a/libwebauthn/examples/ceremony/webauthn_hid.rs b/libwebauthn/examples/ceremony/webauthn_hid.rs index 2028d711..8b3b3fa9 100644 --- a/libwebauthn/examples/ceremony/webauthn_hid.rs +++ b/libwebauthn/examples/ceremony/webauthn_hid.rs @@ -2,9 +2,9 @@ use std::error::Error; use std::time::Duration; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, RelatedOrigins, - RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, SystemPublicSuffixList, - WebAuthnIDLResponse as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, OriginValidation, + RelatedOrigins, RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, + SystemPublicSuffixList, WebAuthnIDLResponse as _, }; use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; use libwebauthn::transport::hid::list_devices; @@ -35,10 +35,12 @@ pub async fn main() -> Result<(), Box> { ); let related_origins = ReqwestRelatedOriginsSource::new()?; let settings = RequestSettings { - public_suffix_list: &psl, - related_origins: RelatedOrigins::Enabled { - source: &related_origins, - max_labels: MaxRegistrableLabels::default(), + origin: OriginValidation::Validate { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Enabled { + source: &related_origins, + max_labels: MaxRegistrableLabels::default(), + }, }, }; let request_json = r#" diff --git a/libwebauthn/examples/ceremony/webauthn_nfc.rs b/libwebauthn/examples/ceremony/webauthn_nfc.rs index 89013fb2..ab3f4f1a 100644 --- a/libwebauthn/examples/ceremony/webauthn_nfc.rs +++ b/libwebauthn/examples/ceremony/webauthn_nfc.rs @@ -1,9 +1,9 @@ use std::error::Error; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, RelatedOrigins, - RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, SystemPublicSuffixList, - WebAuthnIDLResponse as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, OriginValidation, + RelatedOrigins, RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, + SystemPublicSuffixList, WebAuthnIDLResponse as _, }; use libwebauthn::transport::nfc::{get_nfc_device, is_nfc_available}; use libwebauthn::transport::{Channel as _, Device}; @@ -33,10 +33,12 @@ pub async fn main() -> Result<(), Box> { ); let related_origins = ReqwestRelatedOriginsSource::new()?; let settings = RequestSettings { - public_suffix_list: &psl, - related_origins: RelatedOrigins::Enabled { - source: &related_origins, - max_labels: MaxRegistrableLabels::default(), + origin: OriginValidation::Validate { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Enabled { + source: &related_origins, + max_labels: MaxRegistrableLabels::default(), + }, }, }; let make_credentials_request = MakeCredentialRequest::prepare( diff --git a/libwebauthn/src/lib.rs b/libwebauthn/src/lib.rs index 86658692..2aa0a380 100644 --- a/libwebauthn/src/lib.rs +++ b/libwebauthn/src/lib.rs @@ -32,7 +32,7 @@ //! //! ```no_run //! use libwebauthn::ops::webauthn::{ -//! MakeCredentialRequest, RelatedOrigins, RequestOrigin, RequestSettings, +//! MakeCredentialRequest, OriginValidation, RelatedOrigins, RequestOrigin, RequestSettings, //! SystemPublicSuffixList, //! }; //! use libwebauthn::transport::hid::list_devices; @@ -51,8 +51,10 @@ //! let origin: RequestOrigin = "https://example.org".try_into().expect("invalid origin"); //! let psl = SystemPublicSuffixList::auto().expect("public suffix list unavailable"); //! let settings = RequestSettings { -//! public_suffix_list: &psl, -//! related_origins: RelatedOrigins::Disabled, +//! origin: OriginValidation::Validate { +//! public_suffix_list: &psl, +//! related_origins: RelatedOrigins::Disabled, +//! }, //! }; //! let request_json = r#"{ "rp": { "id": "example.org", "name": "Example" } }"#; // abbreviated //! let request = diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index a17e4982..77b1bd9b 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -663,7 +663,7 @@ mod tests { HttpClientError, MaxRegistrableLabels, RelatedOrigins, RelatedOriginsError, RelatedOriginsSource, }; - use crate::ops::webauthn::{GetAssertionRequest, RequestOrigin}; + use crate::ops::webauthn::{GetAssertionRequest, OriginValidation, RequestOrigin}; use crate::proto::ctap2::Ctap2PublicKeyCredentialType; use super::*; @@ -715,8 +715,10 @@ mod tests { origin, json, &RequestSettings { - public_suffix_list: psl, - related_origins, + origin: OriginValidation::Validate { + public_suffix_list: psl, + related_origins, + }, }, ) .await @@ -834,6 +836,22 @@ mod tests { )); } + #[tokio::test] + async fn origin_trust_accepts_mismatching_rp_id() { + let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap(); + let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#); + let req = GetAssertionRequest::prepare( + &request_origin, + &req_json, + &RequestSettings { + origin: OriginValidation::Trust, + }, + ) + .await + .unwrap(); + assert_eq!(req.relying_party_id, "example.com"); + } + #[tokio::test] async fn test_request_from_json_rp_id_is_parent_registrable_suffix() { // origin = login.example.org, rp.id = example.org -> accepted. diff --git a/libwebauthn/src/ops/webauthn/idl/mod.rs b/libwebauthn/src/ops/webauthn/idl/mod.rs index 239b1d35..b9625e63 100644 --- a/libwebauthn/src/ops/webauthn/idl/mod.rs +++ b/libwebauthn/src/ops/webauthn/idl/mod.rs @@ -26,11 +26,23 @@ use super::related_origins::{validate_related_origins, RelatedOrigins}; pub type JsonError = serde_json::Error; -/// Dependencies for origin validation: the Public Suffix List (rp.id suffix -/// check and related-origins matching) and the related-origins policy. +/// Per-request settings (currently just the origin-validation policy). pub struct RequestSettings<'a> { - pub public_suffix_list: &'a dyn PublicSuffixList, - pub related_origins: RelatedOrigins<'a>, + pub origin: OriginValidation<'a>, +} + +/// How the caller origin is validated against the request rp.id. +pub enum OriginValidation<'a> { + /// Trust the caller's origin to rp.id binding with no check, for callers + /// that have already validated it (e.g. a browser). Misuse defeats phishing + /// resistance, so the caller owns that decision. + Trust, + /// Validate rp.id against the caller origin: a registrable suffix of the + /// effective domain, then related origins on mismatch. + Validate { + public_suffix_list: &'a dyn PublicSuffixList, + related_origins: RelatedOrigins<'a>, + }, } /// Builds a request from its parsed IDL model, validating origin against rp.id. @@ -48,19 +60,26 @@ where ) -> Result; } -/// Whether `request_origin` may act for `rp_id`: a registrable suffix of the -/// caller's effective domain, or a matching related origin when enabled. +/// Whether `request_origin` may act for `rp_id`. `Trust` accepts any rp.id; +/// `Validate` requires a registrable suffix of the caller's effective domain or +/// a matching related origin. pub(crate) async fn rp_id_authorised( request_origin: &RequestOrigin, rp_id: &RelyingPartyId, settings: &RequestSettings<'_>, ) -> bool { + let (public_suffix_list, related_origins) = match &settings.origin { + OriginValidation::Trust => return true, + OriginValidation::Validate { + public_suffix_list, + related_origins, + } => (*public_suffix_list, related_origins), + }; let effective_rp_id = request_origin.origin.host.as_str(); - if is_registrable_domain_suffix_or_equal(&rp_id.0, effective_rp_id, settings.public_suffix_list) - { + if is_registrable_domain_suffix_or_equal(&rp_id.0, effective_rp_id, public_suffix_list) { return true; } - match &settings.related_origins { + match related_origins { RelatedOrigins::Disabled => false, RelatedOrigins::Enabled { source, max_labels } => { match source.allowed_origins(rp_id).await { @@ -71,7 +90,7 @@ pub(crate) async fn rp_id_authorised( Ok(origins) => match validate_related_origins( &request_origin.origin, &origins, - settings.public_suffix_list, + public_suffix_list, *max_labels, ) { Ok(()) => true, diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 53cb67b0..ed8acb5c 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -702,7 +702,7 @@ mod tests { HttpClientError, MaxRegistrableLabels, RelatedOrigins, RelatedOriginsError, RelatedOriginsSource, }; - use crate::ops::webauthn::{MakeCredentialRequest, RequestOrigin}; + use crate::ops::webauthn::{MakeCredentialRequest, OriginValidation, RequestOrigin}; use crate::proto::ctap2::Ctap2PublicKeyCredentialType; use super::*; @@ -754,8 +754,10 @@ mod tests { origin, json, &RequestSettings { - public_suffix_list: psl, - related_origins, + origin: OriginValidation::Validate { + public_suffix_list: psl, + related_origins, + }, }, ) .await @@ -1095,6 +1097,48 @@ mod tests { )); } + #[tokio::test] + async fn origin_trust_accepts_mismatching_rp_id() { + let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "rp", + r#"{"id": "example.com", "name": "example.com"}"#, + ); + let req = MakeCredentialRequest::prepare( + &request_origin, + &req_json, + &RequestSettings { + origin: OriginValidation::Trust, + }, + ) + .await + .unwrap(); + assert_eq!(req.relying_party.id, "example.com"); + } + + #[tokio::test] + async fn origin_trust_still_rejects_invalid_rp_id() { + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "rp", + r#"{"id": "example.org.", "name": "example.org"}"#, + ); + let result = MakeCredentialRequest::prepare( + &request_origin, + &req_json, + &RequestSettings { + origin: OriginValidation::Trust, + }, + ) + .await; + assert!(matches!( + result, + Err(MakeCredentialPrepareError::InvalidRelyingPartyId(_)) + )); + } + #[tokio::test] async fn test_request_from_json_mismatching_rp_id() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 63c45b2a..0caa0bc6 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -21,8 +21,8 @@ pub use idl::{ rpid::RelyingPartyId, AuthenticationExtensionsClientOutputsJSON, AuthenticationResponseJSON, AuthenticatorAssertionResponseJSON, AuthenticatorAttestationResponseJSON, Base64UrlString, - JsonFormat, RegistrationResponseJSON, RequestSettings, ResponseSerializationError, - WebAuthnIDLResponse, + JsonFormat, OriginValidation, RegistrationResponseJSON, RequestSettings, + ResponseSerializationError, WebAuthnIDLResponse, }; pub use make_credential::{ CredentialPropsExtension, CredentialProtectionExtension, CredentialProtectionPolicy, diff --git a/libwebauthn/tests/related_origins.rs b/libwebauthn/tests/related_origins.rs index cf6ac0af..d7c2b71e 100644 --- a/libwebauthn/tests/related_origins.rs +++ b/libwebauthn/tests/related_origins.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use libwebauthn::ops::webauthn::{ GetAssertionRequest, HttpClient, HttpClientError, MakeCredentialRequest, MaxRegistrableLabels, - PublicSuffixList, RelatedOrigins, RequestOrigin, RequestSettings, + OriginValidation, PublicSuffixList, RelatedOrigins, RequestOrigin, RequestSettings, WellKnownRelatedOriginsSource, }; @@ -86,10 +86,12 @@ fn settings<'a>( source: &'a WellKnownRelatedOriginsSource, ) -> RequestSettings<'a> { RequestSettings { - public_suffix_list: psl, - related_origins: RelatedOrigins::Enabled { - source, - max_labels: MaxRegistrableLabels::default(), + origin: OriginValidation::Validate { + public_suffix_list: psl, + related_origins: RelatedOrigins::Enabled { + source, + max_labels: MaxRegistrableLabels::default(), + }, }, } } From c0119304028c3b70c54340164026a0bd8f1ac0ca Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sat, 30 May 2026 18:00:25 +0100 Subject: [PATCH 29/29] refactor(examples): drop related origins from ceremony examples, add dedicated webauthn_related_origins_hid --- README.md | 15 ++-- libwebauthn/Cargo.toml | 11 +-- libwebauthn/examples/ceremony/webauthn_ble.rs | 9 +- .../examples/ceremony/webauthn_cable.rs | 11 +-- libwebauthn/examples/ceremony/webauthn_hid.rs | 11 +-- libwebauthn/examples/ceremony/webauthn_nfc.rs | 11 +-- .../features/webauthn_related_origins_hid.rs | 85 +++++++++++++++++++ 7 files changed, 111 insertions(+), 42 deletions(-) create mode 100644 libwebauthn/examples/features/webauthn_related_origins_hid.rs diff --git a/README.md b/README.md index 4f1c54c1..574e72b3 100644 --- a/README.md +++ b/README.md @@ -70,15 +70,15 @@ WebAuthn examples consume and emit JSON per the [WebAuthn IDL][webauthn]. | Transport | FIDO U2F | WebAuthn (FIDO2) [^ro] | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **USB (HID)** | `cargo run --example u2f_hid` | `cargo run --features reqwest-related-origins-source --example webauthn_hid` | -| **Bluetooth (BLE)** | `cargo run --example u2f_ble` | `cargo run --features reqwest-related-origins-source --example webauthn_ble` | -| **NFC** [^nfc] | `cargo run --features nfc-backend-pcsc --example u2f_nfc`
`cargo run --features nfc-backend-libnfc --example u2f_nfc` | `cargo run --features nfc-backend-pcsc,reqwest-related-origins-source --example webauthn_nfc`
`cargo run --features nfc-backend-libnfc,reqwest-related-origins-source --example webauthn_nfc` | -| **Hybrid (caBLE v2 + CTAP 2.3)** | — | `cargo run --features reqwest-related-origins-source --example webauthn_cable` | -| **Hybrid (caBLE v2)** | — | `cargo run --features reqwest-related-origins-source --example webauthn_cable_wss` | +| **USB (HID)** | `cargo run --example u2f_hid` | `cargo run --example webauthn_hid` | +| **Bluetooth (BLE)** | `cargo run --example u2f_ble` | `cargo run --example webauthn_ble` | +| **NFC** [^nfc] | `cargo run --features nfc-backend-pcsc --example u2f_nfc`
`cargo run --features nfc-backend-libnfc --example u2f_nfc` | `cargo run --features nfc-backend-pcsc --example webauthn_nfc`
`cargo run --features nfc-backend-libnfc --example webauthn_nfc` | +| **Hybrid (caBLE v2 + CTAP 2.3)** | — | `cargo run --example webauthn_cable` | +| **Hybrid (caBLE v2)** | — | `cargo run --example webauthn_cable_wss` | [^nfc]: `nfc-backend-pcsc` is pure userspace and recommended on most systems. `nfc-backend-libnfc` requires the `libnfc` system library. Both can be enabled together; the first FIDO device found by either backend is used. -[^ro]: The WebAuthn ceremony examples wire up the bundled reqwest-backed [related-origins](https://www.w3.org/TR/webauthn-3/#sctn-related-origins) source, which lives behind the optional `reqwest-related-origins-source` feature. Consumers that already ship their own HTTP stack can implement `HttpClient` or `RelatedOriginsSource` directly and omit the feature. +[^ro]: The ceremony examples run with related origins disabled (they are same-origin, so it never applies). The bundled reqwest-backed [related-origins](https://www.w3.org/TR/webauthn-3/#sctn-related-origins) source is shown in the `webauthn_related_origins_hid` example below, behind the optional `reqwest-related-origins-source` feature. Consumers that ship their own HTTP stack can implement `HttpClient` or `RelatedOriginsSource` directly. Additional HID-only examples cover specific FIDO2 features and authenticator management: @@ -90,6 +90,9 @@ $ cargo run --example webauthn_prf_hid $ cargo run --example prf_replay -- CREDENTIAL_ID FIRST_PRF_INPUT $ cargo run --example device_selection_hid +# Related origins (reqwest-backed well-known fetch) +$ cargo run --features reqwest-related-origins-source --example webauthn_related_origins_hid + # CTAP2 authenticator management $ cargo run --example change_pin_hid $ cargo run --example bio_enrollment_hid diff --git a/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml index 4be55763..78b6490b 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -142,27 +142,23 @@ required-features = ["nfc"] [[example]] name = "webauthn_hid" path = "examples/ceremony/webauthn_hid.rs" -required-features = ["reqwest-related-origins-source"] [[example]] name = "webauthn_ble" path = "examples/ceremony/webauthn_ble.rs" -required-features = ["reqwest-related-origins-source"] [[example]] name = "webauthn_nfc" path = "examples/ceremony/webauthn_nfc.rs" -required-features = ["nfc", "reqwest-related-origins-source"] +required-features = ["nfc"] [[example]] name = "webauthn_cable" path = "examples/ceremony/webauthn_cable.rs" -required-features = ["reqwest-related-origins-source"] [[example]] name = "webauthn_cable_wss" path = "examples/ceremony/webauthn_cable_wss.rs" -required-features = ["reqwest-related-origins-source"] [[example]] name = "webauthn_extensions_hid" @@ -176,6 +172,11 @@ path = "examples/features/webauthn_preflight_hid.rs" name = "webauthn_prf_hid" path = "examples/features/webauthn_prf_hid.rs" +[[example]] +name = "webauthn_related_origins_hid" +path = "examples/features/webauthn_related_origins_hid.rs" +required-features = ["reqwest-related-origins-source"] + [[example]] name = "prf_replay" path = "examples/features/prf_replay.rs" diff --git a/libwebauthn/examples/ceremony/webauthn_ble.rs b/libwebauthn/examples/ceremony/webauthn_ble.rs index e44ac390..0f9df727 100644 --- a/libwebauthn/examples/ceremony/webauthn_ble.rs +++ b/libwebauthn/examples/ceremony/webauthn_ble.rs @@ -2,8 +2,7 @@ use std::error::Error; use libwebauthn::ops::webauthn::{ DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, - MaxRegistrableLabels, OriginValidation, RelatedOrigins, RequestOrigin, RequestSettings, - ReqwestRelatedOriginsSource, WebAuthnIDLResponse as _, + OriginValidation, RelatedOrigins, RequestOrigin, RequestSettings, WebAuthnIDLResponse as _, }; use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; use libwebauthn::transport::ble::list_devices; @@ -53,14 +52,10 @@ pub async fn main() -> Result<(), Box> { "attestation": "none" } "#; - let related_origins = ReqwestRelatedOriginsSource::new()?; let settings = RequestSettings { origin: OriginValidation::Validate { public_suffix_list: &psl, - related_origins: RelatedOrigins::Enabled { - source: &related_origins, - max_labels: MaxRegistrableLabels::default(), - }, + related_origins: RelatedOrigins::Disabled, }, }; let make_credentials_request: MakeCredentialRequest = diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index 9bf2e431..f00a9630 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -11,9 +11,8 @@ use qrcode::render::unicode; use qrcode::QrCode; use libwebauthn::ops::webauthn::{ - DatFilePublicSuffixList, JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, - OriginValidation, RelatedOrigins, RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, - WebAuthnIDLResponse as _, + DatFilePublicSuffixList, JsonFormat, MakeCredentialRequest, OriginValidation, RelatedOrigins, + RequestOrigin, RequestSettings, WebAuthnIDLResponse as _, }; use libwebauthn::transport::{Channel as _, Device}; use libwebauthn::webauthn::WebAuthn; @@ -59,14 +58,10 @@ pub async fn main() -> Result<(), Box> { let psl = DatFilePublicSuffixList::from_system_file().expect( "PSL not available; install the publicsuffix-list package or pass an explicit path", ); - let related_origins = ReqwestRelatedOriginsSource::new()?; let settings = RequestSettings { origin: OriginValidation::Validate { public_suffix_list: &psl, - related_origins: RelatedOrigins::Enabled { - source: &related_origins, - max_labels: MaxRegistrableLabels::default(), - }, + related_origins: RelatedOrigins::Disabled, }, }; diff --git a/libwebauthn/examples/ceremony/webauthn_hid.rs b/libwebauthn/examples/ceremony/webauthn_hid.rs index 8b3b3fa9..4e69df06 100644 --- a/libwebauthn/examples/ceremony/webauthn_hid.rs +++ b/libwebauthn/examples/ceremony/webauthn_hid.rs @@ -2,9 +2,8 @@ use std::error::Error; use std::time::Duration; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, OriginValidation, - RelatedOrigins, RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, - SystemPublicSuffixList, WebAuthnIDLResponse as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, OriginValidation, RelatedOrigins, + RequestOrigin, RequestSettings, SystemPublicSuffixList, WebAuthnIDLResponse as _, }; use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; use libwebauthn::transport::hid::list_devices; @@ -33,14 +32,10 @@ pub async fn main() -> Result<(), Box> { let psl = SystemPublicSuffixList::auto().expect( "PSL not available; install the publicsuffix-list (or publicsuffix-list-dafsa) package, or pass an explicit path", ); - let related_origins = ReqwestRelatedOriginsSource::new()?; let settings = RequestSettings { origin: OriginValidation::Validate { public_suffix_list: &psl, - related_origins: RelatedOrigins::Enabled { - source: &related_origins, - max_labels: MaxRegistrableLabels::default(), - }, + related_origins: RelatedOrigins::Disabled, }, }; let request_json = r#" diff --git a/libwebauthn/examples/ceremony/webauthn_nfc.rs b/libwebauthn/examples/ceremony/webauthn_nfc.rs index ab3f4f1a..dc57ad00 100644 --- a/libwebauthn/examples/ceremony/webauthn_nfc.rs +++ b/libwebauthn/examples/ceremony/webauthn_nfc.rs @@ -1,9 +1,8 @@ use std::error::Error; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, OriginValidation, - RelatedOrigins, RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, - SystemPublicSuffixList, WebAuthnIDLResponse as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, OriginValidation, RelatedOrigins, + RequestOrigin, RequestSettings, SystemPublicSuffixList, WebAuthnIDLResponse as _, }; use libwebauthn::transport::nfc::{get_nfc_device, is_nfc_available}; use libwebauthn::transport::{Channel as _, Device}; @@ -31,14 +30,10 @@ pub async fn main() -> Result<(), Box> { let psl = SystemPublicSuffixList::auto().expect( "PSL not available; install the publicsuffix-list (or publicsuffix-list-dafsa) package, or pass an explicit path", ); - let related_origins = ReqwestRelatedOriginsSource::new()?; let settings = RequestSettings { origin: OriginValidation::Validate { public_suffix_list: &psl, - related_origins: RelatedOrigins::Enabled { - source: &related_origins, - max_labels: MaxRegistrableLabels::default(), - }, + related_origins: RelatedOrigins::Disabled, }, }; let make_credentials_request = MakeCredentialRequest::prepare( diff --git a/libwebauthn/examples/features/webauthn_related_origins_hid.rs b/libwebauthn/examples/features/webauthn_related_origins_hid.rs new file mode 100644 index 00000000..3ecb126f --- /dev/null +++ b/libwebauthn/examples/features/webauthn_related_origins_hid.rs @@ -0,0 +1,85 @@ +//! HID make-credential with related origins enabled. +//! +//! The bundled reqwest-backed [`ReqwestRelatedOriginsSource`] fetches +//! `https:///.well-known/webauthn` when the request's rp.id is not a +//! registrable suffix of the caller origin. This demo is same-origin +//! (`example.org`), so the source is wired but the fetch is not triggered. It +//! fires when rp.id and the origin sit on different registrable domains, with +//! the RP listing the caller origin in its well-known document. + +use std::error::Error; +use std::time::Duration; + +use libwebauthn::ops::webauthn::{ + JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, OriginValidation, RelatedOrigins, + RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, SystemPublicSuffixList, + WebAuthnIDLResponse as _, +}; +use libwebauthn::transport::hid::list_devices; +use libwebauthn::transport::{Channel as _, Device}; +use libwebauthn::webauthn::WebAuthn; + +#[path = "../common/mod.rs"] +mod common; + +const TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::main] +pub async fn main() -> Result<(), Box> { + common::setup_logging(); + + let devices = list_devices().await.unwrap(); + println!("Devices found: {:?}", devices); + + for mut device in devices { + println!("Selected HID authenticator: {}", &device); + let mut channel = device.channel().await?; + channel.wink(TIMEOUT).await?; + + let request_origin: RequestOrigin = + "https://example.org".try_into().expect("Invalid origin"); + let psl = SystemPublicSuffixList::auto().expect( + "PSL not available; install the publicsuffix-list (or publicsuffix-list-dafsa) package, or pass an explicit path", + ); + let related_origins = ReqwestRelatedOriginsSource::new()?; + let settings = RequestSettings { + origin: OriginValidation::Validate { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Enabled { + source: &related_origins, + max_labels: MaxRegistrableLabels::default(), + }, + }, + }; + let request_json = r#" + { + "rp": { "id": "example.org", "name": "Example Relying Party" }, + "user": { + "id": "MTIzNDU2NzgxMjM0NTY3ODEyMzQ1Njc4MTIzNDU2Nzg", + "name": "alice", + "displayName": "Alice" + }, + "challenge": "MTIzNDU2NzgxMjM0NTY3ODEyMzQ1Njc4MTIzNDU2Nzg", + "pubKeyCredParams": [{"type": "public-key", "alg": -7}], + "timeout": 60000, + "excludeCredentials": [], + "authenticatorSelection": { "residentKey": "discouraged", "userVerification": "preferred" }, + "attestation": "none" + } + "#; + let request = MakeCredentialRequest::prepare(&request_origin, request_json, &settings) + .await + .expect("Failed to parse request JSON"); + + let state_recv = channel.get_ux_update_receiver(); + tokio::spawn(common::handle_uv_updates(state_recv)); + + 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}"); + } + + Ok(()) +}