Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions libwebauthn/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
41 changes: 36 additions & 5 deletions libwebauthn/src/ops/webauthn/get_assertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::{
HmacGetSecretInputJson, LargeBlobInputJson, PrfInputJson,
PublicKeyCredentialRequestOptionsJSON,
},
origin::is_registrable_domain_suffix_or_equal,
response::{
AuthenticationExtensionsClientOutputsJSON, AuthenticationResponseJSON,
AuthenticatorAssertionResponseJSON, HMACGetSecretOutputJSON, LargeBlobOutputJSON,
Expand Down Expand Up @@ -118,18 +119,21 @@ impl FromIdlModel<PublicKeyCredentialRequestOptionsJSON, GetAssertionRequestPars
inner: PublicKeyCredentialRequestOptionsJSON,
) -> Result<Self, GetAssertionRequestParsingError> {
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 {
Expand Down Expand Up @@ -158,7 +162,7 @@ impl FromIdlModel<PublicKeyCredentialRequestOptionsJSON, GetAssertionRequestPars
.unwrap_or(DEFAULT_TIMEOUT);

Ok(GetAssertionRequest {
relying_party_id: effective_rp_id.to_string(),
relying_party_id: resolved_rp_id,
challenge: inner.challenge.to_vec(),
origin: request_origin.origin.to_string(),
top_origin: request_origin.top_origin.as_ref().map(|o| o.to_string()),
Expand Down Expand Up @@ -671,6 +675,33 @@ mod tests {
));
}

#[test]
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, &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();
Expand Down
127 changes: 127 additions & 0 deletions libwebauthn/src/ops/webauthn/idl/origin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,52 @@ impl TryFrom<String> 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::*;
Expand Down Expand Up @@ -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"));
}
}
37 changes: 35 additions & 2 deletions libwebauthn/src/ops/webauthn/make_credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -372,8 +373,8 @@ impl FromIdlModel<PublicKeyCredentialCreationOptionsJSON, MakeCredentialRequestP
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) {
return Err(
MakeCredentialRequestParsingError::MismatchingRelyingPartyId(
rp_id.0,
Expand Down Expand Up @@ -873,6 +874,38 @@ mod tests {
));
}

#[test]
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,
"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 {
Expand Down
Loading