From 55333044029a5684eb9d140bbcb3914cb59e225e Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sat, 9 May 2026 02:03:32 +0100 Subject: [PATCH 1/4] Add PublicSuffixList trait and DatFilePublicSuffixList impl --- Cargo.lock | 62 +++++++++++- libwebauthn/Cargo.toml | 1 + libwebauthn/src/ops/webauthn/mod.rs | 2 + libwebauthn/src/ops/webauthn/psl.rs | 152 ++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 libwebauthn/src/ops/webauthn/psl.rs diff --git a/Cargo.lock b/Cargo.lock index 6413ad3..b2fc9f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1559,6 +1559,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "1.1.0" @@ -1791,7 +1802,7 @@ dependencies = [ "hidapi", "hkdf", "hmac 0.12.1", - "idna", + "idna 1.1.0", "maplit", "mockall", "nfc1", @@ -1801,6 +1812,7 @@ dependencies = [ "num_enum", "p256 0.13.2", "pcsc", + "publicsuffix", "qrcode", "rand 0.8.6", "rustls", @@ -1942,6 +1954,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + [[package]] name = "memchr" version = "2.8.0" @@ -2450,6 +2468,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "publicsuffix" +version = "1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4ce31ff0a27d93c8de1849cf58162283752f065a90d508f1105fa6c9a213f" +dependencies = [ + "idna 0.2.3", + "url", +] + [[package]] name = "pxfm" version = "0.1.29" @@ -3287,6 +3315,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" @@ -3586,12 +3629,27 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3621,7 +3679,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", - "idna", + "idna 1.1.0", "percent-encoding", "serde", ] diff --git a/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml index 4536c05..99ec891 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -32,6 +32,7 @@ base64-url = "3.0.0" dbus = "0.9.5" tracing = "0.1.29" idna = "1.0.3" +publicsuffix = { version = "1.5", default-features = false } url = "2.5" maplit = "1.0.2" sha2 = "0.10.2" diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 2104bc1..2a57e67 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -2,6 +2,7 @@ mod client_data; mod get_assertion; pub mod idl; mod make_credential; +pub mod psl; mod timeout; use super::u2f::{RegisterRequest, SignRequest}; @@ -29,6 +30,7 @@ pub use make_credential::{ MakeCredentialsRequestExtensions, MakeCredentialsResponseExtensions, MakeCredentialsResponseUnsignedExtensions, ResidentKeyRequirement, }; +pub use psl::{DatFileLoadError, DatFilePublicSuffixList, PublicSuffixList, SYSTEM_PSL_PATH}; use serde::Deserialize; #[derive(Debug, Clone, Copy, Deserialize, PartialEq)] diff --git a/libwebauthn/src/ops/webauthn/psl.rs b/libwebauthn/src/ops/webauthn/psl.rs new file mode 100644 index 0000000..6cdb822 --- /dev/null +++ b/libwebauthn/src/ops/webauthn/psl.rs @@ -0,0 +1,152 @@ +//! Public Suffix List lookup. +//! +//! libwebauthn needs to know whether a host's apparent registrable domain is +//! actually registrable (i.e. has more labels than its public suffix) when it +//! validates that a request's `rp.id` is a registrable suffix of the calling +//! origin's effective domain (HTML §6.5, used by WebAuthn L3 §5.1.3 step 7). +//! +//! Rather than bundle a snapshot of the PSL inside the crate (which would go +//! stale with each release), libwebauthn defines a [`PublicSuffixList`] trait +//! and lets callers plug in an implementation. A simple +//! [`DatFilePublicSuffixList`] is provided that reads the standard `.dat` +//! file shipped by the `publicsuffix-list` distribution package, kept fresh +//! by the system package manager. + +use std::path::{Path, PathBuf}; + +/// Public Suffix List lookup interface. +/// +/// Implementations decide where the PSL data lives (system file, embedded +/// snapshot, HTTP-cached, etc). +pub trait PublicSuffixList: Send + Sync { + /// Returns the registrable domain (eTLD+1) of `host`, or `None` if + /// `host` has no registrable domain (e.g. it is itself a public suffix). + fn registrable_domain(&self, host: &str) -> Option; + + /// Returns the public suffix of `host`, or `None` if none applies. + fn public_suffix(&self, host: &str) -> Option; +} + +#[derive(thiserror::Error, Debug)] +pub enum DatFileLoadError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("invalid PSL data: {0}")] + Parse(String), +} + +/// Standard system path for the Public Suffix List on most Linux distros that +/// ship the `publicsuffix-list` (or equivalent) package. +pub const SYSTEM_PSL_PATH: &str = "/usr/share/publicsuffix/public_suffix_list.dat"; + +/// `PublicSuffixList` implementation backed by a Public Suffix List `.dat` +/// file loaded from disk at construction time. +pub struct DatFilePublicSuffixList { + list: publicsuffix::List, + source: PathBuf, +} + +impl std::fmt::Debug for DatFilePublicSuffixList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DatFilePublicSuffixList") + .field("source", &self.source) + .finish() + } +} + +impl DatFilePublicSuffixList { + /// Reads a PSL `.dat` file from `path`. + pub fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let data = std::fs::read_to_string(path)?; + let list = publicsuffix::List::from_str(&data) + .map_err(|e| DatFileLoadError::Parse(e.to_string()))?; + Ok(Self { + list, + source: path.to_path_buf(), + }) + } + + /// Reads the system-managed PSL at [`SYSTEM_PSL_PATH`]. + pub fn from_system_file() -> Result { + Self::from_path(SYSTEM_PSL_PATH) + } +} + +impl PublicSuffixList for DatFilePublicSuffixList { + fn registrable_domain(&self, host: &str) -> Option { + let domain = self.list.parse_domain(host).ok()?; + domain.root().map(|s| s.to_string()) + } + + fn public_suffix(&self, host: &str) -> Option { + let domain = self.list.parse_domain(host).ok()?; + domain.suffix().map(|s| s.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test-only PSL that recognises a small fixed set of public suffixes. + /// Sufficient for unit tests of the suffix-check algorithm without + /// reading the system file. + pub(crate) struct MockPublicSuffixList; + + const KNOWN_SUFFIXES: &[&str] = &["com", "co.uk", "org", "net"]; + + impl PublicSuffixList for MockPublicSuffixList { + 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 + } + + fn registrable_domain(&self, host: &str) -> Option { + let suffix = self.public_suffix(host)?; + if host == suffix { + return None; + } + let prefix = host.strip_suffix(&suffix)?.strip_suffix('.')?; + let last_label = prefix.rsplit('.').next()?; + Some(format!("{last_label}.{suffix}")) + } + } + + #[test] + fn mock_public_suffix_lookup() { + let psl = MockPublicSuffixList; + assert_eq!(psl.public_suffix("example.com").as_deref(), Some("com")); + assert_eq!(psl.public_suffix("com").as_deref(), Some("com")); + assert_eq!(psl.public_suffix("bbc.co.uk").as_deref(), Some("co.uk")); + assert_eq!(psl.public_suffix("co.uk").as_deref(), Some("co.uk")); + assert_eq!(psl.public_suffix("localhost"), None); + } + + #[test] + fn mock_registrable_domain() { + let psl = MockPublicSuffixList; + assert_eq!( + psl.registrable_domain("login.example.com").as_deref(), + Some("example.com") + ); + assert_eq!( + psl.registrable_domain("example.com").as_deref(), + Some("example.com") + ); + assert_eq!(psl.registrable_domain("com"), None); + assert_eq!( + psl.registrable_domain("bbc.co.uk").as_deref(), + Some("bbc.co.uk") + ); + assert_eq!(psl.registrable_domain("co.uk"), None); + } +} From 969c38bd734528cc5405d4cf980e703d59ac2390 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sat, 9 May 2026 02:08:46 +0100 Subject: [PATCH 2/4] Use PublicSuffixList for registrable-suffix check in from_idl_model --- libwebauthn/examples/webauthn_json_hid.rs | 11 +- libwebauthn/src/ops/webauthn/get_assertion.rs | 75 +++++++-- libwebauthn/src/ops/webauthn/idl/mod.rs | 16 +- libwebauthn/src/ops/webauthn/idl/origin.rs | 149 ++++++++++++++++++ .../src/ops/webauthn/make_credential.rs | 76 +++++++-- libwebauthn/src/ops/webauthn/psl.rs | 58 +++---- 6 files changed, 323 insertions(+), 62 deletions(-) diff --git a/libwebauthn/examples/webauthn_json_hid.rs b/libwebauthn/examples/webauthn_json_hid.rs index f7a652d..c1d290d 100644 --- a/libwebauthn/examples/webauthn_json_hid.rs +++ b/libwebauthn/examples/webauthn_json_hid.rs @@ -8,8 +8,8 @@ use tokio::sync::broadcast::Receiver; use tracing_subscriber::{self, EnvFilter}; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, WebAuthnIDL as _, - WebAuthnIDLResponse as _, + DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, + WebAuthnIDL as _, WebAuthnIDLResponse as _, }; use libwebauthn::pin::PinRequestReason; use libwebauthn::transport::hid::list_devices; @@ -81,6 +81,9 @@ pub async fn main() -> Result<(), Box> { let request_origin: RequestOrigin = "https://example.org".try_into().expect("Invalid origin"); + let psl = DatFilePublicSuffixList::from_system_file().expect( + "PSL not available; install the publicsuffix-list package or pass an explicit path", + ); let request_json = r#" { "rp": { @@ -106,7 +109,7 @@ pub async fn main() -> Result<(), Box> { } "#; let make_credentials_request: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, request_json) + MakeCredentialRequest::from_json(&request_origin, &psl, request_json) .expect("Failed to parse request JSON"); println!( "WebAuthn MakeCredential request: {:?}", @@ -158,7 +161,7 @@ pub async fn main() -> Result<(), Box> { } "#; let get_assertion: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, request_json) + GetAssertionRequest::from_json(&request_origin, &psl, request_json) .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 3b3acf4..3c58bac 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -13,6 +13,7 @@ use crate::{ HmacGetSecretInputJson, LargeBlobInputJson, PrfInputJson, PublicKeyCredentialRequestOptionsJSON, }, + origin::is_registrable_domain_suffix_or_equal, response::{ AuthenticationExtensionsClientOutputsJSON, AuthenticationResponseJSON, AuthenticatorAssertionResponseJSON, HMACGetSecretOutputJSON, LargeBlobOutputJSON, @@ -20,6 +21,7 @@ use crate::{ }, Base64UrlString, FromIdlModel, JsonError, }, + psl::PublicSuffixList, Operation, WebAuthnIDL, }, pin::PinUvAuthProtocol, @@ -115,21 +117,25 @@ impl FromIdlModel Result { let effective_rp_id = request_origin.origin.host.as_str(); - if let Some(relying_party_id) = inner.relying_party_id.as_deref() { + let resolved_rp_id = if let Some(relying_party_id) = inner.relying_party_id.as_deref() { let parsed = RelyingPartyId::try_from(relying_party_id).map_err(|err| { GetAssertionRequestParsingError::InvalidRelyingPartyId(err.to_string()) })?; - // TODO(#160): Add support for related origin per WebAuthn Level 3. - if parsed.0 != effective_rp_id { + // TODO(#160): Add related-origins fallback per WebAuthn L3 §5.11. + if !is_registrable_domain_suffix_or_equal(&parsed.0, effective_rp_id, psl) { return Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( parsed.0, effective_rp_id.to_string(), )); } - } + parsed.0 + } else { + effective_rp_id.to_string() + }; let prf = match inner.extensions.as_ref() { Some(ext) => match &ext.prf { @@ -158,7 +164,7 @@ impl FromIdlModel 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(); + 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() { + // 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); assert!(matches!( result, Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( @@ -677,7 +720,8 @@ mod tests { let req_json = json_field_rm(REQUEST_BASE_JSON, "allowCredentials"); let req: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &req_json).unwrap(); + GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .unwrap(); assert_eq!( req, GetAssertionRequest { @@ -693,7 +737,8 @@ mod tests { let req_json = json_field_rm(REQUEST_BASE_JSON, "timeout"); let req: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &req_json).unwrap(); + GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .unwrap(); assert_eq!(req.timeout, DEFAULT_TIMEOUT); } @@ -706,7 +751,8 @@ mod tests { let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", r#"{}"#); let req: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &req_json).unwrap(); + GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .unwrap(); assert_eq!( req.extensions, Some(GetAssertionRequestExtensions::default()) @@ -724,7 +770,8 @@ mod tests { ); let req: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &req_json).unwrap(); + GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .unwrap(); if let Some(GetAssertionRequestExtensions { prf: Some(PrfInput { diff --git a/libwebauthn/src/ops/webauthn/idl/mod.rs b/libwebauthn/src/ops/webauthn/idl/mod.rs index 38d8a95..49eb837 100644 --- a/libwebauthn/src/ops/webauthn/idl/mod.rs +++ b/libwebauthn/src/ops/webauthn/idl/mod.rs @@ -16,6 +16,8 @@ pub use response::{ use origin::RequestOrigin; +use super::psl::PublicSuffixList; + use serde::de::DeserializeOwned; use serde_json; @@ -33,9 +35,13 @@ where /// The JSON model that this IDL can deserialize from. type IdlModel: DeserializeOwned; - fn from_json(request_origin: &RequestOrigin, json: &str) -> Result { + fn from_json( + request_origin: &RequestOrigin, + psl: &dyn PublicSuffixList, + json: &str, + ) -> Result { let idl_model: Self::IdlModel = serde_json::from_str(json)?; - Self::from_idl_model(request_origin, idl_model).map_err(From::from) + Self::from_idl_model(request_origin, psl, idl_model).map_err(From::from) } } @@ -44,5 +50,9 @@ where T: DeserializeOwned, E: std::error::Error, { - fn from_idl_model(request_origin: &RequestOrigin, model: T) -> Result; + fn from_idl_model( + request_origin: &RequestOrigin, + psl: &dyn PublicSuffixList, + model: T, + ) -> Result; } diff --git a/libwebauthn/src/ops/webauthn/idl/origin.rs b/libwebauthn/src/ops/webauthn/idl/origin.rs index 94d7aba..217001f 100644 --- a/libwebauthn/src/ops/webauthn/idl/origin.rs +++ b/libwebauthn/src/ops/webauthn/idl/origin.rs @@ -4,6 +4,8 @@ use std::str::FromStr; use url::{Host, ParseError, Url}; +use super::super::psl::PublicSuffixList; + #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] pub enum HostParseError { #[error("empty host")] @@ -287,6 +289,54 @@ impl TryFrom for RequestOrigin { } } +/// Returns true iff `rp_id` is a registrable domain suffix of, or equal to, +/// `effective_domain`, per HTML §6.5 ("is a registrable domain suffix of or +/// is equal to") which WebAuthn L3 §5.1.3 step 7 / §5.1.7 step 9 reference. +/// +/// Public-suffix knowledge is supplied by the caller via the +/// [`PublicSuffixList`] trait. Validation rejects bare public suffixes (e.g. +/// `co.uk`) on either side of the comparison so they cannot be claimed as an +/// rp.id. +pub(crate) fn is_registrable_domain_suffix_or_equal( + rp_id: &str, + effective_domain: &str, + psl: &dyn PublicSuffixList, +) -> bool { + if rp_id.is_empty() { + return false; + } + if rp_id == effective_domain { + return true; + } + + // `rp_id`, prefixed by U+002E (.), must match the end of `effective_domain`. + // This enforces label alignment and excludes the equality case (handled above). + if effective_domain.len() <= rp_id.len() { + return false; + } + let boundary = effective_domain.len() - rp_id.len() - 1; + if effective_domain.as_bytes()[boundary] != b'.' { + return false; + } + if &effective_domain[boundary + 1..] != rp_id { + return false; + } + + // `rp_id` must not be `effective_domain`'s public suffix (otherwise an + // attacker on a sibling registrable could claim the eTLD). + if psl.public_suffix(effective_domain).as_deref() == Some(rp_id) { + return false; + } + + // `rp_id` must not itself be a public suffix (cannot register a credential + // against a bare eTLD like `co.uk`). + if psl.public_suffix(rp_id).as_deref() == Some(rp_id) { + return false; + } + + true +} + #[cfg(test)] mod tests { use super::*; @@ -507,4 +557,103 @@ mod tests { assert_eq!(origin.scheme, Scheme::Https); assert_eq!(origin.to_string(), "https://example.org:8443"); } + + fn psl() -> super::super::super::psl::MockPublicSuffixList { + super::super::super::psl::MockPublicSuffixList + } + + #[test] + fn registrable_suffix_equality() { + assert!(is_registrable_domain_suffix_or_equal( + "example.com", + "example.com", + &psl(), + )); + } + + #[test] + fn registrable_suffix_parent_domain() { + assert!(is_registrable_domain_suffix_or_equal( + "example.com", + "login.example.com", + &psl(), + )); + assert!(is_registrable_domain_suffix_or_equal( + "example.com", + "a.b.c.example.com", + &psl(), + )); + } + + #[test] + fn registrable_suffix_cousin_domains_rejected() { + assert!(!is_registrable_domain_suffix_or_equal( + "other.com", + "login.example.com", + &psl(), + )); + } + + #[test] + fn registrable_suffix_longer_than_effective_rejected() { + assert!(!is_registrable_domain_suffix_or_equal( + "login.example.com", + "example.com", + &psl(), + )); + } + + #[test] + fn registrable_suffix_label_alignment_required() { + assert!(!is_registrable_domain_suffix_or_equal( + "ample.com", + "example.com", + &psl(), + )); + } + + #[test] + fn registrable_suffix_etld_rejected() { + assert!(!is_registrable_domain_suffix_or_equal( + "com", + "example.com", + &psl(), + )); + } + + #[test] + fn registrable_suffix_multilabel_etld_rejected() { + assert!(!is_registrable_domain_suffix_or_equal( + "co.uk", + "example.co.uk", + &psl(), + )); + } + + #[test] + fn registrable_suffix_under_multilabel_etld_accepted() { + assert!(is_registrable_domain_suffix_or_equal( + "example.co.uk", + "login.example.co.uk", + &psl(), + )); + } + + #[test] + fn registrable_suffix_skip_intermediate_labels_accepted() { + assert!(is_registrable_domain_suffix_or_equal( + "bar.example.com", + "foo.bar.example.com", + &psl(), + )); + } + + #[test] + fn registrable_suffix_empty_rejected() { + assert!(!is_registrable_domain_suffix_or_equal( + "", + "example.com", + &psl(), + )); + } } diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 6bf597d..db7c97c 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -12,6 +12,7 @@ use crate::{ client_data::ClientData, idl::{ create::PublicKeyCredentialCreationOptionsJSON, + origin::is_registrable_domain_suffix_or_equal, response::{ AuthenticationExtensionsClientOutputsJSON, AuthenticatorAttestationResponseJSON, CredentialPropertiesOutputJSON, LargeBlobOutputJSON, PRFOutputJSON, @@ -19,6 +20,7 @@ use crate::{ }, Base64UrlString, FromIdlModel, JsonError, WebAuthnIDL, }, + psl::PublicSuffixList, Operation, RelyingPartyId, RequestOrigin, }, proto::{ @@ -366,14 +368,15 @@ 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 support for related origin per WebAuthn Level 3. - if rp_id.0 != effective_rp_id { + // 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, @@ -642,6 +645,7 @@ impl DowngradableRequest for MakeCredentialRequest { mod tests { use std::time::Duration; + use crate::ops::webauthn::psl::MockPublicSuffixList; use crate::ops::webauthn::{MakeCredentialRequest, RequestOrigin}; use crate::proto::ctap2::Ctap2PublicKeyCredentialType; @@ -710,7 +714,8 @@ mod tests { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); let req_json = json_field_rm(REQUEST_BASE_JSON, field); - let result = MakeCredentialRequest::from_json(&request_origin, &req_json); + let result = + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); assert!(matches!( result, Err(MakeCredentialRequestParsingError::EncodingError(_)) @@ -720,8 +725,12 @@ mod tests { #[test] fn test_request_from_json_base() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); - let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, REQUEST_BASE_JSON).unwrap(); + let req: MakeCredentialRequest = MakeCredentialRequest::from_json( + &request_origin, + &MockPublicSuffixList, + REQUEST_BASE_JSON, + ) + .unwrap(); assert_eq!(req, request_base()); } @@ -752,7 +761,8 @@ mod tests { let req_json: String = json_field_rm(REQUEST_BASE_JSON, "challenge"); let req_json = json_field_add(&req_json, "challenge", r#""""#); - let result = MakeCredentialRequest::from_json(&request_origin, &req_json); + let result = + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); assert!(matches!( result, Err(MakeCredentialRequestParsingError::EncodingError(_)) @@ -769,7 +779,8 @@ mod tests { ); let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &req_json).unwrap(); + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .unwrap(); assert!(matches!( req.extensions, Some(MakeCredentialsRequestExtensions { prf: Some(_), .. }) @@ -785,7 +796,8 @@ mod tests { r#"[{"type": "something", "alg": -12345}]"#, ); let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &req_json).unwrap(); + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .unwrap(); assert_eq!( req.algorithms, vec![Ctap2CredentialType { @@ -801,7 +813,8 @@ mod tests { let req_json = json_field_rm(REQUEST_BASE_JSON, "timeout"); let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &req_json).unwrap(); + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .unwrap(); assert_eq!(req.timeout, DEFAULT_TIMEOUT); } @@ -813,7 +826,8 @@ mod tests { let req_json = json_field_rm(REQUEST_BASE_JSON, "authenticatorSelection"); let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &req_json).unwrap(); + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .unwrap(); assert_eq!( req.user_verification, UserVerificationRequirement::Preferred @@ -834,7 +848,8 @@ mod tests { ); let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &req_json).unwrap(); + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .unwrap(); assert_eq!( req.user_verification, UserVerificationRequirement::Preferred @@ -850,7 +865,8 @@ mod tests { r#"{"id": "example.org.", "name": "example.org"}"#, ); - let result = MakeCredentialRequest::from_json(&request_origin, &req_json); + let result = + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); assert!(matches!( result, Err(MakeCredentialRequestParsingError::InvalidRelyingPartyId(_)) @@ -866,7 +882,41 @@ mod tests { r#"{"id": "other.example.org", "name": "example.org"}"#, ); - let result = MakeCredentialRequest::from_json(&request_origin, &req_json); + let result = + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + assert!(matches!( + result, + Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _)) + )); + } + + #[test] + 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, + "rp", + r#"{"id": "example.org", "name": "example.org"}"#, + ); + + let req = + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .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() { + let request_origin: RequestOrigin = "https://example.co.uk".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "rp", + r#"{"id": "co.uk", "name": "co.uk"}"#, + ); + + let result = + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); assert!(matches!( result, Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _)) diff --git a/libwebauthn/src/ops/webauthn/psl.rs b/libwebauthn/src/ops/webauthn/psl.rs index 6cdb822..47967ad 100644 --- a/libwebauthn/src/ops/webauthn/psl.rs +++ b/libwebauthn/src/ops/webauthn/psl.rs @@ -85,41 +85,43 @@ impl PublicSuffixList for DatFilePublicSuffixList { } } +/// Test-only PSL that recognises a small fixed set of public suffixes. +/// +/// Sufficient for unit tests of the suffix-check algorithm without reading +/// the system file. Recognises `com`, `co.uk`, `org`, and `net`. #[cfg(test)] -mod tests { - use super::*; +pub(crate) struct MockPublicSuffixList; - /// Test-only PSL that recognises a small fixed set of public suffixes. - /// Sufficient for unit tests of the suffix-check algorithm without - /// reading the system file. - pub(crate) struct MockPublicSuffixList; - - const KNOWN_SUFFIXES: &[&str] = &["com", "co.uk", "org", "net"]; - - impl PublicSuffixList for MockPublicSuffixList { - 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()); - } +#[cfg(test)] +impl PublicSuffixList for MockPublicSuffixList { + fn public_suffix(&self, host: &str) -> Option { + const KNOWN_SUFFIXES: &[&str] = &["com", "co.uk", "org", "net"]; + 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 } + None + } - fn registrable_domain(&self, host: &str) -> Option { - let suffix = self.public_suffix(host)?; - if host == suffix { - return None; - } - let prefix = host.strip_suffix(&suffix)?.strip_suffix('.')?; - let last_label = prefix.rsplit('.').next()?; - Some(format!("{last_label}.{suffix}")) + fn registrable_domain(&self, host: &str) -> Option { + let suffix = self.public_suffix(host)?; + if host == suffix { + return None; } + let prefix = host.strip_suffix(&suffix)?.strip_suffix('.')?; + let last_label = prefix.rsplit('.').next()?; + Some(format!("{last_label}.{suffix}")) } +} + +#[cfg(test)] +mod tests { + use super::*; #[test] fn mock_public_suffix_lookup() { From c57ce4f7efbd5ac681568b8ff012d669f57730e3 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 18:29:36 +0100 Subject: [PATCH 3/4] Document Public Suffix List runtime requirement in README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index b7bc839..8c5e433 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ _Looking for the D-Bus API proposal?_ Check out [credentialsd][credentialsd]. - 🟢 Hybrid transport (caBLE v2): QR-initiated transactions - 🟢 Hybrid transport (caBLE v2): State-assisted transactions (remember this phone) +## Runtime requirements + +Validating the relying party ID against the calling origin requires the [Public Suffix List][psl]. The built-in loader reads it from the standard system path. The `publicsuffix` package on Debian/Ubuntu or `publicsuffix-list` on Fedora and Arch installs it there, but these are not always present on minimal installs. Install explicitly if needed. Callers wiring their own list don't need a system package. + ## Transports | | FIDO U2F | WebAuthn (FIDO2) | @@ -79,3 +83,4 @@ If you don't know where to start, check out the _Issues_ tab. [#17]: https://github.com/linux-credentials/libwebauthn/issues/17 [#18]: https://github.com/linux-credentials/libwebauthn/issues/18 [#31]: https://github.com/linux-credentials/libwebauthn/issues/31 +[psl]: https://publicsuffix.org/ From da9c709cde2ebf40c10a984bd7a9d45ac5a5697c Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 18:38:43 +0100 Subject: [PATCH 4/4] Add localhost registrable-suffix tests --- libwebauthn/src/ops/webauthn/idl/origin.rs | 18 +++++++++++ .../src/ops/webauthn/make_credential.rs | 32 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/libwebauthn/src/ops/webauthn/idl/origin.rs b/libwebauthn/src/ops/webauthn/idl/origin.rs index 217001f..7be9cce 100644 --- a/libwebauthn/src/ops/webauthn/idl/origin.rs +++ b/libwebauthn/src/ops/webauthn/idl/origin.rs @@ -656,4 +656,22 @@ mod tests { &psl(), )); } + + #[test] + fn registrable_suffix_localhost_equal() { + assert!(is_registrable_domain_suffix_or_equal( + "localhost", + "localhost", + &psl(), + )); + } + + #[test] + fn registrable_suffix_localhost_subdomain_accepted() { + assert!(is_registrable_domain_suffix_or_equal( + "localhost", + "sub.localhost", + &psl(), + )); + } } diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index db7c97c..03d2b6d 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -923,6 +923,38 @@ mod tests { )); } + #[test] + 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, + "rp", + r#"{"id": "localhost", "name": "localhost"}"#, + ); + + let req = + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .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() { + let request_origin: RequestOrigin = "http://localhost:3000".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "rp", + r#"{"id": "localhost", "name": "localhost"}"#, + ); + + let req = + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .unwrap(); + assert_eq!(req.relying_party.id, "localhost"); + assert_eq!(req.origin, "http://localhost:3000"); + } + // Tests for response JSON serialization fn create_test_response() -> MakeCredentialResponse {