diff --git a/Cargo.lock b/Cargo.lock index e3fcfbfd..5a4a754d 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" @@ -1026,6 +1032,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" @@ -1159,6 +1174,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" @@ -1302,8 +1323,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1313,9 +1336,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]] @@ -1369,6 +1394,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" @@ -1504,12 +1548,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" @@ -1662,6 +1789,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 = [ "hidapi", "hkdf", "hmac 0.12.1", + "http", "idna", "maplit", "mockall", @@ -1845,6 +1979,7 @@ dependencies = [ "publicsuffix", "qrcode", "rand 0.8.6", + "reqwest", "rustls", "serde", "serde-indexed 0.2.0", @@ -1970,6 +2105,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" @@ -1997,6 +2138,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" @@ -2542,6 +2689,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" @@ -2666,6 +2868,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" @@ -2765,6 +3011,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -2785,6 +3032,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" @@ -2988,6 +3241,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" @@ -3210,6 +3475,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" @@ -3386,6 +3660,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" @@ -3495,6 +3784,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" @@ -3660,6 +3994,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" @@ -3783,6 +4123,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" @@ -3820,6 +4169,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" @@ -3874,6 +4233,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" @@ -3886,6 +4258,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/README.md b/README.md index 5bc43093..574e72b3 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 --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` | [^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 ceremony examples run with related origins disabled (they are same-origin, so it never applies). The bundled reqwest-backed [related-origins](https://www.w3.org/TR/webauthn-3/#sctn-related-origins) source is shown in the `webauthn_related_origins_hid` example below, behind the optional `reqwest-related-origins-source` feature. Consumers that ship their own HTTP stack can implement `HttpClient` or `RelatedOriginsSource` directly. + Additional HID-only examples cover specific FIDO2 features and authenticator management: ``` @@ -88,6 +90,9 @@ $ cargo run --example webauthn_prf_hid $ cargo run --example prf_replay -- CREDENTIAL_ID FIRST_PRF_INPUT $ cargo run --example device_selection_hid +# Related origins (reqwest-backed well-known fetch) +$ cargo run --features reqwest-related-origins-source --example webauthn_related_origins_hid + # CTAP2 authenticator management $ cargo run --example change_pin_hid $ cargo run --example bio_enrollment_hid diff --git a/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml index 0921dff3..78b6490b 100644 --- a/libwebauthn/Cargo.toml +++ b/libwebauthn/Cargo.toml @@ -39,6 +39,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 HttpClient and ReqwestRelatedOriginsSource. Off by +# default so the core crate stays HTTP-client-free. Consumers that want the +# default fetch opt in, others implement HttpClient or RelatedOriginsSource. +reqwest-related-origins-source = ["dep:reqwest"] [dependencies] base64-url = "3.0.0" @@ -47,6 +51,7 @@ tracing = "0.1.29" idna = "1.0.3" publicsuffix = "2.3" url = "2.5" +http = "1" maplit = "1.0.2" sha2 = "0.10.2" uuid = { version = "1.5.0", features = ["serde", "v4"] } @@ -100,6 +105,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"] } @@ -161,6 +172,11 @@ path = "examples/features/webauthn_preflight_hid.rs" name = "webauthn_prf_hid" path = "examples/features/webauthn_prf_hid.rs" +[[example]] +name = "webauthn_related_origins_hid" +path = "examples/features/webauthn_related_origins_hid.rs" +required-features = ["reqwest-related-origins-source"] + [[example]] name = "prf_replay" path = "examples/features/prf_replay.rs" diff --git a/libwebauthn/examples/ceremony/webauthn_ble.rs b/libwebauthn/examples/ceremony/webauthn_ble.rs index f46fbc01..0f9df727 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, + OriginValidation, RelatedOrigins, RequestOrigin, RequestSettings, WebAuthnIDLResponse as _, }; use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; use libwebauthn::transport::ble::list_devices; @@ -52,8 +52,15 @@ pub async fn main() -> Result<(), Box> { "attestation": "none" } "#; + let settings = RequestSettings { + origin: OriginValidation::Validate { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Disabled, + }, + }; let make_credentials_request: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &psl, request_json) + MakeCredentialRequest::prepare(&request_origin, request_json, &settings) + .await .expect("Failed to parse request JSON"); println!( "WebAuthn MakeCredential request: {:?}", @@ -97,7 +104,8 @@ pub async fn main() -> Result<(), Box> { "# ); let get_assertion: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &psl, &request_json) + GetAssertionRequest::prepare(&request_origin, &request_json, &settings) + .await .expect("Failed to parse request JSON"); println!("WebAuthn GetAssertion request: {:?}", get_assertion); diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index 779406b4..f00a9630 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, OriginValidation, RelatedOrigins, + RequestOrigin, RequestSettings, WebAuthnIDLResponse as _, }; use libwebauthn::transport::{Channel as _, Device}; use libwebauthn::webauthn::WebAuthn; @@ -58,6 +58,12 @@ 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 settings = RequestSettings { + origin: OriginValidation::Validate { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Disabled, + }, + }; let mut device: CableQrCodeDevice = CableQrCodeDevice::new_transient( QrCodeOperationHint::MakeCredential, @@ -79,8 +85,10 @@ 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::prepare(&request_origin, MAKE_CREDENTIAL_REQUEST, &settings) + .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 fc3c40c2..9c5b3876 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, OriginValidation, RelatedOrigins, + RequestOrigin, RequestSettings, SystemPublicSuffixList, WebAuthnIDLResponse as _, }; use libwebauthn::transport::cable::channel::CableChannel; use libwebauthn::transport::{Channel as _, Device}; @@ -95,9 +95,18 @@ 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::prepare( + &request_origin, + MAKE_CREDENTIAL_REQUEST, + &RequestSettings { + origin: OriginValidation::Validate { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Disabled, + }, + }, + ) + .await + .expect("Failed to parse request JSON"); let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); let response_json = response @@ -155,8 +164,18 @@ 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::prepare( + request_origin, + GET_ASSERTION_REQUEST, + &RequestSettings { + origin: OriginValidation::Validate { + public_suffix_list: psl, + related_origins: RelatedOrigins::Disabled, + }, + }, + ) + .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 aa03a04b..4e69df06 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, OriginValidation, RelatedOrigins, + RequestOrigin, RequestSettings, SystemPublicSuffixList, WebAuthnIDLResponse as _, }; use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; use libwebauthn::transport::hid::list_devices; @@ -32,6 +32,12 @@ 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 settings = RequestSettings { + origin: OriginValidation::Validate { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Disabled, + }, + }; let request_json = r#" { "rp": { @@ -57,7 +63,8 @@ pub async fn main() -> Result<(), Box> { } "#; let make_credentials_request: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &psl, request_json) + MakeCredentialRequest::prepare(&request_origin, request_json, &settings) + .await .expect("Failed to parse request JSON"); println!( "WebAuthn MakeCredential request: {:?}", @@ -101,7 +108,8 @@ pub async fn main() -> Result<(), Box> { "# ); let get_assertion: GetAssertionRequest = - GetAssertionRequest::from_json(&request_origin, &psl, &request_json) + GetAssertionRequest::prepare(&request_origin, &request_json, &settings) + .await .expect("Failed to parse request JSON"); println!("WebAuthn GetAssertion request: {:?}", get_assertion); diff --git a/libwebauthn/examples/ceremony/webauthn_nfc.rs b/libwebauthn/examples/ceremony/webauthn_nfc.rs index 6c58fdb9..dc57ad00 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, OriginValidation, RelatedOrigins, + RequestOrigin, RequestSettings, SystemPublicSuffixList, WebAuthnIDLResponse as _, }; use libwebauthn::transport::nfc::{get_nfc_device, is_nfc_available}; use libwebauthn::transport::{Channel as _, Device}; @@ -30,9 +30,14 @@ 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 make_credentials_request = MakeCredentialRequest::from_json( + let settings = RequestSettings { + origin: OriginValidation::Validate { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Disabled, + }, + }; + let make_credentials_request = MakeCredentialRequest::prepare( &request_origin, - &psl, r#" { "rp": { @@ -57,7 +62,9 @@ pub async fn main() -> Result<(), Box> { "attestation": "none" } "#, + &settings, ) + .await .expect("Failed to parse request JSON"); println!( "WebAuthn MakeCredential request: {:?}", @@ -74,9 +81,8 @@ pub async fn main() -> Result<(), Box> { .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); - let get_assertion = GetAssertionRequest::from_json( + let get_assertion = GetAssertionRequest::prepare( &request_origin, - &psl, r#" { "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu", @@ -85,7 +91,9 @@ pub async fn main() -> Result<(), Box> { "userVerification": "discouraged" } "#, + &settings, ) + .await .expect("Failed to parse request JSON"); println!("WebAuthn GetAssertion request: {:?}", get_assertion); diff --git a/libwebauthn/examples/features/webauthn_related_origins_hid.rs b/libwebauthn/examples/features/webauthn_related_origins_hid.rs new file mode 100644 index 00000000..3ecb126f --- /dev/null +++ b/libwebauthn/examples/features/webauthn_related_origins_hid.rs @@ -0,0 +1,85 @@ +//! HID make-credential with related origins enabled. +//! +//! The bundled reqwest-backed [`ReqwestRelatedOriginsSource`] fetches +//! `https:///.well-known/webauthn` when the request's rp.id is not a +//! registrable suffix of the caller origin. This demo is same-origin +//! (`example.org`), so the source is wired but the fetch is not triggered. It +//! fires when rp.id and the origin sit on different registrable domains, with +//! the RP listing the caller origin in its well-known document. + +use std::error::Error; +use std::time::Duration; + +use libwebauthn::ops::webauthn::{ + JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, OriginValidation, RelatedOrigins, + RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, SystemPublicSuffixList, + WebAuthnIDLResponse as _, +}; +use libwebauthn::transport::hid::list_devices; +use libwebauthn::transport::{Channel as _, Device}; +use libwebauthn::webauthn::WebAuthn; + +#[path = "../common/mod.rs"] +mod common; + +const TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::main] +pub async fn main() -> Result<(), Box> { + common::setup_logging(); + + let devices = list_devices().await.unwrap(); + println!("Devices found: {:?}", devices); + + for mut device in devices { + println!("Selected HID authenticator: {}", &device); + let mut channel = device.channel().await?; + channel.wink(TIMEOUT).await?; + + let request_origin: RequestOrigin = + "https://example.org".try_into().expect("Invalid origin"); + 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 = ReqwestRelatedOriginsSource::new()?; + let settings = RequestSettings { + origin: OriginValidation::Validate { + public_suffix_list: &psl, + related_origins: RelatedOrigins::Enabled { + source: &related_origins, + max_labels: MaxRegistrableLabels::default(), + }, + }, + }; + let request_json = r#" + { + "rp": { "id": "example.org", "name": "Example Relying Party" }, + "user": { + "id": "MTIzNDU2NzgxMjM0NTY3ODEyMzQ1Njc4MTIzNDU2Nzg", + "name": "alice", + "displayName": "Alice" + }, + "challenge": "MTIzNDU2NzgxMjM0NTY3ODEyMzQ1Njc4MTIzNDU2Nzg", + "pubKeyCredParams": [{"type": "public-key", "alg": -7}], + "timeout": 60000, + "excludeCredentials": [], + "authenticatorSelection": { "residentKey": "discouraged", "userVerification": "preferred" }, + "attestation": "none" + } + "#; + let request = MakeCredentialRequest::prepare(&request_origin, request_json, &settings) + .await + .expect("Failed to parse request JSON"); + + let state_recv = channel.get_ux_update_receiver(); + tokio::spawn(common::handle_uv_updates(state_recv)); + + let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); + let response_json = response + .to_json_string(&request, JsonFormat::Prettified) + .expect("Failed to serialize MakeCredential response"); + println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); + } + + Ok(()) +} diff --git a/libwebauthn/src/fido.rs b/libwebauthn/src/fido.rs index 68b4e16b..1d5f2cf3 100644 --- a/libwebauthn/src/fido.rs +++ b/libwebauthn/src/fido.rs @@ -1,3 +1,17 @@ +//! Protocol abstractions shared between FIDO2 (CTAP2) and FIDO U2F (CTAP1). +//! This module models the common protocol surface of the two standards, +//! including protocol negotiation via [`FidoProtocol`], revision tracking via +//! [`FidoRevision`], and the [`AuthenticatorData`] structure returned by a +//! device during authentication and attestation ceremonies. +//! +//! [`AuthenticatorData`] is the central type. It carries the relying party ID +//! hash, the user presence and verification flags, the signature counter, +//! optional attested credential data (the device AAGUID, credential ID, and +//! credential public key), and any protocol extension outputs. The module +//! serializes and deserializes these responses per the CTAP2 specification and +//! preserves the COSE key bytes verbatim, so the device signatures a relying +//! party verifies stay intact. + use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use serde::{ de::{DeserializeOwned, Error as DesError, Visitor}, diff --git a/libwebauthn/src/lib.rs b/libwebauthn/src/lib.rs index d7f4abd2..2aa0a380 100644 --- a/libwebauthn/src/lib.rs +++ b/libwebauthn/src/lib.rs @@ -1,3 +1,72 @@ +//! libwebauthn is a Linux platform library implementing the FIDO2/WebAuthn and +//! FIDO U2F specifications. It provides traits for performing WebAuthn +//! operations (make credential and get assertion) and U2F operations (register +//! and sign) against authenticator devices connected over USB, Bluetooth, or +//! optionally NFC. The core abstractions are the [`WebAuthn`](webauthn::WebAuthn) +//! and [`U2F`](u2f::U2F) traits that drive the protocol logic, and the +//! [`Channel`](transport::Channel) trait that abstracts communication with a +//! physical authenticator. +//! +//! While an operation runs, the library may need user verification. It surfaces +//! this through the [`UvUpdate`] enum and related types such as +//! [`PinRequiredUpdate`] and [`PinNotSetUpdate`], which let an application +//! collect input (PIN entry or a presence confirmation) and feed it back into +//! the ongoing operation. The [`transport`] module manages device discovery and +//! communication, and the [`pin`] module handles the cryptographic side of PIN +//! and user verification. +//! +//! # Getting started +//! +//! A typical flow is to enumerate devices on your transport of choice, open a +//! [`Channel`](transport::Channel) to each one, and run a ceremony on that +//! channel. The ceremony traits are blanket-implemented for every channel: +//! [`WebAuthn`](webauthn::WebAuthn) for make-credential and get-assertion, and +//! the [`management`] traits ([`CredentialManagement`](management::CredentialManagement), +//! [`AuthenticatorConfig`](management::AuthenticatorConfig), +//! [`BioEnrollment`](management::BioEnrollment)) for the administrative +//! ceremonies. The make-credential and get-assertion requests are built from +//! their WebAuthn IDL (the JSON the browser API speaks) with +//! [`MakeCredentialRequest::prepare`](ops::webauthn::MakeCredentialRequest::prepare) +//! and [`GetAssertionRequest::prepare`](ops::webauthn::GetAssertionRequest::prepare), +//! which validate the caller origin against the relying party ID. +//! +//! ```no_run +//! use libwebauthn::ops::webauthn::{ +//! MakeCredentialRequest, OriginValidation, RelatedOrigins, RequestOrigin, RequestSettings, +//! SystemPublicSuffixList, +//! }; +//! use libwebauthn::transport::hid::list_devices; +//! use libwebauthn::transport::Device; +//! use libwebauthn::webauthn::WebAuthn; +//! +//! # async fn run() -> Result<(), Box> { +//! // 1. Enumerate authenticators on your transport of choice (HID shown here). +//! let devices = list_devices().await?; +//! +//! for mut device in devices { +//! // 2. Open a channel to the device. +//! let mut channel = device.channel().await?; +//! +//! // 3. Build a request from its WebAuthn IDL JSON. +//! let origin: RequestOrigin = "https://example.org".try_into().expect("invalid origin"); +//! let psl = SystemPublicSuffixList::auto().expect("public suffix list unavailable"); +//! let settings = RequestSettings { +//! origin: OriginValidation::Validate { +//! public_suffix_list: &psl, +//! related_origins: RelatedOrigins::Disabled, +//! }, +//! }; +//! let request_json = r#"{ "rp": { "id": "example.org", "name": "Example" } }"#; // abbreviated +//! let request = +//! MakeCredentialRequest::prepare(&origin, request_json, &settings).await?; +//! +//! // 4. Run the ceremony on the channel. +//! let _response = channel.webauthn_make_credential(&request).await?; +//! } +//! # Ok(()) +//! # } +//! ``` + // Production code must not panic. Tests keep unwrap/expect/panic latitude // through `not(test)`, and the virt test-utility code through local allows. #![cfg_attr(not(test), deny(clippy::unwrap_used))] diff --git a/libwebauthn/src/management.rs b/libwebauthn/src/management.rs index 8ff3f9da..9534ef3f 100644 --- a/libwebauthn/src/management.rs +++ b/libwebauthn/src/management.rs @@ -1,3 +1,16 @@ +//! Administration interfaces for CTAP2 authenticators. This module exposes +//! traits for managing device configuration, biometric enrollment, and stored +//! credentials over any [`Channel`](crate::transport::Channel) transport. These +//! operations require user verification through a PIN or biometric factor, and +//! the protocol handles token acquisition and retry internally. +//! +//! Use [`CredentialManagement`] to enumerate and delete resident credentials, +//! [`AuthenticatorConfig`] to adjust device settings such as PIN policy and +//! enterprise attestation, and [`BioEnrollment`] to manage biometric templates. +//! Each trait is blanket-implemented for any +//! [`Channel`](crate::transport::Channel), so the same API works across every +//! transport. + mod bio_enrollment; pub use bio_enrollment::BioEnrollment; diff --git a/libwebauthn/src/ops/mod.rs b/libwebauthn/src/ops/mod.rs index 73931b52..df610d27 100644 --- a/libwebauthn/src/ops/mod.rs +++ b/libwebauthn/src/ops/mod.rs @@ -1,2 +1,25 @@ +//! Request and response types for WebAuthn registration and authentication +//! ceremonies, alongside the legacy FIDO U2F operation types. The main types are +//! [`MakeCredentialRequest`](webauthn::MakeCredentialRequest) and +//! [`GetAssertionRequest`](webauthn::GetAssertionRequest), which describe the +//! parameters for creating and asserting a credential, and the matching +//! [`MakeCredentialResponse`](webauthn::MakeCredentialResponse) and +//! [`GetAssertionResponse`](webauthn::GetAssertionResponse) that carry the +//! outcome. The request types also cover WebAuthn extensions such as PRF, +//! HMAC secret, credProtect, and large blob storage. +//! +//! Always build these requests from their WebAuthn IDL JSON with +//! [`MakeCredentialRequest::prepare`](webauthn::MakeCredentialRequest::prepare) +//! and [`GetAssertionRequest::prepare`](webauthn::GetAssertionRequest::prepare): +//! `prepare` validates the caller origin against the relying party ID, so it is +//! the only safe way to construct an operation. +//! +//! The module also defines the operation kinds and user verification +//! requirements, client data hashing helpers, and conversions between the +//! WebAuthn IDL (JSON) representation and the internal types. A U2F response can +//! be promoted to its WebAuthn equivalent through the +//! [`UpgradableResponse`](u2f::UpgradableResponse) trait, which bridges legacy +//! credentials into the WebAuthn world. + pub mod u2f; pub mod webauthn; diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 1fcbf7d0..77b1bd9b 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}; @@ -13,16 +14,14 @@ use crate::{ HmacGetSecretInputJson, LargeBlobInputJson, PrfInputJson, PublicKeyCredentialRequestOptionsJSON, }, - origin::is_registrable_domain_suffix_or_equal, response::{ AuthenticationExtensionsClientOutputsJSON, AuthenticationResponseJSON, AuthenticatorAssertionResponseJSON, HMACGetSecretOutputJSON, LargeBlobOutputJSON, PRFOutputJSON, PRFValuesJSON, ResponseSerializationError, WebAuthnIDLResponse, }, - Base64UrlString, FromIdlModel, JsonError, + rp_id_authorised, Base64UrlString, FromIdlModel, JsonError, RequestSettings, }, - psl::PublicSuffixList, - Operation, WebAuthnIDL, + Operation, }, pin::PinUvAuthProtocol, proto::ctap2::{ @@ -109,7 +108,7 @@ impl GetAssertionRequest { } #[derive(thiserror::Error, Debug)] -pub enum GetAssertionRequestParsingError { +pub enum GetAssertionPrepareError { /// The client must throw an "EncodingError" DOMException. #[error("Invalid JSON format: {0}")] EncodingError(#[from] JsonError), @@ -135,24 +134,32 @@ pub enum GetAssertionRequestParsingError { /// 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 { +fn validate_appid(appid: &str) -> Result { if appid.is_empty() { - return Err(GetAssertionRequestParsingError::InvalidAppId( + 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(GetAssertionRequestParsingError::InvalidAppId(format!( + return Err(GetAssertionPrepareError::InvalidAppId(format!( "appid must be an https URL, got: {appid}" ))); } Ok(appid.to_string()) } -impl WebAuthnIDL for GetAssertionRequest { - type Error = GetAssertionRequestParsingError; - type IdlModel = PublicKeyCredentialRequestOptionsJSON; +impl GetAssertionRequest { + /// Builds a [`GetAssertionRequest`] from its WebAuthn IDL JSON, validating + /// the caller origin against rp.id per `settings`. + pub async fn prepare( + request_origin: &RequestOrigin, + json: &str, + settings: &RequestSettings<'_>, + ) -> Result { + let model: PublicKeyCredentialRequestOptionsJSON = serde_json::from_str(json)?; + Self::from_idl_model(request_origin, settings, model).await + } } /** dictionary PublicKeyCredentialRequestOptionsJSON { @@ -164,22 +171,21 @@ impl WebAuthnIDL for GetAssertionRequest { sequence hints = []; AuthenticationExtensionsClientInputsJSON extensions; }; */ -impl FromIdlModel - for GetAssertionRequest -{ - fn from_idl_model( +#[async_trait] +impl FromIdlModel for GetAssertionRequest { + type Error = GetAssertionPrepareError; + + async fn from_idl_model( request_origin: &RequestOrigin, - psl: &dyn PublicSuffixList, + settings: &RequestSettings<'_>, inner: PublicKeyCredentialRequestOptionsJSON, - ) -> Result { + ) -> Result { let effective_rp_id = request_origin.origin.host.as_str(); 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 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( + let parsed = RelyingPartyId::try_from(relying_party_id) + .map_err(|err| GetAssertionPrepareError::InvalidRelyingPartyId(err.to_string()))?; + if !rp_id_authorised(request_origin, &parsed, settings).await { + return Err(GetAssertionPrepareError::MismatchingRelyingPartyId( parsed.0, effective_rp_id.to_string(), )); @@ -254,7 +260,7 @@ pub struct PrfInput { } impl TryFrom for PrfInput { - type Error = GetAssertionRequestParsingError; + type Error = GetAssertionPrepareError; fn try_from(value: PrfInputJson) -> Result { let eval = value.eval.map(|v| PrfInputValue { @@ -298,18 +304,18 @@ pub struct HMACGetSecretInput { } impl TryFrom for HMACGetSecretInput { - type Error = GetAssertionRequestParsingError; + type Error = GetAssertionPrepareError; fn try_from(value: HmacGetSecretInputJson) -> Result { let salt1 = value.salt1.as_slice().try_into().map_err(|_| { - GetAssertionRequestParsingError::UnexpectedLengthError( + GetAssertionPrepareError::UnexpectedLengthError( "extensions.hmacCreateSecret.salt1".to_string(), value.salt1.as_slice().len(), ) })?; let salt2 = match value.salt2 { Some(s) => Some(s.as_slice().try_into().map_err(|_| { - GetAssertionRequestParsingError::UnexpectedLengthError( + GetAssertionPrepareError::UnexpectedLengthError( "extensions.hmacCreateSecret.salt2".to_string(), s.as_slice().len(), ) @@ -328,15 +334,15 @@ pub enum GetAssertionLargeBlobExtension { } impl TryFrom for GetAssertionLargeBlobExtension { - type Error = GetAssertionRequestParsingError; + type Error = GetAssertionPrepareError; fn try_from(value: LargeBlobInputJson) -> Result { match value.read { Some(true) => Ok(GetAssertionLargeBlobExtension::Read), - Some(false) => Err(GetAssertionRequestParsingError::NotSupported( + Some(false) => Err(GetAssertionPrepareError::NotSupported( "largeBlob writes not supported".to_string(), )), - None => Err(GetAssertionRequestParsingError::NotSupported( + None => Err(GetAssertionPrepareError::NotSupported( "largeBlob read not requested".to_string(), )), } @@ -649,14 +655,75 @@ 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::{GetAssertionRequest, RequestOrigin}; + use crate::ops::webauthn::psl::{MockPublicSuffixList, PublicSuffixList}; + use crate::ops::webauthn::related_origins::{ + HttpClientError, MaxRegistrableLabels, RelatedOrigins, RelatedOriginsError, + RelatedOriginsSource, + }; + use crate::ops::webauthn::{GetAssertionRequest, OriginValidation, RequestOrigin}; use crate::proto::ctap2::Ctap2PublicKeyCredentialType; use super::*; + // Fixed-result source; `panicking` proves the suffix-check short-circuit by + // failing if consulted. + struct MockSource { + result: Option, RelatedOriginsError>>, + } + + impl MockSource { + fn origins(items: &[&str]) -> Self { + Self { + result: Some(Ok(items.iter().map(|s| s.to_string()).collect())), + } + } + + fn err(e: RelatedOriginsError) -> Self { + Self { + result: Some(Err(e)), + } + } + + fn panicking() -> Self { + Self { result: None } + } + } + + #[async_trait] + impl RelatedOriginsSource for MockSource { + async fn allowed_origins( + &self, + _: &RelyingPartyId, + ) -> Result, RelatedOriginsError> { + match &self.result { + Some(r) => r.clone(), + None => panic!("allowed_origins should not be called"), + } + } + } + + async fn from_json( + origin: &RequestOrigin, + psl: &dyn PublicSuffixList, + related_origins: RelatedOrigins<'_>, + json: &str, + ) -> Result { + GetAssertionRequest::prepare( + origin, + json, + &RequestSettings { + origin: OriginValidation::Validate { + public_suffix_list: psl, + related_origins, + }, + }, + ) + .await + } + pub const REQUEST_BASE_JSON: &str = r#" { "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu", @@ -703,95 +770,227 @@ 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( + let req: GetAssertionRequest = from_json( &request_origin, &MockPublicSuffixList, + RelatedOrigins::Disabled, 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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await; assert!(matches!( result, - Err(GetAssertionRequestParsingError::InvalidRelyingPartyId(_)) + Err(GetAssertionPrepareError::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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await; assert!(matches!( result, - Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( - _, - _ - )) + Err(GetAssertionPrepareError::MismatchingRelyingPartyId(_, _)) )); } - #[test] - fn test_request_from_json_rp_id_is_parent_registrable_suffix() { + #[tokio::test] + async fn origin_trust_accepts_mismatching_rp_id() { + let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap(); + let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#); + let req = GetAssertionRequest::prepare( + &request_origin, + &req_json, + &RequestSettings { + origin: OriginValidation::Trust, + }, + ) + .await + .unwrap(); + assert_eq!(req.relying_party_id, "example.com"); + } + + #[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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await; assert!(matches!( result, - Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId( - _, - _ - )) + Err(GetAssertionPrepareError::MismatchingRelyingPartyId(_, _)) )); } - #[test] - fn test_request_from_json_ignore_missing_allow_credentials() { + // `.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 source = MockSource::origins(&["https://app.example.org"]); + + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, + &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 source = MockSource::origins(&["https://other.org"]); + + let result = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, + &req_json, + ) + .await; + assert!(matches!( + result, + Err(GetAssertionPrepareError::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 source = MockSource::err(RelatedOriginsError::Http(HttpClientError::Transport( + "simulated".into(), + ))); + + let result = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, + &req_json, + ) + .await; + assert!(matches!( + result, + Err(GetAssertionPrepareError::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 source = MockSource::panicking(); + + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, + &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(); 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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); assert_eq!( req, GetAssertionRequest { @@ -801,36 +1000,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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &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, @@ -838,9 +1047,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 = 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(), @@ -848,8 +1062,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, @@ -857,10 +1071,16 @@ mod tests { r#"{"appid":"http://www.example.org/u2f/origins.json"}"#, ); - let res = GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + let res = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await; assert!(matches!( res, - Err(GetAssertionRequestParsingError::InvalidAppId(_)) + Err(GetAssertionPrepareError::InvalidAppId(_)) )); } @@ -909,34 +1129,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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &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}"); @@ -944,34 +1170,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 49eb8377..b9625e63 100644 --- a/libwebauthn/src/ops/webauthn/idl/mod.rs +++ b/libwebauthn/src/ops/webauthn/idl/mod.rs @@ -14,45 +14,92 @@ pub use response::{ WebAuthnIDLResponse, }; -use origin::RequestOrigin; +use async_trait::async_trait; +use serde::de::DeserializeOwned; +use tracing::debug; -use super::psl::PublicSuffixList; +use origin::{is_registrable_domain_suffix_or_equal, RequestOrigin}; +use rpid::RelyingPartyId; -use serde::de::DeserializeOwned; -use serde_json; +use super::psl::PublicSuffixList; +use super::related_origins::{validate_related_origins, RelatedOrigins}; pub type JsonError = serde_json::Error; -pub trait WebAuthnIDL: Sized -where - E: std::error::Error, // Validation error type. - 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; - - /// The JSON model that this IDL can deserialize from. - type IdlModel: DeserializeOwned; +/// Per-request settings (currently just the origin-validation policy). +pub struct RequestSettings<'a> { + pub origin: OriginValidation<'a>, +} - 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, psl, idl_model).map_err(From::from) - } +/// How the caller origin is validated against the request rp.id. +pub enum OriginValidation<'a> { + /// Trust the caller's origin to rp.id binding with no check, for callers + /// that have already validated it (e.g. a browser). Misuse defeats phishing + /// resistance, so the caller owns that decision. + Trust, + /// Validate rp.id against the caller origin: a registrable suffix of the + /// effective domain, then related origins on mismatch. + Validate { + public_suffix_list: &'a dyn PublicSuffixList, + related_origins: RelatedOrigins<'a>, + }, } -pub trait FromIdlModel: Sized +/// Builds a request from its parsed IDL model, validating origin against rp.id. +#[async_trait] +pub(crate) trait FromIdlModel: Sized where - T: DeserializeOwned, - E: std::error::Error, + T: DeserializeOwned + Send, { - fn from_idl_model( + type Error: std::error::Error + From; + + async fn from_idl_model( request_origin: &RequestOrigin, - psl: &dyn PublicSuffixList, + settings: &RequestSettings<'_>, model: T, - ) -> Result; + ) -> Result; +} + +/// 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. +pub(crate) async fn rp_id_authorised( + request_origin: &RequestOrigin, + rp_id: &RelyingPartyId, + settings: &RequestSettings<'_>, +) -> bool { + let (public_suffix_list, related_origins) = match &settings.origin { + OriginValidation::Trust => return true, + OriginValidation::Validate { + public_suffix_list, + related_origins, + } => (*public_suffix_list, related_origins), + }; + let effective_rp_id = request_origin.origin.host.as_str(); + if is_registrable_domain_suffix_or_equal(&rp_id.0, effective_rp_id, public_suffix_list) { + return true; + } + match related_origins { + RelatedOrigins::Disabled => false, + RelatedOrigins::Enabled { source, max_labels } => { + match source.allowed_origins(rp_id).await { + Err(err) => { + debug!(rp_id = %rp_id.0, %err, "Related-origins resolution failed"); + false + } + Ok(origins) => match validate_related_origins( + &request_origin.origin, + &origins, + public_suffix_list, + *max_labels, + ) { + Ok(()) => true, + Err(err) => { + debug!(rp_id = %rp_id.0, %err, "Related-origins match failed"); + false + } + }, + } + } + } } diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 0601a9a6..ed8acb5c 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}; @@ -12,15 +13,13 @@ use crate::{ idl::{ create::PublicKeyCredentialCreationOptionsJSON, get::PrfValuesJson, - origin::is_registrable_domain_suffix_or_equal, response::{ AuthenticationExtensionsClientOutputsJSON, AuthenticatorAttestationResponseJSON, CredentialPropertiesOutputJSON, LargeBlobOutputJSON, PRFOutputJSON, PRFValuesJSON, RegistrationResponseJSON, ResponseSerializationError, WebAuthnIDLResponse, }, - Base64UrlString, FromIdlModel, JsonError, WebAuthnIDL, + rp_id_authorised, Base64UrlString, FromIdlModel, JsonError, RequestSettings, }, - psl::PublicSuffixList, Operation, PrfInputValue, PrfOutputValue, RelyingPartyId, RequestOrigin, }, proto::{ @@ -365,26 +364,23 @@ impl MakeCredentialRequest { } } -impl FromIdlModel - for MakeCredentialRequest -{ - fn from_idl_model( +#[async_trait] +impl FromIdlModel for MakeCredentialRequest { + type Error = MakeCredentialPrepareError; + + async fn from_idl_model( request_origin: &RequestOrigin, - psl: &dyn PublicSuffixList, + settings: &RequestSettings<'_>, inner: PublicKeyCredentialCreationOptionsJSON, - ) -> Result { + ) -> 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(), - ), - ); + let rp_id = RelyingPartyId::try_from(inner.rp.id.as_str()) + .map_err(|err| MakeCredentialPrepareError::InvalidRelyingPartyId(err.to_string()))?; + if !rp_id_authorised(request_origin, &rp_id, settings).await { + return Err(MakeCredentialPrepareError::MismatchingRelyingPartyId( + rp_id.0, + effective_rp_id.to_string(), + )); } let mut relying_party = inner.rp; relying_party.id = rp_id.0; @@ -435,7 +431,7 @@ impl FromIdlModel for MakeCredentialRequest { - type Error = MakeCredentialRequestParsingError; - type IdlModel = PublicKeyCredentialCreationOptionsJSON; +impl MakeCredentialRequest { + /// Builds a [`MakeCredentialRequest`] from its WebAuthn IDL JSON, validating + /// the caller origin against rp.id per `settings`. + pub async fn prepare( + request_origin: &RequestOrigin, + json: &str, + settings: &RequestSettings<'_>, + ) -> Result { + let model: PublicKeyCredentialCreationOptionsJSON = serde_json::from_str(json)?; + Self::from_idl_model(request_origin, settings, model).await + } } #[derive(Debug, Default, Clone, Deserialize, PartialEq)] @@ -691,12 +695,74 @@ impl DowngradableRequest for MakeCredentialRequest { mod tests { use std::time::Duration; - use crate::ops::webauthn::psl::MockPublicSuffixList; - use crate::ops::webauthn::{MakeCredentialRequest, RequestOrigin}; + use async_trait::async_trait; + + use crate::ops::webauthn::psl::{MockPublicSuffixList, PublicSuffixList}; + use crate::ops::webauthn::related_origins::{ + HttpClientError, MaxRegistrableLabels, RelatedOrigins, RelatedOriginsError, + RelatedOriginsSource, + }; + use crate::ops::webauthn::{MakeCredentialRequest, OriginValidation, RequestOrigin}; use crate::proto::ctap2::Ctap2PublicKeyCredentialType; use super::*; + // Fixed-result source; `panicking` proves the suffix-check short-circuit by + // failing if consulted. + struct MockSource { + result: Option, RelatedOriginsError>>, + } + + impl MockSource { + fn origins(items: &[&str]) -> Self { + Self { + result: Some(Ok(items.iter().map(|s| s.to_string()).collect())), + } + } + + fn err(e: RelatedOriginsError) -> Self { + Self { + result: Some(Err(e)), + } + } + + fn panicking() -> Self { + Self { result: None } + } + } + + #[async_trait] + impl RelatedOriginsSource for MockSource { + async fn allowed_origins( + &self, + _: &RelyingPartyId, + ) -> Result, RelatedOriginsError> { + match &self.result { + Some(r) => r.clone(), + None => panic!("allowed_origins should not be called"), + } + } + } + + async fn from_json( + origin: &RequestOrigin, + psl: &dyn PublicSuffixList, + related_origins: RelatedOrigins<'_>, + json: &str, + ) -> Result { + MakeCredentialRequest::prepare( + origin, + json, + &RequestSettings { + origin: OriginValidation::Validate { + public_suffix_list: psl, + related_origins, + }, + }, + ) + .await + } + pub const REQUEST_BASE_JSON: &str = r#" { "rp": { @@ -756,76 +822,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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await; assert!(matches!( result, - Err(MakeCredentialRequestParsingError::EncodingError(_)) + Err(MakeCredentialPrepareError::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( + let req: MakeCredentialRequest = from_json( &request_origin, &MockPublicSuffixList, + RelatedOrigins::Disabled, 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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await; assert!(matches!( result, - Err(MakeCredentialRequestParsingError::EncodingError(_)) + Err(MakeCredentialPrepareError::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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); let prf = req .extensions .as_ref() @@ -836,29 +919,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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); let prf = req .extensions .as_ref() @@ -869,8 +962,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, @@ -878,9 +971,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 = 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(), @@ -888,17 +986,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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); assert_eq!( req.algorithms, vec![Ctap2CredentialType { @@ -908,27 +1011,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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &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 = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); assert_eq!( req.user_verification, UserVerificationRequirement::Preferred @@ -937,8 +1050,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"); @@ -948,17 +1061,22 @@ mod tests { r#"{"residentKey": "discouraged"}"#, ); - let req: MakeCredentialRequest = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req: MakeCredentialRequest = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &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, @@ -966,16 +1084,63 @@ mod tests { r#"{"id": "example.org.", "name": "example.org"}"#, ); - let result = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + let result = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await; assert!(matches!( result, - Err(MakeCredentialRequestParsingError::InvalidRelyingPartyId(_)) + Err(MakeCredentialPrepareError::InvalidRelyingPartyId(_)) )); } - #[test] - fn test_request_from_json_mismatching_rp_id() { + #[tokio::test] + async fn origin_trust_accepts_mismatching_rp_id() { + 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 req = MakeCredentialRequest::prepare( + &request_origin, + &req_json, + &RequestSettings { + origin: OriginValidation::Trust, + }, + ) + .await + .unwrap(); + assert_eq!(req.relying_party.id, "example.com"); + } + + #[tokio::test] + async fn origin_trust_still_rejects_invalid_rp_id() { + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "rp", + r#"{"id": "example.org.", "name": "example.org"}"#, + ); + let result = MakeCredentialRequest::prepare( + &request_origin, + &req_json, + &RequestSettings { + origin: OriginValidation::Trust, + }, + ) + .await; + assert!(matches!( + result, + Err(MakeCredentialPrepareError::InvalidRelyingPartyId(_)) + )); + } + + #[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, @@ -983,16 +1148,21 @@ mod tests { r#"{"id": "other.example.org", "name": "example.org"}"#, ); - let result = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + let result = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await; assert!(matches!( result, - Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _)) + Err(MakeCredentialPrepareError::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, @@ -1000,15 +1170,20 @@ mod tests { r#"{"id": "example.org", "name": "example.org"}"#, ); - let req = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &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, @@ -1016,16 +1191,21 @@ mod tests { r#"{"id": "co.uk", "name": "co.uk"}"#, ); - let result = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + let result = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await; assert!(matches!( result, - Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _)) + Err(MakeCredentialPrepareError::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, @@ -1033,15 +1213,20 @@ mod tests { r#"{"id": "localhost", "name": "localhost"}"#, ); - let req = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &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, @@ -1049,13 +1234,123 @@ mod tests { r#"{"id": "localhost", "name": "localhost"}"#, ); - let req = - MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) - .unwrap(); + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Disabled, + &req_json, + ) + .await + .unwrap(); assert_eq!(req.relying_party.id, "localhost"); 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 source = MockSource::origins(&["https://app.example.org"]); + + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, + &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 source = MockSource::origins(&["https://other.org"]); + + let result = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, + &req_json, + ) + .await; + assert!(matches!( + result, + Err(MakeCredentialPrepareError::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 source = MockSource::err(RelatedOriginsError::Http(HttpClientError::Transport( + "simulated".into(), + ))); + + let result = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, + &req_json, + ) + .await; + assert!(matches!( + result, + Err(MakeCredentialPrepareError::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 source = MockSource::panicking(); + + let req = from_json( + &request_origin, + &MockPublicSuffixList, + RelatedOrigins::Enabled { + source: &source, + max_labels: MaxRegistrableLabels::default(), + }, + &req_json, + ) + .await + .unwrap(); + assert_eq!(req.relying_party.id, "example.com"); + } + // Tests for response JSON serialization fn create_test_response() -> MakeCredentialResponse { diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 767c6c0a..0caa0bc6 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}; @@ -10,32 +11,39 @@ use crate::webauthn::CtapError; pub use client_data::ClientData; pub use get_assertion::{ Assertion, Ctap2HMACGetSecretOutput, GetAssertionHmacOrPrfInput, - GetAssertionLargeBlobExtension, GetAssertionLargeBlobExtensionOutput, GetAssertionPrfOutput, - GetAssertionRequest, GetAssertionRequestExtensions, GetAssertionResponse, - GetAssertionResponseExtensions, GetAssertionResponseUnsignedExtensions, HMACGetSecretInput, - HMACGetSecretOutput, PrfInput, PrfInputValue, PrfOutputValue, + GetAssertionLargeBlobExtension, GetAssertionLargeBlobExtensionOutput, GetAssertionPrepareError, + GetAssertionPrfOutput, GetAssertionRequest, GetAssertionRequestExtensions, + GetAssertionResponse, GetAssertionResponseExtensions, GetAssertionResponseUnsignedExtensions, + HMACGetSecretInput, HMACGetSecretOutput, PrfInput, PrfInputValue, PrfOutputValue, }; pub use idl::{ origin::{HostParseError, Origin, OriginHost, OriginParseError, RequestOrigin, Scheme}, rpid::RelyingPartyId, AuthenticationExtensionsClientOutputsJSON, AuthenticationResponseJSON, AuthenticatorAssertionResponseJSON, AuthenticatorAttestationResponseJSON, Base64UrlString, - JsonFormat, RegistrationResponseJSON, ResponseSerializationError, WebAuthnIDL, - WebAuthnIDLResponse, + JsonFormat, OriginValidation, RegistrationResponseJSON, RequestSettings, + ResponseSerializationError, WebAuthnIDLResponse, }; pub use make_credential::{ CredentialPropsExtension, CredentialProtectionExtension, CredentialProtectionPolicy, MakeCredentialLargeBlobExtension, MakeCredentialLargeBlobExtensionInput, - MakeCredentialLargeBlobExtensionOutput, MakeCredentialPrfInput, MakeCredentialPrfOutput, - MakeCredentialRequest, MakeCredentialResponse, MakeCredentialsRequestExtensions, - MakeCredentialsResponseExtensions, MakeCredentialsResponseUnsignedExtensions, - ResidentKeyRequirement, + MakeCredentialLargeBlobExtensionOutput, MakeCredentialPrepareError, MakeCredentialPrfInput, + MakeCredentialPrfOutput, MakeCredentialRequest, MakeCredentialResponse, + MakeCredentialsRequestExtensions, MakeCredentialsResponseExtensions, + MakeCredentialsResponseUnsignedExtensions, ResidentKeyRequirement, }; pub use psl::{ DafsaFileLoadError, DafsaFilePublicSuffixList, DatFileLoadError, DatFilePublicSuffixList, PublicSuffixList, SystemLoadError, SystemPublicSuffixList, SYSTEM_PSL_DAFSA_PATH, SYSTEM_PSL_PATH, }; +pub use related_origins::{ + validate_related_origins, HttpClient, HttpClientError, MaxRegistrableLabels, RelatedOrigins, + RelatedOriginsError, RelatedOriginsSource, StaticRelatedOriginsSource, + WellKnownRelatedOriginsSource, +}; +#[cfg(feature = "reqwest-related-origins-source")] +pub use related_origins::{HttpPolicy, ReqwestHttpClient, ReqwestRelatedOriginsSource}; 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 00000000..2d2ab1e6 --- /dev/null +++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs @@ -0,0 +1,750 @@ +//! Related-origins validation (WebAuthn L3 §5.11). Matching lives here in the +//! core. The allowed origins for an rp.id come from a [`RelatedOriginsSource`], +//! the default [`WellKnownRelatedOriginsSource`] fetching the well-known +//! document over an [`HttpClient`]. + +use std::collections::{BTreeSet, HashMap}; + +use async_trait::async_trait; +use serde::Deserialize; +use url::{Host, Url}; + +use super::idl::origin::{Origin, Scheme}; +use super::idl::rpid::RelyingPartyId; +use super::psl::PublicSuffixList; + +#[cfg(feature = "reqwest-related-origins-source")] +mod reqwest_impl; + +#[cfg(feature = "reqwest-related-origins-source")] +pub use reqwest_impl::{HttpPolicy, ReqwestHttpClient, ReqwestRelatedOriginsSource}; + +/// Cap on distinct registrable-domain labels considered during matching +/// (WebAuthn L3 §5.11). Cannot hold a value below the spec floor of five. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MaxRegistrableLabels(usize); + +impl MaxRegistrableLabels { + /// Absolute cap, or `None` if below the floor of five. + pub const fn new(n: usize) -> Option { + if n >= 5 { + Some(Self(n)) + } else { + None + } + } + + /// `n` labels beyond the floor of five. + pub const fn extra(n: usize) -> Self { + Self(5usize.saturating_add(n)) + } +} + +impl Default for MaxRegistrableLabels { + fn default() -> Self { + Self(5) + } +} + +impl From for usize { + fn from(value: MaxRegistrableLabels) -> Self { + value.0 + } +} + +/// Transport failure modes for [`HttpClient::get`]. +#[derive(thiserror::Error, Debug, Clone)] +pub enum HttpClientError { + /// TLS, DNS, timeout, rejected redirect, stream interrupt, client build, etc. + #[error("transport error: {0}")] + Transport(String), + /// Body exceeded the implementation's configured size cap before completion. + #[error("response body exceeded the configured size cap")] + BodyTooLarge, +} + +/// Minimal HTTP GET for fetching the well-known document. Implementations MUST +/// send no credentials or Referer, follow only `https://` redirects, and cap the +/// body size and duration. Status is returned as data, the body unparsed. +#[async_trait] +pub trait HttpClient: Send + Sync { + async fn get(&self, url: &Url) -> Result>, HttpClientError>; +} + +/// Supplies the set of allowed origins for an RP id (WebAuthn L3 §5.11). +/// [`WellKnownRelatedOriginsSource`] is the default, fetching the well-known +/// document; a caller that already holds the list may implement this directly. +#[async_trait] +pub trait RelatedOriginsSource: Send + Sync { + async fn allowed_origins( + &self, + rp_id: &RelyingPartyId, + ) -> Result, RelatedOriginsError>; +} + +/// How related-origins is handled for a request. +pub enum RelatedOrigins<'a> { + /// Do not consult related origins; a mismatching rp.id is rejected. + Disabled, + /// Resolve allowed origins through `source` and match, considering at most + /// `max_labels` distinct registrable-domain labels. + Enabled { + source: &'a dyn RelatedOriginsSource, + max_labels: MaxRegistrableLabels, + }, +} + +/// Failure modes when resolving or matching related origins. +#[derive(thiserror::Error, Debug, Clone)] +pub enum RelatedOriginsError { + #[error("http error: {0}")] + Http(#[from] HttpClientError), + /// Endpoint replied with a non-200 status (after following redirects). + #[error("unexpected HTTP status {0}")] + UnexpectedStatus(u16), + #[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")] + NoMatchingOrigin, +} + +pub type RelatedOriginsResult = Result<(), RelatedOriginsError>; + +#[derive(Debug, Deserialize)] +struct WellKnownDocument { + origins: Vec, +} + +/// [`RelatedOriginsSource`] that fetches `https://{rp_id}/.well-known/webauthn` +/// over an [`HttpClient`] and returns its `origins` array (WebAuthn L3 §5.11.1 +/// step 2). Generic over the transport. +pub struct WellKnownRelatedOriginsSource { + http: C, +} + +impl WellKnownRelatedOriginsSource { + /// Wrap an [`HttpClient`] as a well-known related-origins source. + pub fn from_client(http: C) -> Self { + Self { http } + } +} + +#[async_trait] +impl RelatedOriginsSource for WellKnownRelatedOriginsSource { + async fn allowed_origins( + &self, + rp_id: &RelyingPartyId, + ) -> Result, RelatedOriginsError> { + let url = Url::parse(&format!("https://{}/.well-known/webauthn", rp_id.0)) + .map_err(|e| RelatedOriginsError::MalformedDocument(format!("invalid rp id: {e}")))?; + let resp = self.http.get(&url).await?; + if resp.status() != http::StatusCode::OK { + return Err(RelatedOriginsError::UnexpectedStatus( + resp.status().as_u16(), + )); + } + let content_type = resp + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let content_type_ok = content_type + .as_deref() + .map(is_application_json) + .unwrap_or(false); + if !content_type_ok { + return Err(RelatedOriginsError::UnexpectedContentType(content_type)); + } + + 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()))?; + Ok(doc.origins) + } +} + +/// [`RelatedOriginsSource`] backed by an in-memory map of rp.id to its known +/// related origins, for callers that already hold the list and want no fetch. +pub struct StaticRelatedOriginsSource { + by_rp_id: HashMap>, +} + +impl StaticRelatedOriginsSource { + /// Source for a single rp.id and its known related origins. + pub fn new( + rp_id: impl Into, + origins: impl IntoIterator>, + ) -> Self { + let mut by_rp_id = HashMap::new(); + by_rp_id.insert(rp_id.into(), origins.into_iter().map(Into::into).collect()); + Self { by_rp_id } + } + + /// Source backed by a map of rp.id to its known related origins. + pub fn from_map(by_rp_id: HashMap>) -> Self { + Self { by_rp_id } + } +} + +#[async_trait] +impl RelatedOriginsSource for StaticRelatedOriginsSource { + async fn allowed_origins( + &self, + rp_id: &RelyingPartyId, + ) -> Result, RelatedOriginsError> { + Ok(self + .by_rp_id + .get(rp_id.0.as_str()) + .cloned() + .unwrap_or_default()) + } +} + +/// Runs the WebAuthn L3 §5.11.1 matching procedure: returns `Ok(())` if +/// `caller_origin` is same-origin with one of `origins`, considering at most +/// `max_labels` distinct registrable-domain labels. +pub fn validate_related_origins( + caller_origin: &Origin, + origins: &[String], + psl: &dyn PublicSuffixList, + max_labels: MaxRegistrableLabels, +) -> RelatedOriginsResult { + let cap: usize = max_labels.into(); + let mut labels_seen: BTreeSet = BTreeSet::new(); + for origin_item in 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() >= cap && !labels_seen.contains(&label) { + continue; + } + if same_origin(caller_origin, &url) { + return Ok(()); + } + if labels_seen.len() < cap { + labels_seen.insert(label); + } + } + + Err(RelatedOriginsError::NoMatchingOrigin) +} + +/// 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()?; + if label.is_empty() { + return None; + } + Some(label.to_string()) +} + +/// Effective domain of `url` per HTML §6.2, or `None` for opaque or host-less URLs. +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}]")), + } +} + +/// Tuple-origin (scheme, host, port) equality per WebAuthn L3 §5.11.1 step 4.f. +fn same_origin(caller: &Origin, listed: &Url) -> bool { + if caller.scheme.as_str() != listed.scheme() { + return false; + } + let Some(listed_host) = effective_domain_of(listed) else { + return false; + }; + 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 per the WHATWG URL Standard 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; 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") +} + +#[cfg(test)] +mod tests { + use super::super::psl::MockPublicSuffixList; + use super::*; + + fn caller(s: &str) -> Origin { + Origin::try_from(s).unwrap() + } + + fn rp(s: &str) -> RelyingPartyId { + RelyingPartyId::try_from(s).unwrap() + } + + fn origins(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() + } + + /// Matches a caller origin against `items` with the default cap (5). + fn validate(caller_s: &str, items: &[&str]) -> RelatedOriginsResult { + validate_related_origins( + &caller(caller_s), + &origins(items), + &MockPublicSuffixList, + MaxRegistrableLabels::default(), + ) + } + + // ---- MaxRegistrableLabels ---- + + #[test] + fn max_registrable_labels_enforces_floor() { + assert_eq!(MaxRegistrableLabels::new(4), None); + assert_eq!(MaxRegistrableLabels::new(0), None); + assert_eq!(usize::from(MaxRegistrableLabels::new(5).unwrap()), 5); + assert_eq!(usize::from(MaxRegistrableLabels::new(50).unwrap()), 50); + assert_eq!(usize::from(MaxRegistrableLabels::extra(0)), 5); + assert_eq!(usize::from(MaxRegistrableLabels::extra(3)), 8); + assert_eq!(usize::from(MaxRegistrableLabels::default()), 5); + } + + // ---- registrable_origin_label ---- + + #[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); + } + + // ---- matching (validate_related_origins) ---- + + #[test] + fn same_origin_caller_listed_first() { + assert!(matches!( + validate("https://example.com", &["https://example.com"]), + Ok(()) + )); + } + + #[test] + fn same_origin_with_port_match() { + assert!(matches!( + validate("https://example.com:8443", &["https://example.com:8443"]), + Ok(()) + )); + } + + #[test] + fn same_origin_with_port_mismatch_rejected() { + assert!(matches!( + validate("https://example.com", &["https://example.com:8443"]), + Err(RelatedOriginsError::NoMatchingOrigin) + )); + } + + #[test] + fn same_origin_default_port_normalised() { + assert!(matches!( + validate("https://example.com", &["https://example.com:443"]), + Ok(()) + )); + } + + #[test] + fn caller_listed_after_other_origins() { + // Substituted `.de` with `.net` (MockPublicSuffixList lacks `.de`). + assert!(matches!( + validate( + "https://example.com", + &["https://other.net", "https://example.com"] + ), + Ok(()) + )); + } + + #[test] + fn label_cap_allows_fifth_distinct_label_match() { + // The 5th distinct label is still within the cap. + assert!(matches!( + validate( + "https://example.com", + &[ + "https://a.com", + "https://b.com", + "https://c.com", + "https://d.com", + "https://example.com", + ] + ), + Ok(()) + )); + } + + #[test] + fn label_cap_blocks_sixth_distinct_label_match() { + // A sixth distinct label is silently skipped, so the would-be match + // never reaches the same-origin check. + assert!(matches!( + validate( + "https://example.com", + &[ + "https://a.com", + "https://b.com", + "https://c.com", + "https://d.com", + "https://e.com", + "https://example.com", + ] + ), + Err(RelatedOriginsError::NoMatchingOrigin) + )); + } + + #[test] + fn higher_cap_allows_sixth_distinct_label_match() { + // With a cap of 6 the previously-blocked sixth label matches. + let res = validate_related_origins( + &caller("https://example.com"), + &origins(&[ + "https://a.com", + "https://b.com", + "https://c.com", + "https://d.com", + "https://e.com", + "https://example.com", + ]), + &MockPublicSuffixList, + MaxRegistrableLabels::extra(1), + ); + assert!(matches!(res, Ok(()))); + } + + #[test] + fn label_cap_allows_repeats_of_seen_label() { + // Once `example` has been recorded, further `example`-label origins + // still proceed to the same-origin check. + assert!(matches!( + validate( + "https://login.example.com", + &[ + "https://a.example.com", + "https://b.example.com", + "https://c.example.com", + "https://d.example.com", + "https://e.example.com", + "https://login.example.com", + ] + ), + Ok(()) + )); + } + + #[test] + fn https_caller_vs_http_listed_rejected() { + // Scheme differs, so the listed http origin is never same-origin. + assert!(matches!( + validate("https://example.com", &["http://example.com"]), + Err(RelatedOriginsError::NoMatchingOrigin) + )); + } + + #[test] + fn unparseable_origin_item_skipped() { + assert!(matches!( + validate("https://example.com", &["not a url", "https://example.com"]), + Ok(()) + )); + } + + #[test] + fn non_https_origin_item_skipped_not_rejected() { + assert!(matches!( + validate( + "https://example.com", + &["data:text/plain,foo", "https://example.com"] + ), + Ok(()) + )); + } + + #[test] + fn unknown_suffix_origin_skipped() { + // `internal.localhost` has no registrable domain in MockPSL. + assert!(matches!( + validate( + "https://example.com", + &["https://internal.localhost", "https://example.com"] + ), + Ok(()) + )); + } + + #[test] + fn bare_etld_origin_skipped() { + // Registrable origin label is None for `co.uk`. + assert!(matches!( + validate( + "https://example.co.uk", + &["https://co.uk", "https://example.co.uk"] + ), + Ok(()) + )); + } + + #[test] + fn no_match_returns_no_matching_origin() { + assert!(matches!( + validate("https://example.com", &["https://elsewhere.com"]), + Err(RelatedOriginsError::NoMatchingOrigin) + )); + } + + #[test] + fn empty_origins_no_match() { + assert!(matches!( + validate("https://example.com", &[]), + Err(RelatedOriginsError::NoMatchingOrigin) + )); + } + + #[test] + fn listed_origin_with_path_still_matches() { + // Same-origin compares (scheme, host, port) only, so a trailing path on + // the listed entry must not block the match. + assert!(matches!( + validate("https://example.com", &["https://example.com/foo"]), + Ok(()) + )); + } + + #[test] + fn ipv6_listed_origin_skipped_no_registrable_label() { + // IPv6 host has no registrable label; the loop skips it. + assert!(matches!( + validate("https://[::1]", &["https://[::1]"]), + Err(RelatedOriginsError::NoMatchingOrigin) + )); + } + + // ---- well-known source fetch/parse ---- + + struct MockHttpClient { + status: u16, + content_type: Option, + body: Vec, + } + + #[async_trait] + impl HttpClient for MockHttpClient { + async fn get(&self, _url: &Url) -> Result>, HttpClientError> { + let mut builder = http::Response::builder().status(self.status); + if let Some(ct) = &self.content_type { + builder = builder.header(http::header::CONTENT_TYPE, ct); + } + Ok(builder.body(self.body.clone()).unwrap()) + } + } + + struct ErrHttpClient(HttpClientError); + + #[async_trait] + impl HttpClient for ErrHttpClient { + async fn get(&self, _url: &Url) -> Result>, HttpClientError> { + Err(self.0.clone()) + } + } + + async fn fetch( + status: u16, + content_type: Option<&str>, + body: &str, + ) -> Result, RelatedOriginsError> { + let source = WellKnownRelatedOriginsSource::from_client(MockHttpClient { + status, + content_type: content_type.map(str::to_owned), + body: body.as_bytes().to_vec(), + }); + source.allowed_origins(&rp("example.com")).await + } + + #[tokio::test] + async fn well_known_returns_origins() { + let res = fetch( + 200, + Some("application/json"), + r#"{"origins":["https://example.com"]}"#, + ) + .await; + assert_eq!(res.unwrap(), vec!["https://example.com".to_string()]); + } + + #[tokio::test] + async fn non_200_status_rejected() { + assert!(matches!( + fetch(404, Some("application/json"), r#"{"origins":[]}"#).await, + Err(RelatedOriginsError::UnexpectedStatus(404)) + )); + } + + #[tokio::test] + async fn wrong_content_type_rejected() { + assert!(matches!( + fetch(200, Some("text/html"), r#"{"origins":[]}"#).await, + Err(RelatedOriginsError::UnexpectedContentType(_)) + )); + } + + #[tokio::test] + async fn missing_content_type_rejected() { + assert!(matches!( + fetch(200, None, r#"{"origins":[]}"#).await, + Err(RelatedOriginsError::UnexpectedContentType(None)) + )); + } + + #[tokio::test] + async fn content_type_with_charset_accepted() { + let res = fetch( + 200, + Some("application/json; charset=utf-8"), + r#"{"origins":["https://elsewhere.com"]}"#, + ) + .await; + assert_eq!(res.unwrap(), vec!["https://elsewhere.com".to_string()]); + } + + #[tokio::test] + async fn content_type_case_insensitive() { + let res = fetch( + 200, + Some("Application/JSON"), + r#"{"origins":["https://example.com"]}"#, + ) + .await; + assert_eq!(res.unwrap(), vec!["https://example.com".to_string()]); + } + + #[tokio::test] + async fn malformed_json_rejected() { + assert!(matches!( + fetch(200, Some("application/json"), "{not json}").await, + Err(RelatedOriginsError::MalformedJson(_)) + )); + } + + #[tokio::test] + async fn non_object_json_rejected() { + assert!(matches!( + fetch(200, Some("application/json"), "[1,2,3]").await, + Err(RelatedOriginsError::MalformedJson(_)) + )); + } + + #[tokio::test] + async fn missing_origins_key_rejected() { + assert!(matches!( + fetch(200, Some("application/json"), "{}").await, + Err(RelatedOriginsError::MalformedDocument(_)) + )); + } + + #[tokio::test] + async fn origins_not_array_rejected() { + assert!(matches!( + fetch( + 200, + Some("application/json"), + r#"{"origins":"https://example.com"}"# + ) + .await, + Err(RelatedOriginsError::MalformedDocument(_)) + )); + } + + #[tokio::test] + async fn origins_array_of_non_strings_rejected() { + assert!(matches!( + fetch(200, Some("application/json"), r#"{"origins":[1,2,3]}"#).await, + Err(RelatedOriginsError::MalformedDocument(_)) + )); + } + + #[tokio::test] + async fn transport_error_propagates_as_http() { + let source = WellKnownRelatedOriginsSource::from_client(ErrHttpClient( + HttpClientError::Transport("simulated".into()), + )); + let res = source.allowed_origins(&rp("example.com")).await; + assert!(matches!( + res, + Err(RelatedOriginsError::Http(HttpClientError::Transport(_))) + )); + } + + #[tokio::test] + async fn static_source_returns_listed_origins() { + let single = StaticRelatedOriginsSource::new("example.com", ["https://app.example.org"]); + assert_eq!( + single.allowed_origins(&rp("example.com")).await.unwrap(), + vec!["https://app.example.org".to_string()] + ); + assert!(single + .allowed_origins(&rp("other.com")) + .await + .unwrap() + .is_empty()); + + let multi = StaticRelatedOriginsSource::from_map( + [("a.com".to_string(), vec!["https://x.org".to_string()])] + .into_iter() + .collect(), + ); + assert_eq!( + multi.allowed_origins(&rp("a.com")).await.unwrap(), + vec!["https://x.org".to_string()] + ); + } +} diff --git a/libwebauthn/src/ops/webauthn/related_origins/reqwest_impl.rs b/libwebauthn/src/ops/webauthn/related_origins/reqwest_impl.rs new file mode 100644 index 00000000..e62bfff6 --- /dev/null +++ b/libwebauthn/src/ops/webauthn/related_origins/reqwest_impl.rs @@ -0,0 +1,118 @@ +//! reqwest-backed [`HttpClient`] and the convenience [`ReqwestRelatedOriginsSource`]. +//! Gated by the `reqwest-related-origins-source` cargo feature. + +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use http::{HeaderMap, Response, StatusCode}; +use reqwest::redirect::Policy; +use reqwest::Client; +use url::Url; + +use super::{HttpClient, HttpClientError, WellKnownRelatedOriginsSource}; + +#[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, + } + } +} + +/// reqwest-backed [`HttpClient`]. Enforces https-only requests and redirects, +/// sends no credentials or Referer, caps the body size, and bounds the request +/// duration. +#[derive(Debug, Clone)] +pub struct ReqwestHttpClient { + client: Client, + max_body_bytes: usize, +} + +impl ReqwestHttpClient { + 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() + }); + // WebAuthn L3 §5.11.1 step 2: no referrer. The `cookies` feature is off, + // so reqwest sends no credentials. + let client = Client::builder() + .https_only(true) + .redirect(redirect_policy) + .referer(false) + .timeout(policy.request_timeout) + .build() + .map_err(|e| HttpClientError::Transport(e.to_string()))?; + Ok(Self { + client, + max_body_bytes: policy.max_body_bytes, + }) + } +} + +#[async_trait] +impl HttpClient for ReqwestHttpClient { + async fn get(&self, url: &Url) -> Result>, HttpClientError> { + let response = self + .client + .get(url.clone()) + .send() + .await + .map_err(|e| HttpClientError::Transport(e.to_string()))?; + let status: StatusCode = response.status(); + let headers: HeaderMap = response.headers().clone(); + + 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| HttpClientError::Transport(e.to_string()))?; + if body.len() + chunk.len() > self.max_body_bytes { + return Err(HttpClientError::BodyTooLarge); + } + body.extend_from_slice(&chunk); + } + + let mut out = Response::new(body); + *out.status_mut() = status; + *out.headers_mut() = headers; + Ok(out) + } +} + +/// reqwest-backed [`RelatedOriginsSource`]: a [`WellKnownRelatedOriginsSource`] +/// over a [`ReqwestHttpClient`]. +/// +/// [`RelatedOriginsSource`]: super::RelatedOriginsSource +pub type ReqwestRelatedOriginsSource = WellKnownRelatedOriginsSource; + +impl WellKnownRelatedOriginsSource { + /// Build with the default [`HttpPolicy`]. + pub fn new() -> Result { + Ok(Self::from_client(ReqwestHttpClient::new()?)) + } + + /// Build with a custom [`HttpPolicy`]. + pub fn with_policy(policy: HttpPolicy) -> Result { + Ok(Self::from_client(ReqwestHttpClient::with_policy(policy)?)) + } +} diff --git a/libwebauthn/src/pin.rs b/libwebauthn/src/pin.rs index 3c022fa2..ffb5b86f 100644 --- a/libwebauthn/src/pin.rs +++ b/libwebauthn/src/pin.rs @@ -1,3 +1,18 @@ +//! PIN and user-verification interaction for CTAP2 authenticators. This module +//! implements the PIN/UV authentication protocols (`PinUvAuthProtocolOne` and +//! `PinUvAuthProtocolTwo`), which perform ECDH key agreement and AES-CBC +//! encryption with HMAC authentication. These are the primitives used to derive +//! a shared secret, encrypt a PIN, and produce the authentication parameters +//! sent during registration and assertion. +//! +//! The public surface exposes [`PinRequestReason`] and [`PinNotSetReason`] so a +//! caller can tell why a PIN is being requested or why setting one failed, and +//! [`PinManagement`] for initiating PIN changes. In normal use a caller does not +//! touch this module directly. PIN requests reach the application through the +//! [`UvUpdate`](crate::UvUpdate) flow in the crate root, delivered as +//! [`PinRequiredUpdate`](crate::PinRequiredUpdate) and +//! [`PinNotSetUpdate`](crate::PinNotSetUpdate). + use std::time::Duration; use aes::cipher::{block_padding::NoPadding, BlockDecryptMut}; diff --git a/libwebauthn/src/proto/mod.rs b/libwebauthn/src/proto/mod.rs index f7080d93..46506aa8 100644 --- a/libwebauthn/src/proto/mod.rs +++ b/libwebauthn/src/proto/mod.rs @@ -1,3 +1,16 @@ +//! The wire protocol layer for FIDO2/CTAP2 and FIDO U2F/CTAP1 authenticators. +//! This module defines how device commands and responses are encoded and +//! decoded: the CBOR request and response structures for CTAP2, the APDU frames +//! for CTAP1, COSE keys, attestation statements, and the protocol status codes +//! in [`CtapError`]. The [`Ctap1`](ctap1::Ctap1) and [`Ctap2`](ctap2::Ctap2) +//! handlers drive these exchanges at the wire level. +//! +//! Alongside the core commands it covers CTAP2 preflight (checking which +//! credentials a device holds before a ceremony), the PIN/UV authentication +//! protocols, client PIN management, biometric enrollment, and on-device +//! credential management. The encodings follow the FIDO Alliance specifications +//! and account for the difference between APDU-based CTAP1 and CBOR-based CTAP2. + mod error; pub mod ctap1; diff --git a/libwebauthn/src/transport/mod.rs b/libwebauthn/src/transport/mod.rs index 1e294b06..8f6c0306 100644 --- a/libwebauthn/src/transport/mod.rs +++ b/libwebauthn/src/transport/mod.rs @@ -1,3 +1,16 @@ +//! Transport layer that carries CTAP messages to FIDO2/WebAuthn authenticators. +//! It abstracts over several physical media: HID (USB), BLE (Bluetooth Low +//! Energy), caBLE (the cable/hybrid mode), and NFC (available when the +//! `nfc-backend-pcsc` or `nfc-backend-libnfc` feature is enabled). The two core +//! abstractions are [`Channel`], an open session with an authenticator that +//! sends and receives CTAP messages, and [`Device`], a discovered authenticator +//! from which a [`Channel`] can be opened. +//! +//! Supporting pieces include the [`Transport`] trait that identifies a specific +//! transport implementation and the [`Ctap2AuthTokenStore`] trait for caching +//! PIN/UV authentication tokens across operations. Each medium provides its own +//! channel and device adapters suited to its protocol and hardware constraints. + pub(crate) mod error; pub mod ble; diff --git a/libwebauthn/src/u2f.rs b/libwebauthn/src/u2f.rs index c448ad64..79481e6b 100644 --- a/libwebauthn/src/u2f.rs +++ b/libwebauthn/src/u2f.rs @@ -1,3 +1,13 @@ +//! High-level FIDO U2F (CTAP1) client API for registering and authenticating +//! against an authenticator device. The [`U2F`] trait is blanket-implemented for +//! any [`Channel`] and offers three async operations: protocol negotiation, +//! registration, and signing. +//! +//! [`RegisterRequest`] and [`SignRequest`] describe the inputs, while the +//! corresponding response types carry the results back to the caller. The trait +//! handles the full lifecycle of a U2F exchange, from negotiating the device +//! protocol through running the request with error handling. + use async_trait::async_trait; use tracing::{instrument, warn}; diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index ca537a06..f4031fec 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -1,3 +1,18 @@ +//! High-level FIDO2 (CTAP2) client API for WebAuthn ceremonies. The [`WebAuthn`] +//! trait is blanket-implemented for any [`Channel`]. Its +//! [`webauthn_make_credential`](WebAuthn::webauthn_make_credential) and +//! [`webauthn_get_assertion`](WebAuthn::webauthn_get_assertion) methods run the +//! full CTAP2 make-credential and get-assertion ceremonies, including the user +//! verification flow, PIN and biometric token handling, credential filtering via +//! preflight, and extension support. When a device does not support FIDO2, the +//! ceremony falls back to U2F (CTAP1). +//! +//! User verification is handled internally by the [`pin_uv_auth_token`] module, +//! which manages PIN and biometric UV, reuse of a cached pinUvAuthToken, shared +//! secret establishment, and the fallback from biometric to PIN. Failures are +//! reported as [`Error`], which distinguishes CTAP protocol errors +//! ([`CtapError`]), transport errors, and platform errors. + pub mod error; pub mod pin_uv_auth_token; diff --git a/libwebauthn/tests/related_origins.rs b/libwebauthn/tests/related_origins.rs new file mode 100644 index 00000000..d7c2b71e --- /dev/null +++ b/libwebauthn/tests/related_origins.rs @@ -0,0 +1,141 @@ +//! End-to-end related-origins integration tests (WebAuthn L3 §5.11). Drives +//! `prepare` with a mock HTTP client behind the well-known source and a tiny +//! inline PSL. No network. + +use async_trait::async_trait; + +use libwebauthn::ops::webauthn::{ + GetAssertionRequest, HttpClient, HttpClientError, MakeCredentialRequest, MaxRegistrableLabels, + OriginValidation, PublicSuffixList, RelatedOrigins, RequestOrigin, RequestSettings, + WellKnownRelatedOriginsSource, +}; + +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 HttpClient for StaticHttp { + async fn get(&self, _: &url::Url) -> Result>, HttpClientError> { + Ok(http::Response::builder() + .status(200) + .header(http::header::CONTENT_TYPE, "application/json") + .body(self.body.as_bytes().to_vec()) + .unwrap()) + } +} + +const MAKE_CREDENTIAL_JSON: &str = r#" +{ + "rp": {"id": "example.org", "name": "example.org"}, + "user": { + "id": "dXNlcmlk", + "name": "alice", + "displayName": "Alice" + }, + "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": "example.org", + "allowCredentials": [ + {"type": "public-key", "id": "bXktY3JlZGVudGlhbC1pZA"} + ], + "userVerification": "preferred" +} +"#; + +// Caller and rp.id sit on different eTLDs (`example.com` vs `example.org`) so the +// related-origins fetch path is actually exercised. +const WELL_KNOWN_BODY: &str = r#"{"origins":["https://app.example.com","https://example.org"]}"#; + +fn settings<'a>( + psl: &'a TestPsl, + source: &'a WellKnownRelatedOriginsSource, +) -> RequestSettings<'a> { + RequestSettings { + origin: OriginValidation::Validate { + public_suffix_list: psl, + related_origins: RelatedOrigins::Enabled { + source, + max_labels: MaxRegistrableLabels::default(), + }, + }, + } +} + +#[tokio::test] +async fn end_to_end_mock_match_via_make_credential() { + let request_origin: RequestOrigin = "https://app.example.com".parse().unwrap(); + let psl = TestPsl; + let source = WellKnownRelatedOriginsSource::from_client(StaticHttp { + body: WELL_KNOWN_BODY, + }); + + let req = MakeCredentialRequest::prepare( + &request_origin, + MAKE_CREDENTIAL_JSON, + &settings(&psl, &source), + ) + .await + .unwrap(); + + assert_eq!(req.relying_party.id, "example.org"); + assert!(req + .client_data_json() + .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.example.com".parse().unwrap(); + let psl = TestPsl; + let source = WellKnownRelatedOriginsSource::from_client(StaticHttp { + body: WELL_KNOWN_BODY, + }); + + let req = GetAssertionRequest::prepare( + &request_origin, + GET_ASSERTION_JSON, + &settings(&psl, &source), + ) + .await + .unwrap(); + + assert_eq!(req.relying_party_id, "example.org"); + assert!(req + .client_data_json() + .contains(r#""origin":"https://app.example.com""#)); +}