diff --git a/Cargo.lock b/Cargo.lock index 6413ad3..3b6c14f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1801,6 +1801,7 @@ dependencies = [ "num_enum", "p256 0.13.2", "pcsc", + "psl", "qrcode", "rand 0.8.6", "rustls", @@ -2450,6 +2451,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl" +version = "2.1.208" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bde51f827dca976f8f9a8c91329a3193114dc076b8012a1ee3624f1588c3582" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "pxfm" version = "0.1.29" diff --git a/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml index 4536c05..ce0f182 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" +psl = "2.1" url = "2.5" maplit = "1.0.2" sha2 = "0.10.2" diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 3b3acf4..12d4da9 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, @@ -118,18 +119,21 @@ 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) { 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 +162,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, &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, &req_json); + assert!(matches!( + result, + Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( + _, + _ + )) + )); + } + #[test] 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/idl/origin.rs b/libwebauthn/src/ops/webauthn/idl/origin.rs index c832921..ecf2a38 100644 --- a/libwebauthn/src/ops/webauthn/idl/origin.rs +++ b/libwebauthn/src/ops/webauthn/idl/origin.rs @@ -234,6 +234,52 @@ 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. +/// +/// The check is Public Suffix List aware so e.g. `co.uk` cannot be used as a +/// registrable suffix on its own. +pub(crate) fn is_registrable_domain_suffix_or_equal(rp_id: &str, effective_domain: &str) -> 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 let Some(suffix) = psl::suffix(effective_domain.as_bytes()) { + if suffix.as_bytes() == rp_id.as_bytes() { + return false; + } + } + + // `rp_id` must not itself be a public suffix (cannot register a credential + // against a bare eTLD like `co.uk`). + if let Some(suffix) = psl::suffix(rp_id.as_bytes()) { + if suffix.as_bytes() == rp_id.as_bytes() { + return false; + } + } + + true +} + #[cfg(test)] mod tests { use super::*; @@ -355,4 +401,85 @@ mod tests { assert_eq!(r.origin.host.as_str(), "example.org"); assert_eq!(r.origin.port, Some(443)); } + + #[test] + fn registrable_suffix_equality() { + assert!(is_registrable_domain_suffix_or_equal( + "example.com", + "example.com", + )); + } + + #[test] + fn registrable_suffix_parent_domain() { + assert!(is_registrable_domain_suffix_or_equal( + "example.com", + "login.example.com", + )); + assert!(is_registrable_domain_suffix_or_equal( + "example.com", + "a.b.c.example.com", + )); + } + + #[test] + fn registrable_suffix_cousin_domains_rejected() { + assert!(!is_registrable_domain_suffix_or_equal( + "other.com", + "login.example.com", + )); + } + + #[test] + fn registrable_suffix_longer_than_effective_rejected() { + assert!(!is_registrable_domain_suffix_or_equal( + "login.example.com", + "example.com", + )); + } + + #[test] + fn registrable_suffix_label_alignment_required() { + // "ample.com" is a string suffix of "example.com" but not label-aligned. + assert!(!is_registrable_domain_suffix_or_equal( + "ample.com", + "example.com", + )); + } + + #[test] + fn registrable_suffix_etld_rejected() { + assert!(!is_registrable_domain_suffix_or_equal("com", "example.com",)); + } + + #[test] + fn registrable_suffix_multilabel_etld_rejected() { + // co.uk is a public suffix; rp_id cannot be `co.uk` itself. + assert!(!is_registrable_domain_suffix_or_equal( + "co.uk", + "example.co.uk", + )); + } + + #[test] + fn registrable_suffix_under_multilabel_etld_accepted() { + assert!(is_registrable_domain_suffix_or_equal( + "example.co.uk", + "login.example.co.uk", + )); + } + + #[test] + fn registrable_suffix_skip_intermediate_labels_accepted() { + // bar.example.com is a registrable suffix of foo.bar.example.com. + assert!(is_registrable_domain_suffix_or_equal( + "bar.example.com", + "foo.bar.example.com", + )); + } + + #[test] + fn registrable_suffix_empty_rejected() { + assert!(!is_registrable_domain_suffix_or_equal("", "example.com")); + } } diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 6bf597d..eba8d5f 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, @@ -372,8 +373,8 @@ impl FromIdlModel accepted. + 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, &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, + "rp", + r#"{"id": "co.uk", "name": "co.uk"}"#, + ); + + let result = MakeCredentialRequest::from_json(&request_origin, &req_json); + assert!(matches!( + result, + Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _)) + )); + } + // Tests for response JSON serialization fn create_test_response() -> MakeCredentialResponse {