From c23423f2cf8a92bec31a116424f8663d35902fb5 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:30:09 +0100 Subject: [PATCH 01/21] 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 dd71749..e2fd21d 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -26,6 +26,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" @@ -85,6 +89,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 0dcafcdbbde5499f5bb9f1aba665f16fc741cc59 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:31:26 +0100 Subject: [PATCH 02/21] 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 767c6c0..d34ba19 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 0000000..3ccf18d --- /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 d84b0daf83d5e8263ffe6bf02c557cf231cf3ab5 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:32:37 +0100 Subject: [PATCH 03/21] 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 0000000..f0974c2 --- /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 f6d63b71bb0b257e05ae1ed2b50255b394d4f285 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:39:03 +0100 Subject: [PATCH 04/21] 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 71a4383..330c039 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" @@ -1014,6 +1020,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" @@ -1147,6 +1162,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" @@ -1290,8 +1311,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1301,9 +1324,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]] @@ -1357,6 +1382,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" @@ -1492,12 +1536,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" @@ -1650,6 +1777,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" @@ -1832,6 +1965,7 @@ dependencies = [ "publicsuffix", "qrcode", "rand 0.8.6", + "reqwest", "rustls", "serde", "serde-indexed 0.2.0", @@ -1956,6 +2090,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" @@ -1983,6 +2123,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" @@ -2528,6 +2674,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" @@ -2652,6 +2853,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" @@ -2751,6 +2996,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -2771,6 +3017,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" @@ -2974,6 +3226,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" @@ -3196,6 +3460,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" @@ -3372,6 +3645,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" @@ -3481,6 +3769,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" @@ -3646,6 +3979,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" @@ -3769,6 +4108,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" @@ -3806,6 +4154,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" @@ -3860,6 +4218,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" @@ -3872,6 +4243,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 f46fbc0..1468154 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 779406b..46aa04d 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 fc3c40c..a35f5fa 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 aa03a04..18e0d13 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 6c58fdb..5f4ebd0 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 7e45b51..04bddfc 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, @@ -166,12 +168,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(); @@ -654,7 +658,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::*; @@ -705,49 +709,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( @@ -757,26 +778,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( @@ -786,14 +818,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 { @@ -803,36 +840,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, @@ -840,9 +887,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(), @@ -850,8 +902,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, @@ -859,7 +911,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(_)) @@ -911,34 +969,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}"); @@ -946,34 +1010,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 49eb837..7a366f0 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 2655bc9..b6c695b 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::{ @@ -381,12 +383,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(); @@ -708,7 +712,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::*; @@ -772,76 +776,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() @@ -852,29 +873,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() @@ -885,8 +916,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, @@ -894,9 +925,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(), @@ -904,17 +940,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 { @@ -924,27 +965,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 @@ -953,8 +1004,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"); @@ -964,17 +1015,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, @@ -982,16 +1038,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, @@ -999,16 +1060,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, @@ -1016,15 +1082,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, @@ -1032,16 +1103,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, @@ -1049,15 +1125,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, @@ -1065,9 +1146,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 3ccf18d..a99047e 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 9e7edb285d2678bc00588d80824947911c0fa1f1 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:40:18 +0100 Subject: [PATCH 05/21] 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 04bddfc..b048168 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, @@ -175,7 +175,7 @@ impl FromIdlModel Result { let effective_rp_id = request_origin.origin.host.as_str(); @@ -183,12 +183,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 107db92ef07f729f0bcd4bbb06e72cc2fbf48863 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:42:36 +0100 Subject: [PATCH 06/21] 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 a99047e..0186600 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 cc6f44c92d229c6ce4e547db50d966e36473d6ba Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:49:07 +0100 Subject: [PATCH 07/21] 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 0186600..09e316a 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 2051926470d82c21a8d8513a965aa47ecf273761 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:49:59 +0100 Subject: [PATCH 08/21] 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 b048168..41ee67a 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -659,14 +659,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", @@ -822,6 +866,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 a4733d7..5b7309e 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -715,12 +715,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": { @@ -1162,6 +1207,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 bb568ae43251a9c584dbb77e6e580bec5950be4f Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:51:27 +0100 Subject: [PATCH 09/21] 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 0000000..2009d65 --- /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 f23909d6abec09945d4aa47e3ee2745dbec8ce34 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 18:59:51 +0100 Subject: [PATCH 10/21] 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 f0974c2..45f0378 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 33b00a4cc1925156732953853c5fba29856244bc Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:00:01 +0100 Subject: [PATCH 11/21] 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 09e316a..8ca231d 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 35b1b94b0e5e1e44cbe56e97c74c76e5cdbb9782 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:00:35 +0100 Subject: [PATCH 12/21] 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 d34ba19..7632298 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 8ca231d..c558cc0 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 151eae9e1b5faecc6e050e52f997a96c9e060853 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:01:31 +0100 Subject: [PATCH 13/21] 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 41ee67a..ef505a8 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, @@ -187,7 +187,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 e10f762ce8b5131e4779f8c550e94fc7e2299f8b Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:02:26 +0100 Subject: [PATCH 14/21] 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 96ce5ef..70f7aa7 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 05a968616043e1c9aa9f16cfe8cb4cbab2ba06c4 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:02:53 +0100 Subject: [PATCH 15/21] 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 70f7aa7..081f0c6 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 d978d57ab63c6d8305abe18d5776bad60fe95c0e Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:03:19 +0100 Subject: [PATCH 16/21] 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 081f0c6..9d24859 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 134c116db3b44e27f2db8a0ee049e87e6384f1b5 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:03:32 +0100 Subject: [PATCH 17/21] 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 9d24859..a13396b 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 8260a130a8608668578d74f3a2ed92a7dbb1b022 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 17 May 2026 19:12:11 +0100 Subject: [PATCH 18/21] 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 a13396b..8e8bdc4 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 12f716abb5440ee0fff2ca6e4c0a07d333030e98 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Mon, 18 May 2026 19:40:19 +0100 Subject: [PATCH 19/21] 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 5bc4309..a55caad 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 e2fd21d..89285be 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -126,23 +126,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 1468154..916531e 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 46aa04d..7f7ae5b 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 18e0d13..cbddd2a 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 5f4ebd0..cdfc059 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 7632298..5219701 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 41ef219d696cf7206f10c307c9209f28028e2704 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Mon, 18 May 2026 19:40:22 +0100 Subject: [PATCH 20/21] 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 2009d65..8d8c488 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 2a4d4e5dc1d9acbcce661f0a943ec72e6bef69e0 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Mon, 18 May 2026 19:47:34 +0100 Subject: [PATCH 21/21] 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 ef505a8..dc79f64 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -187,7 +187,7 @@ impl FromIdlModel>, + response: Option>, } impl MockHttpClient { @@ -687,7 +687,7 @@ mod tests { } } - fn err(e: RelatedOriginsError) -> Self { + fn err(e: WellKnownFetchError) -> Self { Self { response: Some(Err(e)), } @@ -703,7 +703,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"), @@ -912,7 +912,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 2cbb700..952d93e 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -401,7 +401,7 @@ impl FromIdlModel>, + response: Option>, } impl MockHttpClient { @@ -742,7 +742,7 @@ mod tests { } } - fn err(e: RelatedOriginsError) -> Self { + fn err(e: WellKnownFetchError) -> Self { Self { response: Some(Err(e)), } @@ -758,7 +758,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"), @@ -1262,7 +1262,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 5219701..16ddd77 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 45f0378..74443a5 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 8e8bdc4..f692dc4 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 8d8c488..024ac22 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(),