diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 6fcdb92e..9f62d545 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -10,6 +10,7 @@ use crate::{ ops::webauthn::{ client_data::ClientData, idl::{ + appid_authorised, get::{ HmacGetSecretInputJson, LargeBlobInputJson, PrfInputJson, PublicKeyCredentialRequestOptionsJSON, @@ -125,27 +126,22 @@ pub enum GetAssertionPrepareError { #[error("Mismatching relying party ID: {0} != {1}")] MismatchingRelyingPartyId(String, String), + /// The client must throw a "SecurityError" DOMException. #[error("Invalid AppID: {0}")] InvalidAppId(String), } -/// Basic sanity check for FIDO AppID strings (WebAuthn L3 §10.1.1). -/// -/// Per spec the AppID should be a same-site URL (typically `https:///...`). -/// Full same-site validation against the rpId is not yet implemented; for now -/// we require non-empty input, an absolute URL form, and the `https` scheme. -fn validate_appid(appid: &str) -> Result { - if appid.is_empty() { - return Err(GetAssertionPrepareError::InvalidAppId( - "appid must not be empty".to_string(), - )); - } - // Sanity check: must be an https URL. - if !appid.starts_with("https://") { - return Err(GetAssertionPrepareError::InvalidAppId(format!( - "appid must be an https URL, got: {appid}" - ))); - } +/// Validates a FIDO AppID string (WebAuthn L3 §10.1.1): a non-empty https URL +/// whose host is authorised for the caller origin (same-site check). Returns +/// the AppID unchanged on success. +async fn validate_appid( + request_origin: &RequestOrigin, + settings: &RequestSettings<'_>, + appid: &str, +) -> Result { + appid_authorised(request_origin, settings, appid) + .await + .map_err(|err| GetAssertionPrepareError::InvalidAppId(err.to_string()))?; Ok(appid.to_string()) } @@ -206,7 +202,7 @@ impl FromIdlModel for GetAssertionRequest }; let appid = match inner.extensions.as_ref().and_then(|e| e.appid.as_ref()) { - Some(s) => Some(validate_appid(s)?), + Some(s) => Some(validate_appid(request_origin, settings, s).await?), None => None, }; @@ -1166,7 +1162,8 @@ mod tests { #[tokio::test] async fn test_request_from_json_appid_extension() { - let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + // Same-site AppID (equal host) is authorised for the caller. + let request_origin: RequestOrigin = "https://www.example.org".parse().unwrap(); let req_json = json_field_add( REQUEST_BASE_JSON, "extensions", @@ -1210,6 +1207,79 @@ mod tests { )); } + #[tokio::test] + async fn test_request_from_json_appid_extension_rejects_cross_site() { + // WebAuthn L3 §10.1.1: a cross-site AppID is not authorised. + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "extensions", + r#"{"appid":"https://example.net/u2f/origins.json"}"#, + ); + + let res = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await; + assert!(matches!( + res, + Err(GetAssertionPrepareError::InvalidAppId(_)) + )); + } + + #[tokio::test] + async fn test_request_from_json_appid_extension_parent_domain() { + // Legacy U2F migration: a subdomain caller may use its registrable + // parent domain as the AppID (WebAuthn L3 §10.1.1). + let request_origin: RequestOrigin = "https://login.example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "extensions", + r#"{"appid":"https://example.org/u2f/origins.json"}"#, + ); + + let req: GetAssertionRequest = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); + let ext = req.extensions.expect("extensions should be present"); + assert_eq!( + ext.appid.as_deref(), + Some("https://example.org/u2f/origins.json") + ); + } + + #[tokio::test] + async fn test_request_from_json_appid_extension_rejects_subdomain() { + // The AppID host must be a suffix of the caller host, so a parent-domain + // caller cannot claim a more-specific subdomain AppID. + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "extensions", + r#"{"appid":"https://sub.example.org/u2f/origins.json"}"#, + ); + + let res = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await; + assert!(matches!( + res, + Err(GetAssertionPrepareError::InvalidAppId(_)) + )); + } + #[test] fn test_try_downgrade_with_appid_uses_appid_hash() { use sha2::{Digest, Sha256}; diff --git a/libwebauthn/src/ops/webauthn/idl/mod.rs b/libwebauthn/src/ops/webauthn/idl/mod.rs index b9625e63..3209c8a0 100644 --- a/libwebauthn/src/ops/webauthn/idl/mod.rs +++ b/libwebauthn/src/ops/webauthn/idl/mod.rs @@ -17,6 +17,7 @@ pub use response::{ use async_trait::async_trait; use serde::de::DeserializeOwned; use tracing::debug; +use url::Url; use origin::{is_registrable_domain_suffix_or_equal, RequestOrigin}; use rpid::RelyingPartyId; @@ -60,6 +61,52 @@ where ) -> Result; } +/// Errors authorising a FIDO `appid` / `appidExclude` URL against the caller +/// origin (WebAuthn L3 §10.1.1 / §10.1.2). +#[derive(thiserror::Error, Debug)] +pub(crate) enum AppIdAuthorisationError { + #[error("appid must not be empty")] + Empty, + #[error("appid must be an https URL: {0}")] + NotHttps(String), + #[error("appid is not a valid URL: {0}")] + InvalidUrl(String), + #[error("appid has no host: {0}")] + NoHost(String), + #[error("appid host is not a valid domain: {0}")] + InvalidHost(String), + #[error("appid is not authorised for the caller origin")] + NotAuthorised, +} + +/// Authorises a FIDO AppID URL for the caller, reusing the same-site rp.id +/// check: the AppID host must be a registrable-domain suffix of, or equal to, +/// the caller origin host (or pass related-origins). This is the web reduction +/// of the FIDO AppID and Facet "is a caller's FacetID authorized" algorithm. +pub(crate) async fn appid_authorised( + request_origin: &RequestOrigin, + settings: &RequestSettings<'_>, + appid: &str, +) -> Result<(), AppIdAuthorisationError> { + if appid.is_empty() { + return Err(AppIdAuthorisationError::Empty); + } + if !appid.starts_with("https://") { + return Err(AppIdAuthorisationError::NotHttps(appid.to_string())); + } + let url = + Url::parse(appid).map_err(|err| AppIdAuthorisationError::InvalidUrl(err.to_string()))?; + let host = url + .host_str() + .ok_or_else(|| AppIdAuthorisationError::NoHost(appid.to_string()))?; + let appid_rp = RelyingPartyId::try_from(host) + .map_err(|err| AppIdAuthorisationError::InvalidHost(err.to_string()))?; + if !rp_id_authorised(request_origin, &appid_rp, settings).await { + return Err(AppIdAuthorisationError::NotAuthorised); + } + Ok(()) +} + /// Whether `request_origin` may act for `rp_id`. `Trust` accepts any rp.id; /// `Validate` requires a registrable suffix of the caller's effective domain or /// a matching related origin. diff --git a/libwebauthn/src/ops/webauthn/idl/response.rs b/libwebauthn/src/ops/webauthn/idl/response.rs index c975aaa9..9ad9a062 100644 --- a/libwebauthn/src/ops/webauthn/idl/response.rs +++ b/libwebauthn/src/ops/webauthn/idl/response.rs @@ -206,6 +206,11 @@ pub struct AuthenticationExtensionsClientOutputsJSON { #[serde(skip_serializing_if = "Option::is_none")] pub appid: Option, + /// FIDO AppID Exclusion extension output (for registration, WebAuthn L3 + /// §10.1.2): `Some(true)` when the appidExclude AppID was acted upon. + #[serde(skip_serializing_if = "Option::is_none")] + pub appid_exclude: Option, + /// The credential properties extension output (for registration). #[serde(skip_serializing_if = "Option::is_none")] pub cred_props: Option, diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index b78e95ae..177a6dd7 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -12,6 +12,7 @@ use crate::{ ops::webauthn::{ client_data::ClientData, idl::{ + appid_authorised, create::PublicKeyCredentialCreationOptionsJSON, get::PrfValuesJson, response::{ @@ -196,6 +197,9 @@ impl MakeCredentialResponse { let mut results = AuthenticationExtensionsClientOutputsJSON::default(); let unsigned_ext = &self.unsigned_extensions_output; + // FIDO AppID Exclusion extension output + results.appid_exclude = unsigned_ext.appid_exclude; + // Credential properties extension if let Some(cred_props) = &unsigned_ext.cred_props { results.cred_props = Some(CredentialPropertiesOutputJSON { rk: cred_props.rk }); @@ -233,7 +237,10 @@ impl MakeCredentialResponse { #[derive(Debug, Default, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MakeCredentialsResponseUnsignedExtensions { - // pub app_id: Option, + /// FIDO AppID Exclusion output (WebAuthn L3 §10.1.2): `Some(true)` when the + /// appidExclude AppID was used in the exclusion check. + #[serde(skip_serializing_if = "Option::is_none")] + pub appid_exclude: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cred_props: Option, // #[serde(skip_serializing_if = "Option::is_none")] @@ -362,7 +369,21 @@ impl MakeCredentialsResponseUnsignedExtensions { } }; + // appidExclude output is true when an excludeList entry was tested under it. + let exclude_present = request + .exclude + .as_ref() + .map(|list| !list.is_empty()) + .unwrap_or(false); + let appid_exclude = request + .extensions + .as_ref() + .and_then(|e| e.appid_exclude.as_ref()) + .filter(|_| exclude_present) + .map(|_| true); + MakeCredentialsResponseUnsignedExtensions { + appid_exclude, cred_props, hmac_create_secret, large_blob, @@ -446,6 +467,15 @@ impl FromIdlModel for MakeCredentialRequ effective_rp_id.to_string(), )); } + if let Some(appid_exclude) = inner + .extensions + .as_ref() + .and_then(|e| e.appid_exclude.as_deref()) + { + appid_authorised(request_origin, settings, appid_exclude) + .await + .map_err(|err| MakeCredentialPrepareError::InvalidAppId(err.to_string()))?; + } let relying_party = Ctap2PublicKeyCredentialRpEntity { id: rp_id.0, name: inner.rp.name, @@ -513,6 +543,9 @@ pub enum MakeCredentialPrepareError { InvalidRelyingPartyId(String), #[error("Mismatching relying party ID: {0} != {1}")] MismatchingRelyingPartyId(String, String), + /// The client must throw a "SecurityError" DOMException. + #[error("Invalid AppID: {0}")] + InvalidAppId(String), } impl MakeCredentialRequest { @@ -1041,7 +1074,8 @@ mod tests { #[tokio::test] async fn test_request_from_json_appid_exclude_extension() { - let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + // Same-site AppID (equal host) is authorised for the caller. + let request_origin: RequestOrigin = "https://www.example.org".parse().unwrap(); let req_json = json_field_add( REQUEST_BASE_JSON, "extensions", @@ -1063,6 +1097,131 @@ mod tests { ); } + #[tokio::test] + async fn test_request_from_json_appid_exclude_rejects_cross_site() { + // WebAuthn L3 §10.1.2: appidExclude must be same-site with the caller. + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "extensions", + r#"{"appidExclude": "https://example.net/u2f/origins.json"}"#, + ); + + let res = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await; + assert!(matches!( + res, + Err(MakeCredentialPrepareError::InvalidAppId(_)) + )); + } + + #[tokio::test] + async fn test_request_from_json_appid_exclude_parent_domain() { + // Legacy U2F migration: a subdomain caller may use its registrable + // parent domain as the appidExclude AppID (WebAuthn L3 §10.1.2). + let request_origin: RequestOrigin = "https://login.example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "extensions", + r#"{"appidExclude": "https://example.org/u2f/origins.json"}"#, + ); + + let req: MakeCredentialRequest = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); + let ext = req.extensions.expect("extensions should be present"); + assert_eq!( + ext.appid_exclude.as_deref(), + Some("https://example.org/u2f/origins.json") + ); + } + + #[tokio::test] + async fn test_request_from_json_appid_exclude_rejects_subdomain() { + // The AppID host must be a suffix of the caller host, so a parent-domain + // caller cannot claim a more-specific subdomain AppID. + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "extensions", + r#"{"appidExclude": "https://sub.example.org/u2f/origins.json"}"#, + ); + + let res = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await; + assert!(matches!( + res, + Err(MakeCredentialPrepareError::InvalidAppId(_)) + )); + } + + #[test] + fn test_appid_exclude_output_emitted_when_acted_upon() { + // WebAuthn L3 §10.1.2: appidExclude output is true when acted upon. + use serde_bytes::ByteBuf; + + let mut request = create_test_request(); + request.exclude = Some(vec![Ctap2PublicKeyCredentialDescriptor { + id: ByteBuf::from(vec![1, 2, 3, 4]), + r#type: Ctap2PublicKeyCredentialType::PublicKey, + transports: None, + }]); + request.extensions = Some(MakeCredentialsRequestExtensions { + appid_exclude: Some("https://www.example.org/u2f/origins.json".to_string()), + ..MakeCredentialsRequestExtensions::default() + }); + + let unsigned = MakeCredentialsResponseUnsignedExtensions::from_signed_extensions( + &None, None, &request, None, None, + ); + assert_eq!(unsigned.appid_exclude, Some(true)); + + let mut response = create_test_response(); + response.unsigned_extensions_output = unsigned; + let model = response.to_idl_model(&request).unwrap(); + assert_eq!(model.client_extension_results.appid_exclude, Some(true)); + + // The RP sees `appidExclude: true` in the serialized client outputs. + use crate::ops::webauthn::idl::response::JsonFormat; + let json = response + .to_json_string(&request, JsonFormat::default()) + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!( + parsed["clientExtensionResults"]["appidExclude"], + serde_json::Value::Bool(true) + ); + } + + #[test] + fn test_appid_exclude_output_absent_without_exclude_list() { + let mut request = create_test_request(); + request.extensions = Some(MakeCredentialsRequestExtensions { + appid_exclude: Some("https://www.example.org/u2f/origins.json".to_string()), + ..MakeCredentialsRequestExtensions::default() + }); + + let unsigned = MakeCredentialsResponseUnsignedExtensions::from_signed_extensions( + &None, None, &request, None, None, + ); + assert_eq!(unsigned.appid_exclude, None); + } + #[tokio::test] async fn test_request_from_json_unknown_pub_key_cred_params() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); @@ -1880,6 +2039,7 @@ mod tests { // Add some extension outputs response.unsigned_extensions_output = MakeCredentialsResponseUnsignedExtensions { + appid_exclude: None, cred_props: Some(CredentialPropsExtension { rk: Some(true) }), hmac_create_secret: Some(true), large_blob: None,