diff --git a/.cargo/config.toml b/.cargo/config.toml index 1302091e0..bc8c20d71 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -26,10 +26,10 @@ test_details = "test --target aarch64-apple-darwin" # native crate needs no change here. Axum (native), Cloudflare # (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests # (native) are simply not listed. -build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" -test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" +test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" # --- Axum adapter (native dev server) --- build-axum = "build -p trusted-server-adapter-axum" diff --git a/Cargo.lock b/Cargo.lock index e29380b77..b186502f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5376,6 +5376,8 @@ dependencies = [ "serde_json", "sha2 0.10.9", "trusted-server-core", + "trusted-server-device-fastly", + "trusted-server-geo-fastly", "url", "urlencoding", ] @@ -5489,6 +5491,23 @@ dependencies = [ "web-time", ] +[[package]] +name = "trusted-server-device-fastly" +version = "0.1.0" +dependencies = [ + "fastly", + "trusted-server-core", +] + +[[package]] +name = "trusted-server-geo-fastly" +version = "0.1.0" +dependencies = [ + "error-stack", + "fastly", + "trusted-server-core", +] + [[package]] name = "trusted-server-integration-tests" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 25c367181..00d3d732d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] resolver = "2" members = [ + "crates/device/fastly", + "crates/geo/fastly", "crates/trusted-server-adapter-axum", "crates/trusted-server-adapter-cloudflare", "crates/trusted-server-adapter-fastly", @@ -109,6 +111,8 @@ toml = "1.1" toml_edit = "0.23.10" tower = "0.4" trusted-server-core = { path = "crates/trusted-server-core" } +trusted-server-device-fastly = { path = "crates/device/fastly" } +trusted-server-geo-fastly = { path = "crates/geo/fastly" } trusted-server-js = { path = "crates/trusted-server-js" } trusted-server-openrtb = { path = "crates/trusted-server-openrtb" } url = "2.5.8" diff --git a/crates/device/README.md b/crates/device/README.md new file mode 100644 index 000000000..3aa4cb04a --- /dev/null +++ b/crates/device/README.md @@ -0,0 +1,10 @@ +# Device providers + +Device-detection provider crates live here, one per vendor. The Fastly provider +(`trusted-server-device-fastly`) classifies a request with the host's TLS and +HTTP/2 fingerprints; future vendor providers (for example +`crates/device/`) slot in alongside it. + +The built-in default provider (User-Agent only) ships in `trusted-server-core` +(`ec::device`). Adapters select and inject the vendor provider via +`build_device_provider`. diff --git a/crates/device/fastly/Cargo.toml b/crates/device/fastly/Cargo.toml new file mode 100644 index 000000000..3ff1b6e5e --- /dev/null +++ b/crates/device/fastly/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "trusted-server-device-fastly" +description = "Fastly host device provider exposing opt-in TLS and HTTP/2 signals." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } +fastly = { workspace = true } diff --git a/crates/device/fastly/src/lib.rs b/crates/device/fastly/src/lib.rs new file mode 100644 index 000000000..822e4cc37 --- /dev/null +++ b/crates/device/fastly/src/lib.rs @@ -0,0 +1,97 @@ +//! The Fastly device provider and host-signal capture. +//! +//! [`FastlyDeviceProvider`] strengthens the built-in User-Agent classification +//! with the host's TLS (JA4) and HTTP/2 fingerprints, for deployments on Fastly +//! Compute. It is selected by `[device] provider = "fastly"` and wired in by the +//! Fastly adapter, which injects the request info and the captured host signals. +//! +//! [`FastlyHostSignals`] captures those fingerprints from a live Fastly request +//! (`get_tls_ja4()`, `get_client_h2_fingerprint()`) into owned values, so it can +//! be shared as an injected [`HostSignals`] service that outlives the borrow of +//! the request. Capturing through the SDK is why this crate depends on the +//! `fastly` crate and builds only for the `wasm32-wasip1` target; off-host the +//! accessors return `None`, so classification degrades to User-Agent only. The +//! platform-neutral [`HostSignals`], [`RequestInfo`], and [`DeviceProvider`] +//! traits and the built-in default live in `trusted-server-core`, where the +//! `DeviceSignals` classification logic stays unit-tested. + +use std::sync::Arc; + +use fastly::Request as FastlyRequest; +use trusted_server_core::ec::device::{DeviceProvider, DeviceSignals}; +use trusted_server_core::evidence::{HostSignals, RequestInfo}; + +/// Host-computed client fingerprints captured from a live Fastly request. +/// +/// Reads the TLS JA4 and HTTP/2 fingerprints once through the Fastly SDK and +/// owns them, so the value can be injected as a [`HostSignals`] service that +/// outlives the borrow of the request it was captured from. Off-host the SDK +/// accessors return `None`, so the signals are simply absent. +#[derive(Debug, Clone, Default)] +pub struct FastlyHostSignals { + ja4: Option, + h2: Option, +} + +impl FastlyHostSignals { + /// Builds host signals from already-captured fingerprint values. + /// + /// Use this when the adapter has read the fingerprints once (for example + /// into the client metadata, or from the trusted internal headers the entry + /// point injects) and wants to share them without another SDK call. + #[must_use] + pub fn new(ja4: Option, h2: Option) -> Self { + Self { ja4, h2 } + } + + /// Captures the TLS JA4 and HTTP/2 fingerprints from a live Fastly request. + #[must_use] + pub fn from_request(req: &FastlyRequest) -> Self { + Self { + ja4: req.get_tls_ja4().map(str::to_string), + h2: req.get_client_h2_fingerprint().map(str::to_string), + } + } +} + +impl HostSignals for FastlyHostSignals { + fn ja4(&self) -> Option<&str> { + self.ja4.as_deref() + } + + fn h2(&self) -> Option<&str> { + self.h2.as_deref() + } +} + +/// The Fastly device provider, opt-in via `[device] provider = "fastly"`. +/// +/// Classifies a request with the fingerprint-strengthened +/// [`DeviceSignals::derive`], reading the User-Agent from its injected +/// [`RequestInfo`] and the TLS/HTTP-2 fingerprints from its injected +/// [`HostSignals`], so the browser/bot gate is backed by the live request. +pub struct FastlyDeviceProvider { + host_signals: Arc, +} + +impl FastlyDeviceProvider { + /// Creates the provider with its injected host signals. + #[must_use] + pub fn new(host_signals: Arc) -> Self { + Self { host_signals } + } +} + +impl DeviceProvider for FastlyDeviceProvider { + fn id(&self) -> &'static str { + "fastly" + } + + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive( + request_info.user_agent(), + self.host_signals.ja4(), + self.host_signals.h2(), + ) + } +} diff --git a/crates/edgecookie/README.md b/crates/edgecookie/README.md new file mode 100644 index 000000000..186b8c304 --- /dev/null +++ b/crates/edgecookie/README.md @@ -0,0 +1,9 @@ +# Edge Cookie providers + +Vendor Edge Cookie provider crates live here, one per vendor, for example +`crates/edgecookie/`. Each implements the `EdgeCookieProvider` trait +from `trusted-server-core` and is wired in by an adapter. + +The built-in default provider (HMAC over the client IP) ships in +`trusted-server-core` (`ec::provider`), so no crate is needed for it. This +directory is a placeholder until a vendor provider is added. diff --git a/crates/fastly.toml b/crates/fastly.toml new file mode 100644 index 000000000..718e87ad3 --- /dev/null +++ b/crates/fastly.toml @@ -0,0 +1,13 @@ +# Minimal Viceroy config for testing crates nested one level deeper than the +# adapters (for example `crates/device/fastly` and `crates/geo/fastly`). +# +# The shared wasm test runner in `.cargo/config.toml` starts Viceroy with +# `-C ../../fastly.toml`, resolved from the crate directory. For a two-level +# crate such as `crates/trusted-server-adapter-fastly` that reaches the +# repository root manifest. For a three-level crate it resolves here, to +# `crates/fastly.toml`. These crates' unit tests use no backends, KV stores, +# or dictionaries, only a manifest Viceroy can start from. +manifest_version = 3 +name = "trusted-server-nested-crate-tests" + +[local_server] diff --git a/crates/geo/README.md b/crates/geo/README.md new file mode 100644 index 000000000..f3c5d11fb --- /dev/null +++ b/crates/geo/README.md @@ -0,0 +1,20 @@ +# Geo providers + +Geo and IP-intelligence provider crates live here, one per implementation, each +implementing the `PlatformGeo` trait from `trusted-server-core`: + +- `crates/geo/fastly` (`trusted-server-geo-fastly`) is the host platform geo + provider for Fastly Compute, wrapping Fastly's `geo_lookup`. The Fastly adapter + injects it via `build_geo_provider`. It depends on the Fastly SDK, so it builds + only for `wasm32-wasip1`. +- Vendor geo providers (for example `crates/geo/`) will live alongside + it, one per vendor, selected by the `[geo] provider` setting. + +Whatever the source, a provider returns the same `GeoInfo` coding. The country +is an ISO 3166-1 alpha-2 code (`US`) and the region is the ISO 3166-2 subdivision +code with no country prefix (`CA`), so the Fastly and other providers feed the +same downstream rules without translation. + +The platform-neutral `PlatformGeo` trait and the `DisabledGeo` default (no +location) both live in `trusted-server-core`, so the default deployment resolves +no location until a provider is selected. diff --git a/crates/geo/fastly/Cargo.toml b/crates/geo/fastly/Cargo.toml new file mode 100644 index 000000000..d1e3c4f7c --- /dev/null +++ b/crates/geo/fastly/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "trusted-server-geo-fastly" +description = "Fastly host geo provider backed by the Fastly geolocation API." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } +error-stack = { workspace = true } +fastly = { workspace = true } diff --git a/crates/geo/fastly/src/lib.rs b/crates/geo/fastly/src/lib.rs new file mode 100644 index 000000000..a93f7dd72 --- /dev/null +++ b/crates/geo/fastly/src/lib.rs @@ -0,0 +1,45 @@ +//! The Fastly host geo provider. +//! +//! [`FastlyPlatformGeo`] implements [`PlatformGeo`] using Fastly's `geo_lookup`, +//! for deployments on Fastly Compute. It is the host platform's geo provider, +//! injected by the Fastly adapter via `build_geo_provider`; selecting a vendor +//! geo provider replaces it. +//! +//! Unlike the pure-logic device provider, this crate calls the Fastly geo SDK +//! directly, so it depends on the `fastly` crate and builds only for the +//! `wasm32-wasip1` target. The platform-neutral `PlatformGeo` trait and the +//! `DisabledGeo` default both live in `trusted-server-core`. + +use std::net::IpAddr; + +use error_stack::Report; +use fastly::geo::{Geo, geo_lookup}; +use trusted_server_core::platform::{GeoInfo, PlatformError, PlatformGeo}; + +/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. +fn geo_from_fastly(geo: &Geo) -> GeoInfo { + GeoInfo { + city: geo.city().to_string(), + country: geo.country_code().to_string(), + continent: format!("{:?}", geo.continent()), + latitude: geo.latitude(), + longitude: geo.longitude(), + metro_code: geo.metro_code(), + region: geo.region().map(str::to_string), + asn: None, + } +} + +/// Fastly geo-lookup implementation of [`PlatformGeo`]. +/// +/// The host platform geo provider for Fastly Compute. The adapter injects it via +/// `build_geo_provider`; selecting a vendor geo provider replaces it. +pub struct FastlyPlatformGeo; + +impl PlatformGeo for FastlyPlatformGeo { + fn lookup(&self, client_ip: Option) -> Result, Report> { + Ok(client_ip + .and_then(geo_lookup) + .map(|geo| geo_from_fastly(&geo))) + } +} diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 4b71d07ce..d125c9cd5 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -133,7 +133,7 @@ where F: FnOnce(Arc, RuntimeServices, Request) -> Fut, Fut: Future>>, { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &state.settings); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 45cbedc2c..0009e1953 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -135,6 +135,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index a511daab2..4886bd6e4 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -533,7 +533,10 @@ impl PlatformHttpClient for AxumPlatformHttpClient { /// KV store is [`trusted_server_core::platform::UnavailableKvStore`] — any route /// touching synthetic-ID or consent KV will degrade gracefully. A `warn` log is /// emitted once per process. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); KV_WARNED.get_or_init(|| { log::warn!( @@ -578,9 +581,12 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // API-route integration flow by reusing a poisoned connection after a // truncated POST. Revisit pooling if profiling shows allocation cost. .http_client(Arc::new(AxumPlatformHttpClient::new())) - .geo(Arc::clone(GEO.get_or_init(|| { - Arc::new(AxumPlatformGeo) as Arc - }))) + // Route through the [geo] provider selector like the Fastly adapter, + // so the selector behaves the same on every adapter. + .geo(trusted_server_core::platform::build_geo_provider( + settings, + Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo) as Arc)), + )) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index ed199e6bf..5de96be92 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -33,6 +33,9 @@ fn test_router() -> edgezero_core::router::RouterService { proxy_secret = "integration-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 86ac86987..02cbbe724 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -127,8 +127,8 @@ fn build_state_with_settings( // Per-request RuntimeServices // --------------------------------------------------------------------------- -fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { - build_runtime_services(ctx) +fn build_per_request_services(ctx: &RequestContext, settings: &Settings) -> RuntimeServices { + build_runtime_services(ctx, settings) } /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, @@ -178,7 +178,7 @@ where let s = Arc::clone(&state); let f = f.clone(); Box::pin(async move { - let services = build_per_request_services(&ctx); + let services = build_per_request_services(&ctx, &s.settings); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &s.settings, @@ -376,7 +376,7 @@ fn build_router(state: &Arc) -> RouterService { state: Arc, ctx: RequestContext, ) -> Result { - let services = build_per_request_services(&ctx); + let services = build_per_request_services(&ctx, &state.settings); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 5b605bcff..cb22e2126 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -151,6 +151,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index fff0bfed1..658915b73 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -598,7 +598,10 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { /// Geo information is read from Cloudflare's injected request headers /// (`cf-ipcountry`, etc.) which are present on all plans; headers absent on /// the native host target simply produce empty/zero defaults. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(target_arch = "wasm32")] @@ -633,7 +636,9 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // Geo: read Cloudflare-injected headers — no #[cfg] needed; headers are // simply absent on the native host target, producing Ok(None) from lookup(). - let geo = build_geo(ctx); + // Routed through the [geo] provider selector like the Fastly adapter, so + // the selector behaves the same on every adapter. + let geo = trusted_server_core::platform::build_geo_provider(settings, Arc::new(build_geo(ctx))); RuntimeServices::builder() .config_store(config_store) @@ -641,7 +646,7 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - .geo(Arc::new(geo)) + .geo(geo) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index fb498ce4e..93b0f9db9 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -36,6 +36,9 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -85,6 +88,9 @@ fn make_router() -> RouterService { proxy_secret = "integration-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 47cc609b2..d0a43e670 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -29,6 +29,8 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-device-fastly = { workspace = true } +trusted-server-geo-fastly = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 41e5e65ee..1b5777f78 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -119,7 +119,11 @@ use trusted_server_core::integrations::{ IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, RequestFilterRegistryOutcome, }; -use trusted_server_core::platform::{ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices}; +use trusted_server_core::platform::{ + ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, build_geo_provider, +}; +use trusted_server_device_fastly::FastlyHostSignals; + use trusted_server_core::proxy::{ AssetProxyCachePolicy, handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -261,6 +265,23 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime ..ClientInfo::default() }); + // The TLS JA4 and HTTP/2 fingerprints arrive as trusted internal headers + // injected by the entry point. They build the host-signal service a + // host-signal provider reads; Fastly always supplies the capability, so the + // service is always set even when a request carried no fingerprint. + let tls_ja4 = ctx + .request() + .headers() + .get("x-ts-tls-ja4") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let h2_fingerprint = ctx + .request() + .headers() + .get("x-ts-h2-fingerprint") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) @@ -272,9 +293,13 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .template_assembler(Arc::new(crate::esi_assembly::FastlyTemplateAssembler)) .backend(Arc::new(FastlyPlatformBackend)) .http_client(Arc::new(FastlyPlatformHttpClient)) - .geo(Arc::new(FastlyPlatformGeo)) + .geo(build_geo_provider( + &state.settings, + Arc::new(FastlyPlatformGeo), + )) .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) .client_info(client_info) + .host_signals(Arc::new(FastlyHostSignals::new(tls_ja4, h2_fingerprint))) .build() } @@ -397,7 +422,7 @@ fn build_ec_request_state( req: &Request, ) -> EcRequestState { let device_signals = device_signals_for(req); - let is_real_browser = device_signals.looks_like_browser(); + let is_real_browser = device_signals.looks_like_browser; if !is_real_browser { log::info!( "Bot gate: blocking EC operations (ja4={:?}, platform={:?}, is_mobile={})", @@ -709,7 +734,7 @@ async fn run_named_route( /// response finalization. fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> Response { let device_signals = device_signals_for(&req); - let is_real_browser = device_signals.looks_like_browser(); + let is_real_browser = device_signals.looks_like_browser; let eids_cookie = crate::extract_cookie_value(&req, COOKIE_TS_EIDS); let sharedid_cookie = crate::extract_cookie_value(&req, COOKIE_SHAREDID); @@ -822,7 +847,7 @@ async fn dispatch_fallback( .ec_context .generate_if_needed(&state.settings, ec.kv_graph.as_ref()) { - log::warn!("EC generation failed for publisher proxy: {err:?}"); + log::error!("EC generation failed for publisher proxy: {err:?}"); } // Publisher pages read consent data, so the consent KV store must be @@ -1353,6 +1378,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" [request_signing] @@ -1422,6 +1450,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -1854,6 +1885,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -2493,6 +2527,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -2618,6 +2655,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index a19d0485d..d9b877176 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -5,14 +5,16 @@ use edgezero_adapter_fastly::request::into_core_request; use edgezero_core::body::Body as EdgeBody; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; -use edgezero_core::http::{Request as HttpRequest, Response as HttpResponse}; +use edgezero_core::http::{ + HeaderMap, HeaderValue, Request as HttpRequest, Response as HttpResponse, header, +}; use edgezero_core::response::IntoResponse; use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::cache_policy::EdgeCacheHeader; -use trusted_server_core::ec::device::DeviceSignals; +use trusted_server_core::ec::device::{DeviceProvider, DeviceSignals, build_device_provider}; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::pull_sync::{ @@ -20,12 +22,14 @@ use trusted_server_core::ec::pull_sync::{ }; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; +use trusted_server_core::evidence::{BorrowedRequestInfo, HostSignals}; use trusted_server_core::integrations::RequestFilterEffects; -use trusted_server_core::platform::PlatformGeo as _; use trusted_server_core::platform::RuntimeServices; +use trusted_server_core::platform::build_geo_provider; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; use trusted_server_core::response_privacy::TerminalPrivateResponse; use trusted_server_core::settings::Settings; +use trusted_server_device_fastly::{FastlyDeviceProvider, FastlyHostSignals}; mod app; mod backend; @@ -161,7 +165,42 @@ fn edgezero_main(mut req: FastlyRequest) { // accessors only return real values on the client request, so store them in // request extensions for build_per_request_services and EC bot classification. let client_info = client_info_from_request(&req); - let device_signals = derive_device_signals(&req); + + // Strip and re-inject the TLS JA4 and HTTP/2 fingerprints from the + // authoritative Fastly SDK values, under the same trust model, so the + // EdgeZero app path can build the host-signal service from these internal + // headers (the SDK accessors return real values only on the live client + // request, not on a request rebuilt from EdgeZero HTTP types). + req.remove_header("x-ts-tls-ja4"); + req.remove_header("x-ts-h2-fingerprint"); + // Take ownership before setting: unlike the static TLS protocol/cipher + // names, these accessors borrow the request, which would otherwise conflict + // with the mutable `set_header`. + if let Some(ja4) = req.get_tls_ja4().map(str::to_string) { + req.set_header("x-ts-tls-ja4", ja4); + } + if let Some(h2) = req.get_client_h2_fingerprint().map(str::to_string) { + req.set_header("x-ts-h2-fingerprint", h2); + } + + // Derive device signals from the original FastlyRequest before conversion. + // Fastly's `get_tls_ja4()` and `get_client_h2_fingerprint()` accessors only + // return real values on the client request; a synthetic request rebuilt from + // EdgeZero HTTP types cannot expose them, which would strip the JA4/H2 class + // the EC bot gate needs and misclassify real browsers as bots. Stored in the + // request extensions so `build_ec_request_state` reads the authoritative + // signals instead of re-deriving from the reconstructed request. + // Reuse the settings snapshot already loaded for the app state rather than + // fetching and validating the config-store blob a second time per request. + let device_signals = match settings_snapshot.as_deref() { + Some(settings) => derive_device_signals(settings, &req), + None => { + log::warn!( + "EdgeZero device signals: settings unavailable, using UA-only classification" + ); + DeviceSignals::derive_ua_only(req.get_header_str("user-agent").unwrap_or("")) + } + }; // Dispatch directly through the EdgeZero router without an intermediate // fastly::Response conversion. That preserves duplicate header values such @@ -279,8 +318,12 @@ fn apply_entry_point_finalize_headers( response: &mut HttpResponse, client_ip: Option, ) { + // Route through the [geo] provider selector, so a deployment that opts + // out of geolocation makes no host geo call on the entry-point finalize + // path either. + let geo = build_geo_provider(settings, Arc::new(FastlyPlatformGeo)); let geo_info = resolve_geo_for_response(response, client_ip, |client_ip| { - FastlyPlatformGeo.lookup(client_ip).unwrap_or_else(|e| { + geo.lookup(client_ip).unwrap_or_else(|e| { log::warn!("entry-point geo lookup failed: {e}"); None }) @@ -486,16 +529,32 @@ pub(crate) fn extract_cookie_value(req: &HttpRequest, name: &str) -> Option DeviceSignals { - let ua = req.get_header_str("user-agent").unwrap_or(""); - let ja4 = req.get_tls_ja4(); - let h2_fp = req.get_client_h2_fingerprint(); - - DeviceSignals::derive(ua, ja4, h2_fp) +/// The providers read request data from injected services: device classification +/// reads only the User-Agent, borrowed here through a `BorrowedRequestInfo`, while the +/// Fastly provider also reads the TLS/H2 fingerprints captured into a +/// [`FastlyHostSignals`]. The Fastly provider, and so the fingerprint capture, is +/// built only when selected, so the default request path makes no Fastly-specific +/// fingerprint call. +pub(crate) fn derive_device_signals(settings: &Settings, req: &FastlyRequest) -> DeviceSignals { + let mut headers = HeaderMap::new(); + if let Some(value) = req + .get_header_str(header::USER_AGENT.as_str()) + .and_then(|user_agent| HeaderValue::from_str(user_agent).ok()) + { + headers.insert(header::USER_AGENT, value); + } + let client_ip = req + .get_client_ip_addr() + .map(|ip| ip.to_string()) + .unwrap_or_default(); + let request_info = BorrowedRequestInfo::new(&client_ip, Some(&headers)); + build_device_provider(settings, || { + let host_signals: Arc = Arc::new(FastlyHostSignals::from_request(req)); + Box::new(FastlyDeviceProvider::new(host_signals)) as Box + }) + .detect(&request_info) } #[cfg(test)] @@ -522,6 +581,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 18f309c68..7702386df 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -300,6 +300,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 9e7920e1c..626461e60 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -2,21 +2,19 @@ //! `trusted-server-core::platform`. use std::io::Read as _; -use std::net::IpAddr; use std::sync::Arc; use bytes::Bytes; use edgezero_adapter_fastly::key_value_store::FastlyKvStore; use edgezero_core::key_value_store::KvError; use error_stack::{Report, ResultExt}; -use fastly::geo::{Geo, geo_lookup}; use fastly::{ConfigStore, Request, SecretStore}; use crate::backend::BackendConfig; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, - PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, + ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, + PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, @@ -666,33 +664,12 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { // FastlyPlatformGeo // --------------------------------------------------------------------------- -/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. -/// -/// Shared by `FastlyPlatformGeo::lookup` in `trusted-server-adapter-fastly` so -/// that field mapping is never duplicated. -fn geo_from_fastly(geo: &Geo) -> GeoInfo { - GeoInfo { - city: geo.city().to_string(), - country: geo.country_code().to_string(), - continent: format!("{:?}", geo.continent()), - latitude: geo.latitude(), - longitude: geo.longitude(), - metro_code: geo.metro_code(), - region: geo.region().map(str::to_string), - asn: None, - } -} - -/// Fastly geo-lookup implementation of [`PlatformGeo`]. -pub struct FastlyPlatformGeo; - -impl PlatformGeo for FastlyPlatformGeo { - fn lookup(&self, client_ip: Option) -> Result, Report> { - Ok(client_ip - .and_then(geo_lookup) - .map(|geo| geo_from_fastly(&geo))) - } -} +/// The Fastly host geo provider now lives in its own crate, +/// `trusted-server-geo-fastly`, so every provider implementation sits under +/// `crates//`. It is re-exported here so this module's +/// [`build_runtime_services`] and the adapter's existing call sites keep +/// referring to it through `crate::platform`. +pub(crate) use trusted_server_geo_fastly::FastlyPlatformGeo; /// Extract [`ClientInfo`] from the original Fastly request. /// diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 06bb1a15a..ed45bed90 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -503,7 +503,7 @@ fn build_router(state: &Arc) -> RouterService { let discovery_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &s.settings); let req = ctx.into_request(); Ok(handle_trusted_server_discovery(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -515,7 +515,7 @@ fn build_router(state: &Arc) -> RouterService { let verify_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &s.settings); let req = ctx.into_request(); Ok(handle_verify_signature(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -548,7 +548,7 @@ fn build_router(state: &Arc) -> RouterService { let auction_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &s.settings); // Request normalization (forwarded-header stripping, trusted // Host/scheme/client-IP derivation) is applied centrally by // `NormalizeMiddleware` before this handler runs, so the signed @@ -586,7 +586,7 @@ fn build_router(state: &Arc) -> RouterService { let page_bids_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &s.settings); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( @@ -621,7 +621,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_proxy_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &s.settings); let req = ctx.into_request(); Ok(handle_first_party_proxy(&s.settings, &services, req) .await @@ -634,7 +634,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_click_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &s.settings); let req = ctx.into_request(); Ok(handle_first_party_click(&s.settings, &services, req) .await @@ -647,7 +647,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_sign_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &s.settings); let req = ctx.into_request(); Ok(handle_first_party_proxy_sign(&s.settings, &services, req) .await @@ -664,7 +664,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_rebuild_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &s.settings); let req = ctx.into_request(); Ok( handle_first_party_proxy_rebuild(&s.settings, &services, req) @@ -680,7 +680,7 @@ fn build_router(state: &Arc) -> RouterService { state: Arc, ctx: RequestContext, ) -> Result { - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &state.settings); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 1bcede1fc..3cadf721d 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -178,6 +178,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 492f1a518..f91b1e8e6 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -715,7 +715,10 @@ impl PlatformSecretStore for SpinSecretStoreAdapter { /// before routing. Secrets are read synchronously from Spin component /// variables because Trusted Server's platform secret trait is sync. #[must_use] -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -744,7 +747,13 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - .geo(Arc::new(NullGeo)) + // Routed through the [geo] provider selector like the Fastly adapter, + // so the selector behaves the same on every adapter. Spin has no host + // geo service, so the host default resolves nothing either way. + .geo(trusted_server_core::platform::build_geo_provider( + settings, + Arc::new(NullGeo), + )) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -994,7 +1003,8 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn build_runtime_services_uses_noop_native_stores_without_handles() { let ctx = make_ctx_without_spin_context(); - let services = build_runtime_services(&ctx); + let services = + build_runtime_services(&ctx, &trusted_server_core::settings::Settings::default()); assert!( services.client_info().client_ip.is_none(), diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index f75ea687e..2389ccebc 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -35,6 +35,9 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 818b6fcc5..75f22b864 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -365,6 +365,9 @@ origin_url = "https://origin.example.com" proxy_secret = "change-me-proxy-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "production-secret-key-32-bytes-min" [[handlers]] diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 6ede36e9c..2525a528d 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -154,7 +154,9 @@ mod tests { fn strings_that_look_like_json_scalars_round_trip_as_strings() { let mut original = test_settings(); original.publisher.proxy_secret = Redacted::new("1234567890".to_string()); - original.ec.passphrase = Redacted::new("12345678901234567890123456789012".to_string()); + original.ec.providers.hmac = Some(crate::settings::HmacProviderConfig { + passphrase: Redacted::new("12345678901234567890123456789012".to_string()), + }); original.handlers[0].password = Redacted::new("true".to_string()); let reconstructed = settings_from_config_blob(&envelope_json(&original)) @@ -166,8 +168,22 @@ mod tests { "numeric-looking proxy secret should remain a string" ); assert_eq!( - reconstructed.ec.passphrase.expose(), - original.ec.passphrase.expose(), + reconstructed + .ec + .providers + .hmac + .as_ref() + .expect("should reconstruct the hmac provider") + .passphrase + .expose(), + original + .ec + .providers + .hmac + .as_ref() + .expect("should keep the hmac provider") + .passphrase + .expose(), "numeric-looking passphrase should remain a string" ); assert_eq!( diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index ac0e0c05b..1b3da4785 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -13,8 +13,6 @@ //! endpoint (`/_ts/api/v1/identify`) exposes the EC ID in its response //! body for legitimate JS use cases. -use std::borrow::Cow; - use edgezero_core::body::Body as EdgeBody; use http::{HeaderValue, Response, header}; @@ -24,64 +22,26 @@ use crate::settings::Settings; /// Maximum age for the EC cookie (1 year in seconds). const COOKIE_MAX_AGE: i32 = 365 * 24 * 60 * 60; +/// Maximum length in bytes of an Edge Cookie identifier. +/// +/// A global bound enforced wherever an identifier enters the system (mint, +/// cookie read-back, cookie write), so no provider can emit a value the cookie +/// layer, logs, or the KV key space cannot carry. +pub(crate) const MAX_EC_ID_LEN: usize = 256; + fn is_allowed_ec_id_char(c: char) -> bool { - c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '~') } -// Outbound allowlist for cookie sanitization: permits [a-zA-Z0-9._-] as a -// defense-in-depth backstop when setting the Set-Cookie header. This is -// intentionally broader than the inbound format validator +// Identifier allowlist: [A-Za-z0-9._~-], the cookie-safe alphabet every +// Edge Cookie identifier must fit regardless of which provider minted it. +// This is intentionally broader than the built-in format validator // (`generation::is_valid_ec_id`), which enforces the exact -// `<64-hex>.<6-alphanumeric>` structure and is used to reject untrusted -// request values before they enter the system. +// `<64-hex>.<6-alphanumeric>` structure of the HMAC provider; an opaque +// vendor identifier only has to fit the alphabet and the length bound. #[must_use] pub(crate) fn ec_id_has_only_allowed_chars(ec_id: &str) -> bool { - ec_id.chars().all(is_allowed_ec_id_char) -} - -fn sanitize_ec_id_for_cookie(ec_id: &str) -> Cow<'_, str> { - if ec_id_has_only_allowed_chars(ec_id) { - return Cow::Borrowed(ec_id); - } - - let safe_id = ec_id - .chars() - .filter(|c| is_allowed_ec_id_char(*c)) - .collect::(); - - log::warn!( - "Stripped disallowed characters from EC ID before setting cookie (len {} -> {}); \ - callers should reject invalid request IDs before cookie creation", - ec_id.len(), - safe_id.len(), - ); - - Cow::Owned(safe_id) -} - -/// Returns `true` if every byte in `value` is a valid RFC 6265 `cookie-octet`. -/// An empty string is always rejected. -/// -/// RFC 6265 restricts cookie values to printable US-ASCII excluding whitespace, -/// double-quote, comma, semicolon, and backslash. Rejecting these characters -/// prevents header-injection attacks where a crafted value could append -/// spurious cookie attributes (e.g. `evil; Domain=.attacker.com`). -/// -/// Non-ASCII characters (multi-byte UTF-8) are always rejected because their -/// byte values exceed `0x7E`. -#[must_use] -fn is_safe_cookie_value(value: &str) -> bool { - // RFC 6265 §4.1.1 cookie-octet: - // 0x21 — '!' - // 0x23–0x2B — '#' through '+' (excludes 0x22 DQUOTE) - // 0x2D–0x3A — '-' through ':' (excludes 0x2C comma) - // 0x3C–0x5B — '<' through '[' (excludes 0x3B semicolon) - // 0x5D–0x7E — ']' through '~' (excludes 0x5C backslash, 0x7F DEL) - // All control characters (0x00–0x20) and non-ASCII (0x80+) are also excluded. - !value.is_empty() - && value - .bytes() - .all(|b| matches!(b, 0x21 | 0x23..=0x2B | 0x2D..=0x3A | 0x3C..=0x5B | 0x5D..=0x7E)) + !ec_id.is_empty() && ec_id.len() <= MAX_EC_ID_LEN && ec_id.chars().all(is_allowed_ec_id_char) } /// Formats a `Set-Cookie` header value for the EC cookie. @@ -98,56 +58,48 @@ fn format_set_cookie(domain: &str, value: &str, max_age: i32) -> String { /// /// Per spec §5.2, the EC cookie domain is computed from /// `settings.publisher.domain` (not `cookie_domain`) to ensure the EC -/// cookie is always scoped to the publisher's apex domain. The EC ID is -/// sanitized through a narrow outbound allowlist as a defense-in-depth -/// backstop against header injection. +/// cookie is always scoped to the publisher's apex domain. Callers validate +/// the identifier with [`ec_id_has_only_allowed_chars`] before this point; +/// an identifier is rejected outright rather than rewritten, so the cookie +/// value and the identity-graph key can never silently diverge. #[must_use] pub(crate) fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String { - let safe_id = sanitize_ec_id_for_cookie(ec_id); - format_set_cookie( &settings.publisher.ec_cookie_domain(), - safe_id.as_ref(), + ec_id, COOKIE_MAX_AGE, ) } /// Sets the EC ID cookie on the given response. /// -/// Validates `ec_id` against RFC 6265 `cookie-octet` rules before -/// interpolation. If the value contains unsafe characters (e.g. semicolons), -/// the cookie is not set and a warning is logged. This prevents an attacker -/// from injecting spurious cookie attributes via a controlled ID value. +/// Validates `ec_id` against the identifier alphabet and length bound before +/// interpolation. An identifier that fails validation is rejected and the +/// cookie is not set, with an error logged; the value is never rewritten, so +/// a provider identifier survives byte for byte or not at all. This also +/// prevents an attacker from injecting spurious cookie attributes via a +/// controlled ID value. /// /// `cookie_domain` comes from operator configuration and is considered trusted. -/// -/// # Panics (debug only) -/// -/// Debug-asserts that `ec_id` passes [`super::generation::is_valid_ec_id`] -/// as a defense-in-depth check against cookie injection. pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_id: &str) { - if !is_safe_cookie_value(ec_id) { - log::warn!( - "Rejecting EC ID for Set-Cookie: value of {} bytes contains characters illegal in a cookie value", - ec_id.len() + if !ec_id_has_only_allowed_chars(ec_id) { + log::error!( + "Rejecting EC ID for Set-Cookie: value of {} bytes is empty, over {} bytes, or \ + contains characters outside the identifier alphabet", + ec_id.len(), + MAX_EC_ID_LEN, ); return; } - debug_assert!( - super::generation::is_valid_ec_id(ec_id), - "EC ID must be validated before cookie creation: got '{ec_id}'" - ); - match HeaderValue::from_str(&create_ec_cookie(settings, ec_id)) { Ok(val) => { response.headers_mut().append(header::SET_COOKIE, val); } Err(e) => { - // Unreachable in practice — is_safe_cookie_value and the debug - // assertion above gate the value, and format_set_cookie emits - // only controlled bytes. Logged for defense-in-depth symmetry - // with the rejection logging above. + // Unreachable in practice: the identifier allowlist above gates + // the value, and format_set_cookie emits only controlled bytes. + // Logged for defense-in-depth symmetry with the rejection above. log::warn!("Skipping EC Set-Cookie: invalid header value: {e}"); } } @@ -177,6 +129,28 @@ pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) #[cfg(test)] mod tests { use super::*; + + #[test] + fn identifier_bounds_reject_oversize_and_accept_tilde() { + assert!( + ec_id_has_only_allowed_chars("a.~-_Z9"), + "the cookie-safe alphabet includes the tilde" + ); + assert!( + !ec_id_has_only_allowed_chars(""), + "an empty identifier is rejected" + ); + let oversize = "a".repeat(MAX_EC_ID_LEN + 1); + assert!( + !ec_id_has_only_allowed_chars(&oversize), + "an identifier over the length cap is rejected" + ); + let at_cap = "a".repeat(MAX_EC_ID_LEN); + assert!( + ec_id_has_only_allowed_chars(&at_cap), + "an identifier at the length cap is accepted" + ); + } use crate::test_support::tests::create_test_settings; use http::header; @@ -226,17 +200,21 @@ mod tests { } #[test] - fn create_ec_cookie_sanitizes_disallowed_chars_in_id() { + fn set_ec_cookie_rejects_disallowed_chars_outright() { + // Rejection, never rewriting: an identifier outside the alphabet must + // not produce a cookie at all, so the cookie value and the identity + // graph key can never silently diverge. let settings = create_test_settings(); - let result = create_ec_cookie(&settings, "evil;injected\r\nfoo=bar\0baz"); - let value = result - .strip_prefix(&format!("{COOKIE_TS_EC}=")) - .and_then(|s| s.split_once(';').map(|(v, _)| v)) - .expect("should have cookie value portion"); - - assert_eq!( - value, "evilinjectedfoobarbaz", - "should strip disallowed characters and preserve safe chars" + let mut response = Response::new(EdgeBody::empty()); + set_ec_cookie( + &settings, + &mut response, + "evil;injected +foo=bar", + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "an identifier outside the alphabet should set no cookie" ); } @@ -289,47 +267,6 @@ mod tests { ); } - #[test] - fn is_safe_cookie_value_rejects_empty_string() { - assert!(!is_safe_cookie_value(""), "should reject empty string"); - } - - #[test] - fn is_safe_cookie_value_accepts_valid_ec_id_characters() { - assert!( - is_safe_cookie_value("abcdef0123456789.ABCDEFabcdef"), - "should accept hex digits, dots, and alphanumeric characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_non_ascii() { - assert!( - !is_safe_cookie_value("val\u{fc}e"), - "should reject non-ASCII UTF-8 characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_illegal_characters() { - assert!(!is_safe_cookie_value("val;ue"), "should reject semicolon"); - assert!(!is_safe_cookie_value("val,ue"), "should reject comma"); - assert!( - !is_safe_cookie_value("val\"ue"), - "should reject double-quote" - ); - assert!(!is_safe_cookie_value("val\\ue"), "should reject backslash"); - assert!(!is_safe_cookie_value("val ue"), "should reject space"); - assert!( - !is_safe_cookie_value("val\x00ue"), - "should reject null byte" - ); - assert!( - !is_safe_cookie_value("val\x7fue"), - "should reject DEL character" - ); - } - #[test] fn expire_ec_cookie_sets_max_age_zero() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index fbefa9586..daad89caf 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -1,9 +1,11 @@ //! Device signal derivation for bot detection and browser classification. //! -//! All functions in this module are pure computations — no KV I/O or Fastly -//! SDK calls. The Fastly adapter extracts raw strings from the request -//! (`get_tls_ja4()`, `get_client_h2_fingerprint()`, UA header) and passes -//! them here for classification. +//! The [`DeviceSignals`] derivation here is pure computation, with no KV I/O or +//! Fastly SDK calls. A [`DeviceProvider`] is wired by dependency injection: its +//! constructor takes the services it reads (the [`RequestInfo`] for the +//! User-Agent, and on a fingerprinting host the +//! [`HostSignals`](crate::evidence::HostSignals) for the TLS/H2 fingerprints), +//! and classifies the request from them. //! //! # Signals //! @@ -18,6 +20,8 @@ use sha2::{Digest as _, Sha256}; use super::kv_types::KvDevice; +use crate::evidence::RequestInfo; +use crate::settings::Settings; /// Device signals derived from a single request. /// @@ -33,18 +37,48 @@ pub struct DeviceSignals { /// Coarse OS family: `"mac"`, `"windows"`, `"ios"`, `"android"`, /// `"linux"`. pub platform_class: Option, - /// SHA256 prefix (12 hex chars) of the raw H2 SETTINGS string. + /// SHA256 prefix (12 hex chars) of raw H2 SETTINGS fingerprint. pub h2_fp_hash: Option, /// `true` = known browser, `false` = known bot, `None` = unknown. pub known_browser: Option, + /// Whether the request looks like a real browser, used to gate Edge Cookie + /// writes. Computed by the producing provider: the built-in provider uses a + /// User-Agent-only heuristic, while the Fastly provider strengthens it with + /// the TLS/H2 fingerprints. + pub looks_like_browser: bool, } impl DeviceSignals { - /// Derives all device signals from raw request data. + /// Derives device signals from the User-Agent alone, with no + /// host-specific TLS or HTTP/2 evidence. + /// + /// This is the default path: it touches no Fastly-specific API, so a + /// default deployment stays host-neutral. `ja4_class` and `h2_fp_hash` are + /// left absent, and the browser/bot decision uses a User-Agent-only + /// heuristic (`looks_like_browser_from_ua`). + #[must_use] + pub fn derive_ua_only(ua: &str) -> Self { + let platform_class = parse_platform_class(ua); + let looks_like_browser = looks_like_browser_from_ua(ua, platform_class.as_deref()); + + Self { + is_mobile: parse_is_mobile(ua), + ja4_class: None, + platform_class, + h2_fp_hash: None, + known_browser: None, + looks_like_browser, + } + } + + /// Derives device signals from the User-Agent strengthened with the + /// host's TLS/H2 fingerprints. /// /// `ua` is the `User-Agent` header value. `ja4` is the full JA4 hash /// from `req.get_tls_ja4()`. `h2_fp` is the raw H2 SETTINGS string - /// from `req.get_client_h2_fingerprint()`. + /// from `req.get_client_h2_fingerprint()`. These fingerprints are + /// host-specific (Fastly), so only the opt-in Fastly device provider + /// uses this path; the browser/bot gate then requires a TLS fingerprint. #[must_use] pub fn derive(ua: &str, ja4: Option<&str>, h2_fp: Option<&str>) -> Self { let is_mobile = parse_is_mobile(ua); @@ -52,6 +86,12 @@ impl DeviceSignals { let platform_class = parse_platform_class(ua); let h2_fp_hash = h2_fp.map(compute_h2_fp_hash); let known_browser = evaluate_known_browser(ja4_class.as_deref(), h2_fp_hash.as_deref()); + // The fingerprint-strengthened gate: a real browser produces a valid + // TLS fingerprint and a recognizable UA platform. Raw HTTP clients + // (curl, Python requests, Go net/http, headless scrapers) lack one or + // both. This is intentionally aimed at filtering obvious missing-signal + // traffic, not at resisting deliberate JA4 + UA spoofing. + let looks_like_browser = ja4_class.is_some() && platform_class.is_some(); Self { is_mobile, @@ -59,32 +99,10 @@ impl DeviceSignals { platform_class, h2_fp_hash, known_browser, + looks_like_browser, } } - /// Returns `true` when the request looks like a real browser. - /// - /// Checks for the presence of recognizable signals rather than matching - /// against a hardcoded signal allowlist. Real browsers always - /// produce a valid TLS probabilistic identifier (`ja4_class`) and a recognizable UA - /// platform string (`platform_class`). Raw HTTP clients (curl, Python - /// requests, Go net/http, headless scrapers) typically lack one or both. - /// - /// # Threat model - /// - /// This heuristic is intentionally aimed at filtering obvious - /// missing-signal traffic, not at resisting deliberate spoofing. A bot - /// that forges plausible JA4 and UA inputs may still pass; deeper - /// consistency checks can be added later if product requirements demand - /// stronger spoof resistance. - /// - /// `known_browser` is still computed and stored on [`KvDevice`] for - /// analytics but does not gate identity operations. - #[must_use] - pub fn looks_like_browser(&self) -> bool { - self.ja4_class.is_some() && self.platform_class.is_some() - } - /// Converts these signals into a [`KvDevice`] for KV storage. #[must_use] pub fn to_kv_device(&self) -> KvDevice { @@ -98,6 +116,80 @@ impl DeviceSignals { } } +/// A strategy for classifying a request into [`DeviceSignals`]. +/// +/// Implementations are selected by configuration. The built-in +/// [`BuiltinDeviceProvider`] is the default; a deployment can switch to another +/// provider without changing call sites. +/// +/// These signals serve identity gating and bot detection, not bid enrichment. +/// [`DeviceSignals`] deliberately carries only the coarse browser and bot +/// classification the Edge Cookie gate needs, not a full device-detection +/// result such as make, model, OS version, or screen size. A richer device +/// model for the ad request is a separate concern. +pub trait DeviceProvider: Send + Sync { + /// Returns the stable identifier for this provider, used in configuration + /// and logs. + fn id(&self) -> &'static str; + + /// Classifies the request into [`DeviceSignals`], reading the request data + /// it needs from the [`RequestInfo`] passed borrowed at call time (plus any + /// host signals injected into its constructor). + /// + /// Device signals gate identity operations and must always yield a value, + /// so this is infallible: a provider that cannot determine a signal returns + /// the unknown variant rather than failing the request. + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals; +} + +/// The built-in device provider, the default. +/// +/// Derives [`DeviceSignals`] from the User-Agent alone via +/// [`DeviceSignals::derive_ua_only`], touching no host-specific API. It reads +/// only [`RequestInfo::user_agent`] and never a host fingerprint, so the default +/// request path stays host-neutral. +#[derive(Debug, Default)] +pub struct BuiltinDeviceProvider; + +impl BuiltinDeviceProvider { + /// Creates the built-in provider. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl DeviceProvider for BuiltinDeviceProvider { + fn id(&self) -> &'static str { + "builtin" + } + + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive_ua_only(request_info.user_agent()) + } +} + +/// Selects the device provider named by the `[device] provider` selector. +/// +/// Returns the built-in User-Agent-only provider unless the `fastly` selector is +/// set, in which case it builds the host-specific provider through the +/// `build_fastly` factory the adapter supplies. The factory runs only when that +/// provider is selected, so the default path captures no host fingerprints (see +/// [`BuiltinDeviceProvider`] for the host-neutral default). A +/// selected-but-unknown provider is rejected at startup by +/// [`DeviceConfig::validate_provider_selection`](crate::settings::DeviceConfig::validate_provider_selection), +/// so this falls back to the built-in provider for that case. +#[must_use] +pub fn build_device_provider( + settings: &Settings, + build_fastly: impl FnOnce() -> Box, +) -> Box { + match settings.device.provider_key() { + "fastly" => build_fastly(), + _ => Box::new(BuiltinDeviceProvider::new()), + } +} + /// Device is a desktop (confirmed via UA platform token). const MOBILE_DESKTOP: u8 = 0; /// Device is a mobile (confirmed via UA mobile token). @@ -146,11 +238,58 @@ fn parse_platform_class(ua: &str) -> Option { None } -/// Extracts Section 1 from a full JA4 string. +/// Decides whether a request looks like a real browser from the User-Agent +/// alone, with no TLS or HTTP/2 evidence. +/// +/// A real browser sends the `Mozilla/` token every major engine still emits and +/// a recognizable platform string (so `platform_class` is present), and is not +/// an obvious bot or command-line client. Raw HTTP clients (curl, Python +/// requests, Go net/http) carry no platform token, so they fail the +/// `platform_class` check; declared crawlers are caught by [`looks_like_bot_ua`]. +/// +/// # Threat model +/// +/// This is the default, host-neutral gate. It filters obvious non-browser +/// traffic but does not resist a bot that forges a complete browser +/// User-Agent. The opt-in Fastly device provider strengthens the gate with the +/// TLS/H2 fingerprints for deployments that need it. +#[must_use] +fn looks_like_browser_from_ua(ua: &str, platform_class: Option<&str>) -> bool { + platform_class.is_some() && ua.contains("Mozilla/") && !looks_like_bot_ua(ua) +} + +/// Returns `true` when the User-Agent declares a known bot, crawler, or +/// non-browser HTTP client. +/// +/// Matches common self-identifying markers case-insensitively. The `bot` marker +/// covers `Googlebot`, `bingbot`, and similar; the library markers cover HTTP +/// clients that set a recognizable platform token. +#[must_use] +fn looks_like_bot_ua(ua: &str) -> bool { + const BOT_MARKERS: &[&str] = &[ + "bot", + "crawl", + "spider", + "slurp", + "curl", + "wget", + "python-requests", + "go-http-client", + "okhttp", + "java/", + "headlesschrome", + "phantomjs", + "scrapy", + ]; + let lower = ua.to_ascii_lowercase(); + BOT_MARKERS.iter().any(|marker| lower.contains(marker)) +} + +/// Extracts Section 1 from a full JA4 fingerprint. /// /// JA4 format: `section1_section2_section3` separated by underscores. /// Section 1 identifies browser family (cipher count, extension count, -/// ALPN) without uniquely identifying a device. +/// ALPN) without uniquely fingerprinting a device. /// /// Returns `None` if the input is empty or has no underscore-delimited /// section. @@ -164,7 +303,7 @@ fn extract_ja4_section1(full_ja4: &str) -> Option { } /// Computes a 12-hex-char prefix of the SHA256 hash of the raw H2 -/// SETTINGS string. +/// SETTINGS fingerprint string. /// /// The raw string looks like `"1:65536;2:0;4:6291456;6:262144"`. #[must_use] @@ -175,7 +314,7 @@ fn compute_h2_fp_hash(raw_h2_fp: &str) -> String { hex::encode(&digest[..6]) } -/// Known browser signal allowlist. +/// Known browser fingerprint allowlist. /// /// Each entry is `(ja4_class, h2_fp_prefix, known_browser)`. /// `h2_fp_prefix` is the raw H2 SETTINGS string (not the hash) — we @@ -191,7 +330,7 @@ const KNOWN_BROWSERS: &[(&str, &str, bool)] = &[ ("t13d1717h2", "1:65536;2:0;4:131072;5:16384", true), ]; -/// Returns H2 SETTINGS hashes for the known browser allowlist. +/// Returns H2 fingerprint hashes for the known browser allowlist. /// /// Computed once on first call and cached via `OnceLock`. fn known_browser_h2_hashes() -> &'static Vec<(&'static str, String, bool)> { @@ -230,6 +369,7 @@ fn evaluate_known_browser(ja4_class: Option<&str>, h2_fp_hash: Option<&str>) -> #[cfg(test)] mod tests { use super::*; + use crate::evidence::OwnedRequestInfo; // Chrome Mac UA const CHROME_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ @@ -366,7 +506,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Chrome signals should be recognized" + "Chrome fingerprint should be recognized" ); } @@ -377,7 +517,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Safari signals should be recognized" + "Safari fingerprint should be recognized" ); } @@ -388,7 +528,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Firefox signals should be recognized" + "Firefox fingerprint should be recognized" ); } @@ -523,7 +663,7 @@ mod tests { Some("1:65536;2:0;4:6291456;6:262144"), ); assert!( - signals.looks_like_browser(), + signals.looks_like_browser, "Chrome/Mac should look like a browser" ); } @@ -537,8 +677,8 @@ mod tests { Some("99:99;88:88"), ); assert!( - signals.looks_like_browser(), - "unknown signal combination with valid JA4 + platform should pass" + signals.looks_like_browser, + "unknown fingerprint with valid JA4 + platform should pass" ); assert_eq!(signals.known_browser, None, "should not match allowlist"); } @@ -547,17 +687,17 @@ mod tests { fn looks_like_browser_rejects_bot() { let signals = DeviceSignals::derive(BOT_UA, None, None); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "bot with no JA4 and no platform should be rejected" ); } #[test] fn looks_like_browser_rejects_missing_ja4() { - // Real UA but no JA4 value (e.g. HTTP/1.1 or missing SDK support) + // Real UA but no TLS fingerprint (e.g. HTTP/1.1 or missing SDK support) let signals = DeviceSignals::derive(CHROME_MAC_UA, None, Some("1:65536")); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "missing JA4 should be rejected even with valid UA" ); } @@ -567,8 +707,128 @@ mod tests { // Has JA4 but unrecognizable UA let signals = DeviceSignals::derive(BOT_UA, Some("t13d1516h2_abc_def"), None); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "unrecognizable UA should be rejected even with JA4" ); } + + #[test] + fn derive_ua_only_accepts_real_browsers_without_fingerprints() { + for ua in [ + CHROME_MAC_UA, + SAFARI_IOS_UA, + FIREFOX_MAC_UA, + CHROME_ANDROID_UA, + CHROME_WINDOWS_UA, + ] { + let signals = DeviceSignals::derive_ua_only(ua); + assert!( + signals.looks_like_browser, + "a real browser UA should pass the UA-only gate: {ua}" + ); + assert!( + signals.ja4_class.is_none() && signals.h2_fp_hash.is_none(), + "the UA-only path must not record any TLS/H2 evidence" + ); + } + } + + #[test] + fn derive_ua_only_rejects_bots_and_http_clients() { + // Declared crawlers and CLI/library clients must not pass the gate. + for ua in [ + BOT_UA, + "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", + "curl/8.4.0", + "python-requests/2.31.0", + "Go-http-client/2.0", + "", + ] { + assert!( + !DeviceSignals::derive_ua_only(ua).looks_like_browser, + "a non-browser client should fail the UA-only gate: {ua:?}" + ); + } + } + + #[test] + fn derive_ua_only_rejects_a_browser_ua_that_declares_a_bot() { + // Newer crawlers send a full browser UA with a platform token; the bot + // marker must still reject them. + let googlebot_mobile = "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) \ + AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36 \ + (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"; + assert!( + !DeviceSignals::derive_ua_only(googlebot_mobile).looks_like_browser, + "a browser-shaped UA declaring Googlebot should be rejected" + ); + } + + #[test] + fn builtin_device_provider_is_ua_only() { + let provider = BuiltinDeviceProvider::new(); + assert_eq!(provider.id(), "builtin"); + + // The built-in provider classifies from the User-Agent in the request + // info passed to `detect` alone, recording no host fingerprint. + let request_info = request_info_with_ua(CHROME_MAC_UA); + let signals = provider.detect(&request_info); + assert_eq!( + signals, + DeviceSignals::derive_ua_only(CHROME_MAC_UA), + "the built-in provider should classify from the User-Agent only" + ); + assert!( + signals.ja4_class.is_none(), + "the built-in provider must not record a JA4 class" + ); + } + + /// Builds request info carrying the given User-Agent, for provider tests. + fn request_info_with_ua(user_agent: &str) -> OwnedRequestInfo { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::USER_AGENT, + http::HeaderValue::from_str(user_agent) + .expect("should build a valid User-Agent header"), + ); + OwnedRequestInfo::new(String::new(), headers) + } + + /// A stand-in for the host-specific provider the adapter injects, so the + /// selection logic can be tested in core without the Fastly provider crate. + struct StubFastlyProvider; + + impl DeviceProvider for StubFastlyProvider { + fn id(&self) -> &'static str { + "fastly" + } + + fn detect(&self, _request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive_ua_only("") + } + } + + #[test] + fn build_device_provider_defaults_to_builtin_and_selects_injected() { + // The default selector returns the built-in provider, ignoring the + // injected candidate. + let settings = crate::settings::Settings::default(); + let default = build_device_provider(&settings, || { + Box::new(StubFastlyProvider) as Box + }); + assert_eq!(default.id(), "builtin", "no selector should be UA-only"); + + // The `fastly` selector returns the provider the adapter's factory builds. + let mut fastly = crate::settings::Settings::default(); + fastly.device.provider = Some("fastly".to_owned()); + let selected = build_device_provider(&fastly, || { + Box::new(StubFastlyProvider) as Box + }); + assert_eq!( + selected.id(), + "fastly", + "the fastly selector should use the injected provider" + ); + } } diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index a553bb7a7..d09d8097a 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -8,12 +8,11 @@ use std::collections::HashSet; use edgezero_core::body::Body as EdgeBody; use http::Response; -use super::consent::{ec_consent_granted, ec_consent_withdrawn}; use crate::settings::Settings; use super::EcContext; +use super::consent::ec_consent_withdrawn; use super::cookies::{expire_ec_cookie, set_ec_cookie}; -use super::generation::is_valid_ec_id; use super::kv::KvIdentityGraph; use super::log_id; use super::prebid_eids::ingest_eid_cookies; @@ -29,12 +28,16 @@ const EC_RESPONSE_HEADERS: &[&str] = &[ /// Finalizes EC response behavior for all routes. /// -/// Applies withdrawal handling, last-seen updates, cookie reconciliation, -/// Prebid EID ingestion, and cookie writes for new EC generation. +/// Applies the resolved consent gate, last-seen updates, cookie +/// reconciliation, Prebid EID ingestion, and cookie writes for new EC generation. /// -/// On consent withdrawal, the browser response clears the EC cookie -/// immediately and the EC identity-graph KV tombstone is the authoritative -/// revocation marker. There is no separate consent KV store to clean up. +/// When the request carries an explicit withdrawal signal (a storage opt-out or +/// a TCF record refusing storage) and the client presented a cookie, the browser +/// response clears the EC cookie immediately and the EC identity-graph KV +/// tombstone is the authoritative revocation marker. A request that is merely +/// not permitted (pre-consent or fail-closed) strips EC response headers but +/// leaves an already-issued cookie intact. There is no separate consent KV +/// store to clean up. /// /// `eids_cookie` should be the raw value of the `ts-eids` cookie extracted /// from the request *before* routing consumes it. @@ -47,19 +50,27 @@ pub fn ec_finalize_response( sharedid_cookie: Option<&str>, response: &mut Response, ) { - let consent_allows_ec = ec_consent_granted(ec_context.consent()); - let consent_withdrawn = ec_consent_withdrawn(ec_context.consent()); - - if !consent_allows_ec { - // Always strip EC-specific response headers when consent is not - // currently usable for this request. This covers both explicit - // revocation and fail-closed cases such as missing geo or undecodable - // consent input. + // Apply any response headers the active provider asked for during + // generation (for example to request more client evidence). This is empty + // unless a provider produced headers, so it is safe on every path. + for (name, value) in ec_context.response_headers() { + response.headers_mut().insert(name, value.clone()); + } + + let ec_permitted = ec_context.ec_allowed(); + + if !ec_permitted { + // Always strip EC-specific response headers when EC is not permitted for + // this request, covering both an explicit withdrawal and fail-closed + // cases such as missing geo or undecodable consent input. clear_ec_headers_on_response(response, Some(registry)); // Only expire the browser cookie and tombstone the identity-graph row - // when the request carries an explicit withdrawal signal. - if consent_withdrawn && ec_context.cookie_was_present() { + // when the request carries an explicit withdrawal signal. A pre-consent + // or fail-closed state (consent is simply not granted) strips headers + // but must not destroy an already-issued identifier, or a returning user + // would be permanently withdrawn before they ever get to consent. + if ec_consent_withdrawn(ec_context.consent()) && ec_context.cookie_was_present() { expire_ec_cookie(settings, response); // Compute once for the authoritative identity-graph tombstones. @@ -82,8 +93,8 @@ pub fn ec_finalize_response( return; } - // Returning user: consent is granted and EC came from request. - if ec_context.ec_was_present() && !ec_context.ec_generated() && consent_allows_ec { + // Returning user: EC is permitted and came from the request. + if ec_context.ec_was_present() && !ec_context.ec_generated() && ec_permitted { if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) { ingest_eid_cookies(eids_cookie, sharedid_cookie, ec_id, graph, registry); } @@ -156,13 +167,13 @@ fn withdrawal_ec_ids(ec_context: &EcContext) -> HashSet { let mut hashes = HashSet::new(); if let Some(cookie_ec_id) = ec_context.existing_cookie_ec_id() - && is_valid_ec_id(cookie_ec_id) + && ec_context.accepts_id(cookie_ec_id) { hashes.insert(cookie_ec_id.to_owned()); } if let Some(active_ec_id) = ec_context.ec_value() - && is_valid_ec_id(active_ec_id) + && ec_context.accepts_id(active_ec_id) { hashes.insert(active_ec_id.to_owned()); } @@ -219,6 +230,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, jurisdiction: Jurisdiction, + ec_allowed: bool, ) -> EcContext { let consent = ConsentContext { jurisdiction, @@ -232,6 +244,7 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, ) } @@ -241,6 +254,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> EcContext { EcContext::new_for_test_with_cookie( ec_value.map(str::to_owned), @@ -248,6 +262,7 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, ) } @@ -275,7 +290,14 @@ mod tests { #[test] fn withdrawal_ec_ids_returns_cookie_ec_only_when_active_missing() { let cookie_ec = sample_ec_id("cook1e"); - let ec_context = make_context(None, Some(&cookie_ec), true, false, Jurisdiction::Unknown); + let ec_context = make_context( + None, + Some(&cookie_ec), + true, + false, + Jurisdiction::Unknown, + false, + ); let ids = withdrawal_ec_ids(&ec_context); @@ -295,6 +317,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let ids = withdrawal_ec_ids(&ec_context); @@ -313,6 +336,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let ids = withdrawal_ec_ids(&ec_context); @@ -331,6 +355,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let ids = withdrawal_ec_ids(&ec_context); @@ -402,7 +427,7 @@ mod tests { ..Default::default() }; let ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", "stale"); set_header(&mut response, "x-ts-eids", "[]"); @@ -459,6 +484,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -493,6 +519,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -527,6 +554,7 @@ mod tests { false, true, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -554,7 +582,7 @@ mod tests { #[test] fn finalize_denied_without_cookie_is_noop() { let settings = create_test_settings(); - let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown); + let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown, false); let mut response = empty_response(); let test_registry = PartnerRegistry::empty(); @@ -579,7 +607,12 @@ mod tests { } #[test] - fn finalize_unknown_jurisdiction_strips_headers_without_expiring_cookie() { + fn finalize_not_permitted_without_withdrawal_keeps_cookie() { + // When EC is not permitted (here a fail-closed unknown jurisdiction with + // no geo) but the request carries no explicit withdrawal signal, the + // response strips EC headers yet must leave an already-issued cookie + // intact. A pre-consent or transient fail-closed request must not + // permanently withdraw a returning user before they get to consent. let settings = create_test_settings(); let ec_id = sample_ec_id("unk001"); let ec_context = make_context( @@ -588,6 +621,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", &ec_id); @@ -606,15 +640,78 @@ mod tests { assert!( get_header(&response, "x-ts-ec").is_none(), - "should strip EC header when consent cannot be verified" + "should strip EC header when EC is not permitted" ); assert!( get_header(&response, "x-ts-eids").is_none(), - "should strip EID header when consent cannot be verified" + "should strip EID header when EC is not permitted" + ); + assert!( + get_header(&response, "set-cookie").is_none(), + "a not-permitted request without a withdrawal signal should keep the cookie" + ); + } + + #[test] + fn set_ec_cookie_on_response_writes_the_ts_ec_cookie() { + // The positive case: when an EC value is present, the finalize path + // writes the ts-ec cookie to the browser, carrying the EC id. + let settings = create_test_settings(); + let ec_id = sample_ec_id("setck1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + true, + ); + let mut response = empty_response(); + + set_ec_cookie_on_response(&settings, &ec_context, &mut response); + + let set_cookie = + get_header_str(&response, "set-cookie").expect("an EC value should write a Set-Cookie"); + assert!( + set_cookie.contains("ts-ec=") && set_cookie.contains(&ec_id), + "should write the ts-ec cookie carrying the EC id, got: {set_cookie}" ); + } + + #[test] + fn closed_consent_gate_writes_no_ec_cookie() { + // The gate: with the consent gate closed (ec_allowed = false), no + // ts-ec cookie is written, even when an EC value and a generated flag are + // present. The consent gate is what suppresses the cookie. + let settings = create_test_settings(); + let ec_id = sample_ec_id("gated1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + false, + ); + let mut response = empty_response(); + + // Pass a KV graph so the missing-graph guard cannot be the reason the + // cookie is suppressed; the closed gate must be doing the work. + let kv = KvIdentityGraph::failing("test_store"); + let test_registry = PartnerRegistry::empty(); + ec_finalize_response( + &settings, + &ec_context, + Some(&kv), + &test_registry, + None, + None, + &mut response, + ); + assert!( get_header(&response, "set-cookie").is_none(), - "should not expire the cookie without an explicit withdrawal signal" + "a closed consent gate must not write a ts-ec cookie" ); } } diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs index 2924b7692..a3bfb1dd6 100644 --- a/crates/trusted-server-core/src/ec/generation.rs +++ b/crates/trusted-server-core/src/ec/generation.rs @@ -11,7 +11,6 @@ use rand::Rng; use sha2::Sha256; use crate::error::TrustedServerError; -use crate::settings::Settings; type HmacSha256 = Hmac; @@ -81,19 +80,39 @@ fn generate_random_suffix(length: usize) -> String { /// /// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails pub fn generate_ec_id( - settings: &Settings, + passphrase: &str, client_ip: &str, ) -> Result> { - let mut mac = HmacSha256::new_from_slice(settings.ec.passphrase.expose().as_bytes()) - .change_context(TrustedServerError::EdgeCookie { + generate_hmac_ec_id(passphrase, &[client_ip]) +} + +/// Mints an Edge Cookie identifier as HMAC-SHA256 over the given parts plus a +/// random suffix, in the `{64hex}.{6alnum}` format. +/// +/// The parts are joined with a unit separator (`\u{1f}`), which cannot appear in +/// a client IP, User-Agent, JA4, or HTTP/2 fingerprint, so distinct part lists +/// cannot collide. A provider that derives identity from several request signals +/// (for example a Fastly provider over JA4, H2, IP, and UA) passes them as +/// separate parts. Each part must be pre-normalized by the caller. +/// +/// # Errors +/// +/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +pub fn generate_hmac_ec_id( + passphrase: &str, + parts: &[&str], +) -> Result> { + let mut mac = HmacSha256::new_from_slice(passphrase.as_bytes()).change_context( + TrustedServerError::EdgeCookie { message: "Failed to create HMAC instance".to_string(), - })?; - mac.update(client_ip.as_bytes()); + }, + )?; + // A unit separator cannot occur in any part, so distinct lists never collide. + mac.update(parts.join("\u{1f}").as_bytes()); let hmac_hash = hex::encode(mac.finalize().into_bytes()); - // Append random 6-character alphanumeric suffix for additional uniqueness. - let random_suffix = generate_random_suffix(6); - let ec_id = format!("{hmac_hash}.{random_suffix}"); + // Append a random 6-character alphanumeric suffix for additional uniqueness. + let ec_id = format!("{hmac_hash}.{}", generate_random_suffix(6)); log::trace!("Generated fresh EC ID: {}", super::log_id(&ec_id)); @@ -175,7 +194,39 @@ mod tests { use super::*; use std::net::{Ipv4Addr, Ipv6Addr}; - use crate::test_support::tests::create_test_settings; + const TEST_PASSPHRASE: &str = "test-secret-key-32-bytes-minimum"; + + #[test] + fn generate_hmac_ec_id_is_stable_per_parts_and_collision_resistant() { + // The 64-char hex prefix is HMAC over the parts and is stable for the + // same parts; the random suffix varies, so compare prefixes only. + let prefix = |parts: &[&str]| { + generate_hmac_ec_id(TEST_PASSPHRASE, parts) + .expect("should generate") + .split('.') + .next() + .expect("should have a prefix") + .to_owned() + }; + + assert_eq!( + prefix(&["a", "b"]), + prefix(&["a", "b"]), + "the same parts should yield the same stable prefix" + ); + assert_ne!( + prefix(&["a", "b"]), + prefix(&["a", "c"]), + "different parts should yield a different prefix" + ); + // The unit separator prevents a join collision: ["a", "b"] must not hash + // the same as ["ab"]. + assert_ne!( + prefix(&["a", "b"]), + prefix(&["ab"]), + "the separator should prevent ['a','b'] colliding with ['ab']" + ); + } #[test] fn normalize_ipv4_unchanged() { @@ -215,8 +266,7 @@ mod tests { #[test] fn generate_produces_valid_format() { - let settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, "192.168.1.1").expect("should generate EC ID"); + let ec_id = generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate EC ID"); assert!( is_valid_ec_id(&ec_id), "should match EC ID format: {{64hex}}.{{6alnum}}, got: {ec_id}" @@ -225,10 +275,10 @@ mod tests { #[test] fn generate_same_ip_produces_consistent_hash_prefix() { - let settings = create_test_settings(); - let first = generate_ec_id(&settings, "192.168.1.1").expect("should generate first EC ID"); + let first = + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate first EC ID"); let second = - generate_ec_id(&settings, "192.168.1.1").expect("should generate second EC ID"); + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate second EC ID"); assert_eq!( ec_hash(&first), diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 6ca251905..eeadaa290 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -10,7 +10,6 @@ use http::{Request, Response, StatusCode}; use url::Url; use super::auth::authenticate_bearer; -use super::consent::ec_consent_granted; use crate::error::TrustedServerError; use crate::openrtb::{Eid, Uid}; use crate::settings::Settings; @@ -62,7 +61,7 @@ pub fn handle_identify( ); }; - if !ec_consent_granted(ec_context.consent()) { + if !ec_context.ec_allowed() { return json_response_with_origin( StatusCode::FORBIDDEN, &serde_json::json!({ "consent": "denied" }), @@ -332,7 +331,6 @@ fn apply_cors_headers(response: &mut Response, origin: &str) { #[cfg(test)] mod tests { use super::*; - use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; use crate::ec::registry::PartnerRegistry; use crate::redacted::Redacted; @@ -352,13 +350,12 @@ mod tests { ); } - fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { let consent = ConsentContext { - jurisdiction, source: ConsentSource::Cookie, ..ConsentContext::default() }; - EcContext::new_for_test(ec_value.map(str::to_owned), consent) + EcContext::new_for_test_gated(ec_value.map(str::to_owned), consent, ec_allowed) } fn make_test_partner(source_domain: &str, api_token: &str) -> EcPartner { @@ -472,7 +469,7 @@ mod tests { .uri("https://edge.test-publisher.com/identify") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -514,7 +511,7 @@ mod tests { .header("authorization", "Bearer wrong-token") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -539,7 +536,7 @@ mod tests { .header("authorization", format!("Bearer {VALID_API_TOKEN}")) .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::Unknown, None); + let ec_context = make_ec_context(false, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct denied response"); @@ -573,7 +570,7 @@ mod tests { .header("authorization", format!("Bearer {VALID_API_TOKEN}")) .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response"); @@ -599,7 +596,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build test request"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct degraded identify response"); @@ -652,7 +649,7 @@ mod tests { .header("origin", "https://evil.example") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct forbidden response"); @@ -678,7 +675,7 @@ mod tests { .header("origin", "https://www.test-publisher.com") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response with CORS headers"); diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 840ce90d3..d1f774b66 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -45,6 +45,7 @@ pub mod kv_backend; pub mod kv_types; pub mod partner; pub mod prebid_eids; +pub mod provider; pub mod pull_sync; pub mod rate_limiter; pub mod registry; @@ -60,6 +61,8 @@ pub fn log_id(ec_id: &str) -> String { format!("{prefix}\u{2026}") } +use std::sync::Arc; + use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; use error_stack::Report; @@ -70,10 +73,12 @@ use crate::constants::COOKIE_TS_EC; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; +use crate::evidence::BorrowedRequestInfo; use crate::geo::GeoInfo; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; +use provider::{EdgeCookieProvider, GeneratedEdgeCookie, IdentityInput, build_provider}; use self::kv::KvIdentityGraph; use self::kv_types::KvEntry; @@ -126,7 +131,15 @@ fn request_ec_id_if_allowed(value: &str, source: &str) -> Option { /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails pub fn get_ec_id(req: &Request) -> Result, Report> { let parsed = parse_ec_from_request(req)?; - let ec_id = parsed.cookie_ec.filter(|v| is_valid_ec_id(v)); + // Accept the coded form (any provider's `{code}~value` within the global + // identifier bounds) and the legacy bare HMAC form. Provider-aware + // ownership lives in `EcContext`; this helper only reads the string. + let ec_id = parsed + .cookie_ec + .filter(|v| match provider::split_provider_code(v) { + (Some(_), value) => !value.is_empty() && cookies::ec_id_has_only_allowed_chars(v), + (None, value) => is_valid_ec_id(value), + }); if let Some(ref id) = ec_id { log::trace!("Existing EC ID found: {}", log_id(id)); } @@ -152,6 +165,10 @@ pub struct EcContext { ec_generated: bool, /// The consent context for this request. consent: ConsentContext, + /// Whether Edge Cookie creation is allowed for this request. Resolved once + /// at construction from the consent context and read via + /// [`ec_allowed`](Self::ec_allowed). + ec_allowed: bool, /// The normalized client IP, captured early before the request body /// is consumed. `None` when the platform cannot determine client IP. client_ip: Option, @@ -161,6 +178,27 @@ pub struct EcContext { /// Set via [`EcContext::set_device_signals`] before /// [`EcContext::generate_if_needed`] is called. device_signals: Option, + /// The selected Edge Cookie provider (built-in or injected), built once at + /// construction. Core asks it whether an identifier is well formed + /// ([`accepts_id`](crate::ec::provider::EdgeCookieProvider::accepts_id)) so + /// an opaque vendor identifier round-trips through read-back and withdrawal + /// instead of being dropped by the built-in shape check. `None` when no + /// provider is configured. + selected_provider: Option>, + /// A snapshot of the request evidence a provider reads at generation time: + /// the request headers (so a provider can read cookies and client hints), and + /// the URL path and query string (so it can read request parameters). + /// Captured once at construction, and only when a provider is configured, so + /// a deployment with no Edge Cookie provider clones nothing. A provider reads + /// these through [`RequestInfo`](crate::evidence::RequestInfo) at generate + /// time. + request_headers: http::HeaderMap, + request_path: String, + request_query: String, + /// Response headers a provider asked to set, captured during + /// [`EcContext::generate_if_needed`] and applied to the response by EC + /// finalization. Empty for providers that set no headers. + response_headers: Vec<(http::HeaderName, http::HeaderValue)>, } impl EcContext { @@ -200,13 +238,49 @@ impl EcContext { ) -> Result> { let parsed = parse_ec_from_request(req)?; - let ec_value = parsed.cookie_ec.clone().filter(|v| is_valid_ec_id(v)); + // Build the selected provider once. It is used here to decide whether + // the incoming cookie value is a usable identifier. Building it needs + // no request data, so nothing is cloned from the request. + let host_signals = services.host_signals(); + let ec_provider = services.ec_provider(); + let selected_provider: Option> = + build_provider(&settings.ec, host_signals, ec_provider)?.map(Arc::from); + + // Read back an existing identifier only when the selected provider + // accepts its shape, so an opaque vendor identifier (for example a signed + // envelope) round-trips instead of being silently dropped by the built-in + // shape check. With no provider configured, Trusted Server is stateless: + // an existing identifier is treated as absent so it is never used or + // egressed, while the raw cookie value stays available to withdrawal + // handling below. + let ec_value = parsed.cookie_ec.clone().filter(|v| { + selected_provider + .as_ref() + .is_some_and(|selected| provider::provider_owns_id(selected.as_ref(), v)) + }); let ec_was_present = ec_value.is_some(); if let Some(ref id) = ec_value { log::trace!("Existing EC ID found: {}", log_id(id)); } + // Snapshot the request evidence a provider reads at generation time (the + // headers, so it can read cookies and client hints, and the URL path and + // query, so it can read request parameters). Capture only when a provider + // is configured and no identifier already exists, so a no-provider + // deployment and a returning visitor clone nothing. Generation runs after + // the request body may be consumed, so the snapshot is owned. + let (request_headers, request_path, request_query) = + if selected_provider.is_some() && ec_value.is_none() { + ( + req.headers().clone(), + req.uri().path().to_owned(), + req.uri().query().unwrap_or_default().to_owned(), + ) + } else { + (http::HeaderMap::new(), String::new(), String::new()) + }; + // Capture the client IP from platform services (normalized). let client_ip = services .client_info() @@ -223,11 +297,20 @@ impl EcContext { kv_store: None, }); + // Gate Edge Cookie creation and use on the request's consent context + // (jurisdiction and consent signals). With no provider selected nothing + // may mint or use an identifier, so the gate is closed rather than open + // by default. Downstream consumers read the stored result via + // [`EcContext::ec_allowed`] rather than re-deriving it. + let ec_allowed = selected_provider + .as_ref() + .is_some_and(|_| consent::ec_consent_granted(&consent)); + log::info!( - "EC context: present={}, cookie_present={}, consent_allowed={}, jurisdiction={}", + "EC context: present={}, cookie_present={}, ec_allowed={}, jurisdiction={}", ec_was_present, parsed.cookie_ec.is_some(), - consent::ec_consent_granted(&consent), + ec_allowed, consent.jurisdiction, ); @@ -237,9 +320,15 @@ impl EcContext { ec_was_present, ec_generated: false, consent, + ec_allowed, client_ip, geo_info: geo_info.cloned(), device_signals: None, + selected_provider, + request_headers, + request_path, + request_query, + response_headers: Vec::new(), }) } @@ -265,22 +354,105 @@ impl EcContext { return Ok(()); } - if !consent::ec_consent_granted(&self.consent) { + // A deployment with no provider selected is stateless: nothing to + // generate, and not an error. Reuse the provider built at read time + // rather than building it again. + let Some(ec_provider) = self.selected_provider.clone() else { + log::trace!("EC generation skipped: no Edge Cookie provider configured"); + return Ok(()); + }; + + if !self.ec_allowed { log::info!( - "EC generation skipped: consent not granted (jurisdiction={})", + "EC generation skipped: EC creation not permitted (jurisdiction={})", self.consent.jurisdiction, ); return Ok(()); } - let client_ip = self.client_ip.as_deref().ok_or_else(|| { - Report::new(TrustedServerError::EdgeCookie { + // EC generation needs the client IP; checked after the cheap skip + // guards so a stateless deployment on a host with no client IP does not + // log spurious errors. The provider reads it borrowed at generate time + // (see [`generate_with_provider`]), so nothing is cloned here. + if self.client_ip.is_none() { + return Err(Report::new(TrustedServerError::EdgeCookie { message: "Client IP required for EC generation but unavailable".to_owned(), - }) - })?; + })); + } + + self.generate_with_provider(ec_provider.as_ref(), settings, kv) + } - let ec_id = generation::generate_ec_id(settings, client_ip)?; - log::info!("Generated new EC ID: {}", log_id(&ec_id)); + /// Derives and commits an EC identifier using a specific provider. + /// + /// Split out of [`generate_if_needed`](Self::generate_if_needed) so the + /// provider is supplied explicitly: the configured path builds it from + /// settings, and tests pass one in to observe the [`IdentityInput`] a + /// provider receives. The request evidence captured at read time (client + /// IP, headers, and the URL path and query) is passed borrowed through + /// [`RequestInfo`](crate::evidence::RequestInfo), so a provider can read + /// cookies and request parameters at generate time; the built-ins read + /// only the client IP. The skip guards (existing EC, consent gate) + /// stay in [`generate_if_needed`](Self::generate_if_needed). + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when the client IP is + /// unavailable, the provider fails to derive an identifier, or persisting a + /// generated identifier to the KV identity graph fails. + fn generate_with_provider( + &mut self, + ec_provider: &dyn EdgeCookieProvider, + settings: &Settings, + kv: Option<&KvIdentityGraph>, + ) -> Result<(), Report> { + let input = IdentityInput { + consent: Some(&self.consent), + }; + // Pass the request evidence captured at read time, borrowed: the client + // IP, the request headers (so a provider reads cookies and client hints), + // and the URL path and query (so it reads request parameters). A built-in + // provider reads only the client IP; a vendor provider reads what it + // needs through [`RequestInfo`]. + let request_info = BorrowedRequestInfo::new( + self.client_ip.as_deref().unwrap_or_default(), + Some(&self.request_headers), + ) + .with_request_target(&self.request_path, &self.request_query); + let generated: GeneratedEdgeCookie = ec_provider.generate(&request_info, &input)?; + // Capture any response headers the provider asked for, even when it + // produced no identifier (for example while it still needs more client + // evidence). EC finalization applies them to the response. + self.response_headers = generated.response_headers; + let generated_id = generated + .id + .map(|value| crate::ec::provider::apply_provider_code(ec_provider, &value)); + let Some(ec_id) = generated_id else { + log::info!( + "EC generation produced no identifier (provider={}); proceeding without an EC", + ec_provider.id(), + ); + return Ok(()); + }; + // Enforce the global identifier bounds at mint: the cookie-safe + // alphabet and the length cap apply to every provider, so no + // implementation can emit a value the cookie layer or the identity + // graph cannot carry. Rejection is loud and total; the identifier is + // never rewritten. + if !ec_id_has_only_allowed_chars(&ec_id) { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Provider `{}` produced an identifier that is empty, over {} bytes, or outside the cookie-safe alphabet", + ec_provider.id(), + cookies::MAX_EC_ID_LEN, + ), + })); + } + log::info!( + "Generated new EC ID (provider={}): {}", + ec_provider.id(), + log_id(&ec_id), + ); self.ec_value = Some(ec_id); self.ec_generated = true; @@ -297,7 +469,13 @@ impl EcContext { .as_ref() .map(DeviceSignals::to_kv_device); - if let Err(err) = graph.create_or_revive(ec_value, &entry) { + // Key the identity graph by the provider's canonical form of the + // identifier, so equivalent representations of one identity share + // one row. The built-in normalization lowercases only the HMAC + // hash segment; an opaque vendor provider overrides it to the + // identity function. + let kv_key = crate::ec::provider::provider_kv_key(ec_provider, ec_value); + if let Err(err) = graph.create_or_revive(&kv_key, &entry) { log::error!( "Failed to create or revive EC entry for id '{}' after generation: {err:?}", log_id(ec_value), @@ -319,6 +497,21 @@ impl EcContext { self.ec_value.as_deref() } + /// Returns whether `value` is a well-formed identifier for the selected + /// provider. + /// + /// Lets core validate a cookie or active identifier (for example before + /// withdrawing it) through the provider that issued it, rather than assuming + /// the built-in shape. Falls back to the built-in shape when no provider is + /// configured. + #[must_use] + pub(crate) fn accepts_id(&self, value: &str) -> bool { + self.selected_provider.as_ref().map_or_else( + || is_valid_ec_id(value), + |provider| provider::provider_owns_id(provider.as_ref(), value), + ) + } + /// Returns whether the `ts-ec` cookie was present on the incoming request. #[must_use] pub fn cookie_was_present(&self) -> bool { @@ -348,7 +541,8 @@ impl EcContext { /// /// Allows handlers to apply query-param fallback consent for the current /// request only when pre-routing consent extraction produced an empty - /// context. + /// context. Mutations do not re-derive [`ec_allowed`](Self::ec_allowed), + /// which is resolved once at construction. pub fn consent_mut(&mut self) -> &mut ConsentContext { &mut self.consent } @@ -365,6 +559,14 @@ impl EcContext { self.device_signals = Some(signals); } + /// Returns the response headers a provider asked to set during + /// [`generate_if_needed`](Self::generate_if_needed). Empty unless a provider + /// produced any. + #[must_use] + pub fn response_headers(&self) -> &[(http::HeaderName, http::HeaderValue)] { + &self.response_headers + } + /// Returns the device signals, if set. #[must_use] pub fn device_signals(&self) -> Option<&DeviceSignals> { @@ -383,10 +585,13 @@ impl EcContext { self.geo_info.as_ref() } - /// Returns whether EC creation is permitted by consent for this request. + /// Returns whether Edge Cookie creation is allowed for this request. + /// + /// Resolved once at construction from the consent context (see + /// [`consent::ec_consent_granted`]). #[must_use] pub fn ec_allowed(&self) -> bool { - consent::ec_consent_granted(&self.consent) + self.ec_allowed } /// Returns the existing EC cookie value for revocation handling. @@ -399,35 +604,51 @@ impl EcContext { self.cookie_ec_value.as_deref() } - /// Returns `true` when the request carried a cookie EC and the selected - /// active EC differs from that cookie value. - #[must_use] - pub fn cookie_differs_from_active_ec(&self) -> bool { - matches!( - (self.cookie_ec_value.as_deref(), self.ec_value.as_deref()), - (Some(cookie), Some(active)) if cookie != active - ) - } - /// Returns the stable EC hash prefix from the active EC value. #[must_use] pub fn ec_hash(&self) -> Option<&str> { self.ec_value.as_deref().map(generation::ec_hash) } - /// Creates a test-only `EcContext` with explicit field values. + /// Creates a test-only `EcContext` whose creation gate is derived from the + /// consent context, matching the production construction path. + /// + /// Use [`new_for_test_gated`](Self::new_for_test_gated) when a test needs + /// an explicit gate. #[cfg(test)] #[must_use] pub fn new_for_test(ec_value: Option, consent: ConsentContext) -> Self { + let ec_allowed = consent::ec_consent_granted(&consent); + Self::new_for_test_gated(ec_value, consent, ec_allowed) + } + + /// Creates a test-only `EcContext` with an explicit creation gate. + /// + /// `ec_allowed` stands in for the gating decision the production path + /// resolves at construction, so a test can exercise the gate-open and + /// gate-closed branches directly. + #[cfg(test)] + #[must_use] + pub fn new_for_test_gated( + ec_value: Option, + consent: ConsentContext, + ec_allowed: bool, + ) -> Self { Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, ec_generated: false, consent, + ec_allowed, client_ip: None, geo_info: None, device_signals: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } @@ -439,15 +660,22 @@ impl EcContext { consent: ConsentContext, client_ip: Option, ) -> Self { + let ec_allowed = consent::ec_consent_granted(&consent); Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, ec_generated: false, consent, + ec_allowed, client_ip, geo_info: None, device_signals: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } @@ -461,6 +689,7 @@ impl EcContext { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> Self { Self { ec_value, @@ -468,9 +697,15 @@ impl EcContext { ec_was_present, ec_generated, consent, + ec_allowed, client_ip: None, geo_info: None, device_signals: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } } @@ -494,6 +729,8 @@ pub(crate) fn current_timestamp() -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::ec::provider::ProviderCode; + use crate::evidence::{OwnedRequestInfo, RequestInfo}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; @@ -512,6 +749,488 @@ mod tests { format!("{}.{suffix}", prefix_char.repeat(64)) } + /// A provider that records the `Cookie` header from the request info passed + /// to `generate`, so a test can prove request cookies reach a provider (a + /// client that stores values in cookies relies on this). + #[derive(Debug)] + struct CookieCapturingProvider { + seen_cookie: std::sync::Mutex>, + } + + impl EdgeCookieProvider for CookieCapturingProvider { + fn id(&self) -> &'static str { + "cookie-capturing" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0cc") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let cookie = request_info.header("cookie").map(ToOwned::to_owned); + *self.seen_cookie.lock().expect("should lock seen cookie") = cookie; + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn a_provider_reads_request_cookies_from_the_request_info() { + // RequestInfo contract: a provider given request info that carries + // headers can read request cookies through it (a client that stores + // values in cookies relies on this). The organic generate path passes + // no header snapshot; a caller that has headers supplies them. + let mut headers = http::HeaderMap::new(); + headers.insert( + "cookie", + "client-id=abc123; ts-ec=xyz" + .parse() + .expect("should build a valid cookie header"), + ); + let request_info = OwnedRequestInfo::new("203.0.113.7".to_owned(), headers); + let provider = CookieCapturingProvider { + seen_cookie: std::sync::Mutex::new(None), + }; + + provider + .generate(&request_info, &IdentityInput::default()) + .expect("generation should succeed"); + + assert_eq!( + provider + .seen_cookie + .lock() + .expect("should lock seen cookie") + .as_deref(), + Some("client-id=abc123; ts-ec=xyz"), + "the provider should read the request cookies from the request info" + ); + } + + /// A provider whose identifiers are opaque and deliberately not the + /// built-in HMAC shape (no dot, mixed case), modeling a vendor identifier + /// such as a signed envelope. It accepts any of its own non-empty + /// identifiers. + #[derive(Debug)] + struct OpaqueIdProvider; + + impl EdgeCookieProvider for OpaqueIdProvider { + fn id(&self) -> &'static str { + "opaque" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0op") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + /// A geo that resolves to the non-regulated jurisdiction (US, no region), + /// so the consent gate is open and generation runs in provider tests. + fn non_regulated_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + } + } + + #[test] + fn read_from_request_round_trips_an_opaque_provider_identifier() { + use crate::platform::test_support::noop_services_with_ec_provider; + + // A vendor identifier that is deliberately not the built-in HMAC shape + // (no dot, mixed case) — the exact value the built-in check would drop. + const OPAQUE_ID: &str = "AbC123opaqueEnvelopeValueXYZ"; + const CODED_ID: &str = "t0op~AbC123opaqueEnvelopeValueXYZ"; + + let mut settings = create_test_settings(); + settings.ec.provider = Some("opaque".to_owned()); + let cookie = format!("ts-ec={CODED_ID}"); + let req = create_test_request(&[("cookie", &cookie)]); + + // With the opaque provider injected, its `accepts_id` governs read-back, + // so the identifier survives verbatim. + let services = noop_services_with_ec_provider(Arc::new(OpaqueIdProvider)); + let ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + Some(CODED_ID), + "an opaque provider identifier should round-trip through read-back verbatim" + ); + let _ = OPAQUE_ID; + + // Control: with the provider selected but not injected by the adapter, + // the request fails loudly instead of silently running stateless with + // the identifier dropped. + let err = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect_err("a selected but uninjected provider should fail the request"); + assert!( + err.to_string().contains("opaque"), + "the error should name the selected provider, got: {err}" + ); + + // Control: with no provider selected at all, the identifier is treated + // as absent, so a stateless deployment never uses or egresses it. + let mut stateless = create_test_settings(); + stateless.ec.provider = None; + stateless.ec.providers.hmac = None; + let ec_without = EcContext::read_from_request(&stateless, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec_without.ec_value(), + None, + "with no provider selected, an existing identifier is treated as absent" + ); + assert!( + !ec_without.ec_allowed(), + "with no provider selected, the gate stays closed" + ); + } + + /// A provider that records the request query parameter `id` and the `Cookie` + /// header it is given at generate time, proving request evidence (parameters + /// and cookies) reaches a provider through the organic generate path. + #[derive(Debug, Default)] + struct EvidenceCapturingProvider { + seen: std::sync::Mutex>, + } + + impl EdgeCookieProvider for EvidenceCapturingProvider { + fn id(&self) -> &'static str { + "evidence" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0ev") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let query_id = request_info.query_param("id").unwrap_or_default(); + let cookie = request_info.header("cookie").unwrap_or_default().to_owned(); + *self.seen.lock().expect("should lock seen evidence") = Some((query_id, cookie)); + Ok(GeneratedEdgeCookie { + id: Some("evidence-ec".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[test] + fn generate_passes_request_parameters_and_cookies_to_the_provider() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let provider = Arc::new(EvidenceCapturingProvider::default()); + let mut settings = create_test_settings(); + settings.ec.provider = Some("evidence".to_owned()); + + // A request carrying a query parameter and a (non-EC) cookie, with no + // existing `ts-ec` cookie so the generate path runs. + let req = Request::builder() + .method("GET") + .uri("http://example.com/page?id=abc123&debug=1") + .header("cookie", "client-id=xyz789") + .body(EdgeBody::empty()) + .expect("should build request"); + + let services = noop_services_with_ec_provider(provider.clone()); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, None) + .expect("should run generation"); + + let seen = provider + .seen + .lock() + .expect("should lock seen evidence") + .clone(); + assert_eq!( + seen, + Some(("abc123".to_owned(), "client-id=xyz789".to_owned())), + "the provider should read the request query parameter and cookies at generate time" + ); + assert_eq!( + ec.ec_value(), + Some("t0ev~evidence-ec"), + "the identifier the provider minted should be committed under its code" + ); + } + + /// A provider that mints an opaque, mixed-case, non-HMAC identifier at the + /// edge, so a test can prove such an identifier persists to the KV identity + /// graph under its own value as the key. + #[derive(Debug)] + struct ServerOpaqueProvider; + + impl EdgeCookieProvider for ServerOpaqueProvider { + fn id(&self) -> &'static str { + "server-opaque" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0so") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("Opaque_EC_Value_MixedCase_123".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + #[test] + fn generate_persists_an_opaque_identifier_to_kv_under_its_own_key() { + use crate::platform::test_support::noop_services_with_ec_provider; + + const OPAQUE: &str = "t0so~Opaque_EC_Value_MixedCase_123"; + + let mut settings = create_test_settings(); + settings.ec.provider = Some("server-opaque".to_owned()); + let services = noop_services_with_ec_provider(Arc::new(ServerOpaqueProvider)); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + + // No existing cookie, so the edge mints and persists. + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, Some(&graph)) + .expect("should generate and persist"); + + assert_eq!( + ec.ec_value(), + Some(OPAQUE), + "the opaque identifier should be minted" + ); + + // The entry is stored under the full identifier verbatim. + assert!( + graph.get(OPAQUE).expect("kv get should succeed").is_some(), + "the entry should exist under the opaque identifier key" + ); + + // A lowercased key must miss, proving the key preserves case rather than + // being lowercased like the built-in HMAC form (the clash this guards). + assert!( + graph + .get(&OPAQUE.to_lowercase()) + .expect("kv get should succeed") + .is_none(), + "the KV key must be case-sensitive and verbatim, not lowercased" + ); + } + + /// A provider that mints an identifier outside the cookie-safe alphabet, + /// to prove core rejects it at mint rather than rewriting it. + #[derive(Debug)] + struct IllegalIdProvider; + + impl EdgeCookieProvider for IllegalIdProvider { + fn id(&self) -> &'static str { + "illegal" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0il") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("bad;value with spaces".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + } + + #[test] + fn generate_rejects_an_identifier_outside_the_cookie_safe_alphabet() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some("illegal".to_owned()); + let services = noop_services_with_ec_provider(Arc::new(IllegalIdProvider)); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + + let err = ec + .generate_if_needed(&settings, None) + .expect_err("an identifier outside the alphabet should be rejected at mint"); + assert!( + err.to_string().contains("illegal"), + "the error should name the provider, got: {err}" + ); + assert_eq!( + ec.ec_value(), + None, + "no identifier should be committed after a mint rejection" + ); + } + + /// A provider whose identifier normalizes to a distinct canonical form, to + /// prove the identity graph is keyed by the canonical form. + #[derive(Debug)] + struct CanonicalizingProvider; + + impl EdgeCookieProvider for CanonicalizingProvider { + fn id(&self) -> &'static str { + "canonical" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0ca") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("MiXeD.CaseId".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_ascii_lowercase() + } + } + + #[test] + fn generate_keys_the_identity_graph_by_the_normalized_identifier() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some("canonical".to_owned()); + let services = noop_services_with_ec_provider(Arc::new(CanonicalizingProvider)); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, Some(&graph)) + .expect("should generate and persist"); + + assert_eq!( + ec.ec_value(), + Some("t0ca~MiXeD.CaseId"), + "the cookie value keeps the provider's exact identifier under its code" + ); + assert!( + graph + .get("t0ca~mixed.caseid") + .expect("should read the graph") + .is_some(), + "the graph row should be keyed by the code plus the canonical form" + ); + } + + #[test] + fn hmac_mints_a_coded_identifier_and_dual_reads_the_legacy_bare_form() { + let settings = create_test_settings(); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let services = crate::platform::test_support::noop_services_with_client_ip( + std::net::IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7)), + ); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, None) + .expect("should generate"); + let minted = ec.ec_value().expect("should mint an identifier"); + assert!( + minted.starts_with("hmac~"), + "a fresh HMAC identifier should carry the hmac code, got {minted}" + ); + + // A deployed pre-envelope cookie (bare form) still reads back, so the + // migration does not orphan existing identities. + let legacy = format!("{}.ABC123", "a".repeat(64)); + let cookie = format!("ts-ec={legacy}"); + let req = create_test_request(&[("cookie", &cookie)]); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + Some(legacy.as_str()), + "the legacy bare form should dual-read under the hmac provider" + ); + } + + #[test] + fn a_foreign_provider_code_is_treated_as_absent() { + // An identifier carrying another provider's code must never be adopted + // by the selected provider, so switching providers cannot silently mix + // identity populations. + let settings = create_test_settings(); + let foreign = format!("zz00~{}.ABC123", "a".repeat(64)); + let cookie = format!("ts-ec={foreign}"); + let req = create_test_request(&[("cookie", &cookie)]); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + None, + "an identifier with a foreign provider code is not this provider's" + ); + } + #[test] fn read_from_request_ignores_header_ec() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs new file mode 100644 index 000000000..1d7fb7de7 --- /dev/null +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -0,0 +1,645 @@ +//! Edge Cookie identity providers. +//! +//! An [`EdgeCookieProvider`] derives an Edge Cookie identifier. Providers are +//! wired by dependency injection: a provider's constructor takes the services it +//! needs (for example [`RequestInfo`] for the client IP, or [`HostSignals`] for +//! the TLS/HTTP-2 fingerprints)//! (the adapter, through [`build_provider`]) supplies instances per request. A +//! provider that needs a service the host does not supply cannot be built, so +//! the request stops rather than silently degrading. +//! +//! The provider is selected by configuration, with no default. [`HmacProvider`] +//! is the built-in server-side implementation that derives the identifier from +//! the client IP using HMAC, the behavior Trusted Server has always shipped. + +use std::sync::Arc; + +use error_stack::Report; + +use crate::consent::ConsentContext; +use crate::error::TrustedServerError; +use crate::evidence::{HostSignals, RequestInfo}; +use crate::redacted::Redacted; +use crate::settings::Ec; + +use super::generation; + +/// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. +/// +/// Request data (client IP, User-Agent, headers, host signals) reaches a +/// provider through the services injected into its constructor, not through this +/// struct. This carries only the per-request gating context a provider may read +/// for behavior beyond gating. The gate has already confirmed Edge Cookie +/// storage is allowed before `generate` is called. +#[derive(Default)] +pub struct IdentityInput<'a> { + /// The request's consent context, when available, for provider-specific + /// logic. The core gates generation before calling the provider, so a + /// provider reads this only to forward or record consent. [`HmacProvider`] + /// ignores it. + pub consent: Option<&'a ConsentContext>, +} + +/// The outcome of [`EdgeCookieProvider::generate`]. +/// +/// Carries the derived identifier, if any, and any response headers the provider +/// needs set on the outbound response. +#[derive(Debug, Default)] +pub struct GeneratedEdgeCookie { + /// The derived Edge Cookie identifier, or `None` when the provider produced + /// none for this request. + pub id: Option, + + /// Response headers the provider needs set on the outbound response, for + /// example to request additional client evidence on later requests. Empty + /// for providers that set no headers, such as [`HmacProvider`]. + pub response_headers: Vec<(http::HeaderName, http::HeaderValue)>, +} + +/// A strategy for deriving an Edge Cookie identifier. +/// +/// Implementations are selected by configuration. A provider derives the +/// identifier at the edge in [`generate`](Self::generate), and the page +/// response sets the `ts-ec` cookie. +/// +/// A provider returns `Ok(None)` from [`generate`](Self::generate) when it +/// cannot derive an identifier at the edge, so the request proceeds without an +/// Edge Cookie rather than failing. +/// The registered short code that namespaces one Edge Cookie provider's +/// identifiers. +/// +/// Exactly four characters from `[a-z0-9]`, allocated append-only in +/// `docs/superpowers/specs/provider-code-registry.md` and never reused. The +/// code appears as the `{code}~` prefix of every identifier the provider +/// mints, so identifiers from different providers can never collide in the +/// cookie, the identity graph, or a withdrawal, and each identifier records +/// which provider created it. +#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, derive_more::Display)] +pub struct ProviderCode(&'static str); + +impl ProviderCode { + /// Creates a provider code, validating the registry format. + /// + /// # Panics + /// + /// Panics when `code` is not exactly four characters of `[a-z0-9]`. Codes + /// are compile-time literals, so the panic fires in tests and never on a + /// request path. + #[must_use] + pub const fn new(code: &'static str) -> Self { + let bytes = code.as_bytes(); + assert!( + bytes.len() == 4, + "provider code must be exactly four characters" + ); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + assert!( + b.is_ascii_lowercase() || b.is_ascii_digit(), + "provider code characters must be [a-z0-9]" + ); + i += 1; + } + Self(code) + } + + /// The code as a string slice. + #[must_use] + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +/// The separator between a provider code and the provider's identifier value. +/// +/// The tilde is inside the cookie-safe identifier alphabet and outside the +/// built-in HMAC identifier's own characters, so a legacy bare identifier can +/// never be misread as a coded one. +pub const PROVIDER_CODE_SEPARATOR: char = '~'; + +/// Splits a full identifier into its provider-code prefix and value. +/// +/// Returns `(Some(code), value)` when the identifier starts with a well-formed +/// `{code}~` prefix, and `(None, full)` for a legacy bare identifier. The code +/// here is the raw string, not a validated [`ProviderCode`]: an unknown code +/// simply fails the ownership check against the selected provider. +#[must_use] +pub fn split_provider_code(full: &str) -> (Option<&str>, &str) { + if let Some((code, value)) = full.split_once(PROVIDER_CODE_SEPARATOR) + && code.len() == 4 + && code + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + { + return (Some(code), value); + } + (None, full) +} + +/// Whether the selected provider owns `full` as one of its identifiers. +/// +/// A coded identifier belongs to the provider whose registered code it +/// carries, with the value part accepted by that provider's +/// [`accepts_id`](EdgeCookieProvider::accepts_id). A legacy bare identifier +/// (no code prefix) belongs only to the built-in HMAC provider, which +/// dual-reads its pre-envelope form for one release cycle so deployed cookies +/// keep working across the migration. +#[must_use] +pub fn provider_owns_id(provider: &dyn EdgeCookieProvider, full: &str) -> bool { + match split_provider_code(full) { + (Some(code), value) => code == provider.code().as_str() && provider.accepts_id(value), + (None, value) => provider.id() == "hmac" && provider.accepts_id(value), + } +} + +/// The full minted identifier for `value` under `provider`'s code. +#[must_use] +pub fn apply_provider_code(provider: &dyn EdgeCookieProvider, value: &str) -> String { + format!("{}{PROVIDER_CODE_SEPARATOR}{value}", provider.code()) +} + +/// The KV-key form of a full identifier under `provider`. +/// +/// The code prefix is preserved verbatim and the provider normalizes only its +/// own value part, so distinct providers' rows can never share a key and a +/// provider never sees another provider's syntax. +#[must_use] +pub fn provider_kv_key(provider: &dyn EdgeCookieProvider, full: &str) -> String { + match split_provider_code(full) { + (Some(code), value) => format!( + "{code}{PROVIDER_CODE_SEPARATOR}{}", + provider.normalize_id_for_kv(value) + ), + (None, value) => provider.normalize_id_for_kv(value), + } +} + +pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { + /// Returns the stable identifier for this provider, used in configuration + /// and logs. + fn id(&self) -> &'static str; + + /// The provider's registered code, the `{code}~` namespace of every + /// identifier it mints. + /// + /// Mandatory, with no default: a provider must allocate a unique code in + /// `docs/superpowers/specs/provider-code-registry.md` before it can exist, + /// so no two providers can ever mint colliding identifiers. Core applies + /// the code at mint and checks it at read-back, and the provider itself + /// only ever sees its own value part. + fn code(&self) -> ProviderCode; + + /// Derives an Edge Cookie identifier from the provider's injected services + /// and the request's gating context. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when derivation fails. + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result>; + + /// Returns whether `value` is a well-formed identifier this provider issues. + /// + /// Core calls this to decide whether an incoming `ts-ec` cookie value is a + /// usable Edge Cookie identifier before reading it back, keying the KV + /// identity graph, or withdrawing it. Core strips the provider's `{code}~` + /// prefix first, so this receives only the provider's own value part. + /// This keeps the identifier opaque to + /// core: a provider whose identifiers are not the built-in shape (for + /// example an opaque signed envelope) accepts its own format here, so its + /// identifier round-trips instead of being silently dropped on read-back. + /// + /// The default accepts the built-in HMAC identifier shape + /// (`<64 hex>.<6 alphanumeric>`), which is correct for [`HmacProvider`] and + /// the other core providers. + fn accepts_id(&self, value: &str) -> bool { + generation::is_valid_ec_id(value) + } + + /// Returns the KV-key form of `value` for this provider's identifiers. + /// + /// Core keys the identity graph by the returned string, so a provider whose + /// identifiers are case-sensitive or carry no separable segments returns the + /// value unchanged to avoid collapsing distinct identifiers into one key. + /// + /// The default lowercases the leading HMAC hash segment and preserves the + /// suffix, matching the built-in identifier shape. + fn normalize_id_for_kv(&self, value: &str) -> String { + generation::normalize_ec_id_for_kv(value) + } +} + +/// The built-in HMAC Edge Cookie provider. +/// +/// Derives the identifier from the client IP (read from the [`RequestInfo`] +/// passed at call time) and the configured passphrase via +/// [`generation::generate_ec_id`]. +#[derive(Debug, Clone)] +pub struct HmacProvider { + passphrase: Redacted, +} + +impl HmacProvider { + /// Creates an HMAC provider with the given passphrase. + #[must_use] + pub fn new(passphrase: Redacted) -> Self { + Self { passphrase } + } +} + +impl EdgeCookieProvider for HmacProvider { + fn id(&self) -> &'static str { + "hmac" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("hmac") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let id = generation::generate_ec_id(self.passphrase.expose(), request_info.client_ip())?; + Ok(GeneratedEdgeCookie { + id: Some(id), + response_headers: Vec::new(), + }) + } +} + +/// The built-in host-signal Edge Cookie provider. +/// +/// Derives the identifier from the host fingerprints (TLS JA4 and HTTP/2, read +/// from the injected [`HostSignals`]) plus the client IP (from [`RequestInfo`]), +/// keyed by the configured passphrase. It is host-agnostic: it depends on the +/// `HostSignals` capability, so any host that supplies one can use it. A host +/// that supplies no `HostSignals` cannot build it, and the request stops. +#[derive(Debug, Clone)] +pub struct HostSignalProvider { + passphrase: Redacted, + host_signals: Arc, +} + +impl HostSignalProvider { + /// Creates the provider with the passphrase and its injected host signals. + #[must_use] + pub fn new(passphrase: Redacted, host_signals: Arc) -> Self { + Self { + passphrase, + host_signals, + } + } +} + +impl EdgeCookieProvider for HostSignalProvider { + fn id(&self) -> &'static str { + "host-signals" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("hs00") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let ja4 = self.host_signals.ja4().unwrap_or_default(); + let h2 = self.host_signals.h2().unwrap_or_default(); + // With no fingerprint at all, minting would silently degrade to an + // IP-only identifier under the host-signals name. Defer instead: no + // identity this request, and the request proceeds. + if ja4.is_empty() && h2.is_empty() { + log::warn!("Host-signal EC provider found no TLS/HTTP-2 fingerprints; deferring"); + return Ok(GeneratedEdgeCookie::default()); + } + let id = generation::generate_hmac_ec_id( + self.passphrase.expose(), + &[ja4, h2, request_info.client_ip()], + )?; + Ok(GeneratedEdgeCookie { + id: Some(id), + response_headers: Vec::new(), + }) + } +} + +/// Builds the Edge Cookie provider named by the `[ec] provider` selector, +/// injecting the services it needs. +/// +/// This is the composition root for the built-in providers: the adapter supplies +/// the [`HostSignals`] when the host can produce them, and this constructs the +/// selected provider. The per-request [`RequestInfo`] is passed borrowed to +/// [`generate`](EdgeCookieProvider::generate) at call time rather than stored, so +/// no request snapshot is cloned here. Returns `Ok(None)` when no provider is +/// selected, so the caller stays stateless. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the selected provider requires +/// a service the host did not supply (for example the host-signal provider on a +/// host that exposes no [`HostSignals`]), or when a selected vendor provider is +/// not injected by the adapter, so a misconfigured deployment fails loudly +/// rather than minting a degraded identifier or silently running stateless. +pub fn build_provider( + ec: &Ec, + host_signals: Option>, + injected: Option>, +) -> Result>, Report> { + let Some(key) = ec.provider.as_deref() else { + return Ok(None); + }; + let provider: Option> = match key { + // Explicit statelessness: the same meaning as omitting the selector. + "none" => None, + "hmac" => ec + .providers + .hmac + .as_ref() + .map(|config| Box::new(HmacProvider::new(config.passphrase.clone())) as _), + "host-signals" => { + let signals = host_signals.ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: "The host-signals Edge Cookie provider requires a host that supplies \ + TLS/HTTP-2 fingerprints, which this host does not" + .to_owned(), + }) + })?; + ec.providers.host_signals.as_ref().map(|config| { + Box::new(HostSignalProvider::new(config.passphrase.clone(), signals)) as _ + }) + } + // Any other key names a vendor or host provider the adapter injects + // through [`RuntimeServices`](crate::platform::RuntimeServices), the same + // seam the device and geo providers use, so core never names a vendor. + // The injected provider is used when its own id matches the selected key, + // and its `[ec.providers.]` block is read by the adapter that built + // it. A selected key with no matching injected provider is a deployment + // error: fail loudly rather than silently running stateless. + other => { + let provider = injected + .filter(|provider| provider.id() == other) + .map(|provider| Box::new(SharedProvider(provider)) as _); + if provider.is_none() { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Edge Cookie provider `{other}` is selected but this deployment's \ + adapter does not provide it" + ), + })); + } + provider + } + }; + Ok(provider) +} + +/// Adapts an injected, shared [`EdgeCookieProvider`] to the owned `Box` that +/// [`build_provider`] returns. +/// +/// A vendor or host provider is injected as an `Arc` so it can live in +/// [`RuntimeServices`](crate::platform::RuntimeServices) and be cloned per +/// request. Every method delegates to the inner provider, so its behavior is +/// unchanged. +#[derive(Debug)] +struct SharedProvider(Arc); + +impl EdgeCookieProvider for SharedProvider { + fn code(&self) -> ProviderCode { + self.0.code() + } + + fn id(&self) -> &'static str { + self.0.id() + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result> { + self.0.generate(request_info, input) + } + + fn accepts_id(&self, value: &str) -> bool { + self.0.accepts_id(value) + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + self.0.normalize_id_for_kv(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::evidence::OwnedRequestInfo; + + #[test] + fn split_provider_code_separates_coded_and_legacy_forms() { + assert_eq!( + split_provider_code("hmac~abc.DEF123"), + (Some("hmac"), "abc.DEF123"), + "a four-character code before the first tilde splits off" + ); + assert_eq!( + split_provider_code("51dd~value~with~tildes"), + (Some("51dd"), "value~with~tildes"), + "only the first tilde splits, so a value may contain tildes" + ); + assert_eq!( + split_provider_code("abcdef.XYZ"), + (None, "abcdef.XYZ"), + "no tilde means the legacy bare form" + ); + assert_eq!( + split_provider_code("toolong~x"), + (None, "toolong~x"), + "a prefix that is not exactly four characters is not a code" + ); + assert_eq!( + split_provider_code("AB12~x"), + (None, "AB12~x"), + "uppercase is outside the code alphabet" + ); + } + + #[test] + fn provider_ownership_follows_the_code() { + let provider = HmacProvider::new(test_passphrase()); + let legacy = format!("{}.ABC123", "a".repeat(64)); + let coded = format!("hmac~{legacy}"); + let foreign = format!("zz00~{legacy}"); + assert!( + provider_owns_id(&provider, &coded), + "the provider owns identifiers carrying its own code" + ); + assert!( + provider_owns_id(&provider, &legacy), + "the built-in hmac provider dual-reads the legacy bare form" + ); + assert!( + !provider_owns_id(&provider, &foreign), + "an identifier with another provider's code is never owned" + ); + } + use crate::redacted::Redacted; + + fn test_passphrase() -> Redacted { + Redacted::from("a-test-passphrase-32-bytes-minimum".to_owned()) + } + + fn test_request_info() -> OwnedRequestInfo { + OwnedRequestInfo::new("203.0.113.1".to_owned(), http::HeaderMap::new()) + } + + /// Test host signals with fixed JA4/H2 values. + #[derive(Debug)] + struct TestHostSignals { + ja4: Option, + h2: Option, + } + + impl HostSignals for TestHostSignals { + fn ja4(&self) -> Option<&str> { + self.ja4.as_deref() + } + fn h2(&self) -> Option<&str> { + self.h2.as_deref() + } + } + + #[test] + fn default_id_semantics_match_the_builtin_shape() { + let provider = HmacProvider::new(test_passphrase()); + + // The default `accepts_id` accepts the built-in HMAC shape and rejects + // anything else, so a built-in provider's identifiers round-trip while an + // opaque value is left to a provider that overrides the check. + let valid = format!("{}.{}", "a".repeat(64), "abc123"); + assert!(provider.accepts_id(&valid), "should accept the HMAC shape"); + assert!( + !provider.accepts_id("not-hmac-shaped"), + "should reject a non-HMAC identifier by default" + ); + + // The default `normalize_id_for_kv` lowercases the hash segment. This is + // exactly the transform that would corrupt an opaque case-sensitive + // identifier, which is why such a provider overrides it. + let mixed = format!("{}.{}", "A".repeat(64), "abc123"); + assert_eq!( + provider.normalize_id_for_kv(&mixed), + format!("{}.{}", "a".repeat(64), "abc123"), + "the default should lowercase the hash segment" + ); + } + + #[test] + fn shared_provider_delegates_id_semantics_to_the_inner_provider() { + // `SharedProvider` wraps an adapter-injected provider. It must forward + // every trait method to the inner provider, including `accepts_id` and + // `normalize_id_for_kv`; a wrapper that silently used the defaults would + // drop an opaque vendor identifier on read-back. This guards that + // delegation directly. + #[derive(Debug)] + struct Inner; + + impl EdgeCookieProvider for Inner { + fn id(&self) -> &'static str { + "inner" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0in") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + value == "opaque-ok" + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + format!("kv:{value}") + } + } + + let shared = SharedProvider(Arc::new(Inner)); + + assert_eq!(shared.id(), "inner", "should delegate id"); + assert!( + shared.accepts_id("opaque-ok"), + "should delegate accepts_id acceptance to the inner provider" + ); + assert!( + !shared.accepts_id("something-else"), + "should delegate accepts_id rejection to the inner provider" + ); + assert_eq!( + shared.normalize_id_for_kv("x"), + "kv:x", + "should delegate normalize_id_for_kv to the inner provider" + ); + } + + #[test] + fn host_signal_provider_mints_from_fingerprints() { + let signals = Arc::new(TestHostSignals { + ja4: Some("t13d1516h2_8daaf6152771_e5627efa2ab1".to_owned()), + h2: Some("1:65536;4:6291456".to_owned()), + }); + let provider = HostSignalProvider::new(test_passphrase(), signals); + let request_info = test_request_info(); + let generated = provider + .generate(&request_info, &IdentityInput::default()) + .expect("should generate"); + assert!( + generated.id.is_some(), + "the host-signal provider mints an identifier from the fingerprints" + ); + } + + #[test] + fn host_signal_provider_defers_without_fingerprints() { + let signals = Arc::new(TestHostSignals { + ja4: None, + h2: None, + }); + let provider = HostSignalProvider::new(test_passphrase(), signals); + let request_info = test_request_info(); + let generated = provider + .generate(&request_info, &IdentityInput::default()) + .expect("should generate"); + assert!( + generated.id.is_none(), + "with no host fingerprints the provider should defer rather than mint an IP-only identifier" + ); + } + + #[test] + fn a_selected_but_uninjected_vendor_provider_fails_loudly() { + let ec = Ec { + provider: Some("acme".to_owned()), + ..Ec::default() + }; + + let err = build_provider(&ec, None, None) + .expect_err("selecting a provider the adapter does not inject should error"); + assert!( + err.to_string().contains("acme"), + "the error should name the selected provider, got: {err}" + ); + } +} diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index a4cdb4730..d53b118aa 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -11,24 +11,29 @@ use crate::constants::{COOKIE_TS_EC, HEADER_X_TS_EC}; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; #[cfg(test)] -use crate::ec::generation::{generate_ec_id as generate_canonical_ec_id, normalize_ip}; +use crate::ec::generation::normalize_ip; +#[cfg(test)] +use crate::ec::provider::{IdentityInput, build_provider}; use crate::error::TrustedServerError; #[cfg(test)] +use crate::evidence::BorrowedRequestInfo; +#[cfg(test)] use crate::platform::RuntimeServices; #[cfg(test)] use crate::settings::Settings; -/// Generates a fresh EC ID based on client IP address. +/// Generates a fresh EC ID using the configured Edge Cookie provider. /// -/// Delegates to the canonical generator in [`crate::ec::generation`] so a -/// single normalization + HMAC path produces EC IDs. The canonical -/// `normalize_ip` format is a stable contract — EC hashes stored in KV -/// depend on it, and a divergent normalization would mint non-correlating -/// identities for the same client. +/// Routes through the pluggable provider model: the active `[ec] provider` +/// selection decides the outcome. Returns `Ok(None)` when no provider is +/// configured, so Trusted Server runs statelessly and mints no Edge Cookie. +/// `request_headers` lets a provider that derives identity from request +/// evidence read it; the built-in HMAC provider ignores it and uses only the +/// normalized client IP. /// /// # Errors /// -/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +/// - [`TrustedServerError::EdgeCookie`] if provider generation fails /// /// Currently exercised only by tests: the production EC lifecycle generates IDs /// through [`crate::ec`]/`EcContext` rather than this edge-cookie helper. @@ -36,18 +41,44 @@ use crate::settings::Settings; pub fn generate_ec_id( settings: &Settings, services: &RuntimeServices, -) -> Result> { - // Fallback to "unknown" when client IP is unavailable (e.g., local testing). - // All such requests share the same HMAC base; the random suffix provides uniqueness. + request_headers: Option<&http::HeaderMap>, +) -> Result, Report> { + // Fall back to "unknown" when the client IP is unavailable (for example in + // local testing). All such requests share the same HMAC base; the random + // suffix provides uniqueness. let client_ip = services - .client_info + .client_info() .client_ip .map(normalize_ip) .unwrap_or_else(|| "unknown".to_string()); log::trace!("Generating fresh EC ID from normalized client context"); - generate_canonical_ec_id(settings, &client_ip) + let Some(provider) = build_provider( + &settings.ec, + services.host_signals(), + services.ec_provider(), + )? + else { + log::info!("No Edge Cookie provider configured; running statelessly"); + return Ok(None); + }; + + // The provider reads request data (the client IP, and on a fingerprinting + // host the TLS/HTTP-2 signals) borrowed at call time, so nothing is cloned. + let request_info = BorrowedRequestInfo::new(&client_ip, request_headers); + // The publisher path gates creation on the request's consent context at + // the call site, and the built-in provider reads neither that result nor + // the consent context, so + // they are not threaded here. + let generated = provider.generate(&request_info, &IdentityInput::default())?; + let generated = crate::ec::provider::GeneratedEdgeCookie { + id: generated + .id + .map(|value| crate::ec::provider::apply_provider_code(provider.as_ref(), &value)), + response_headers: generated.response_headers, + }; + Ok(generated.id) } /// Gets an existing EC ID from the request. @@ -99,7 +130,10 @@ pub fn get_ec_id(req: &Request) -> Result, Report, -) -> Result> { +) -> Result, Report> { if let Some(id) = get_ec_id(req)? { - return Ok(id); + return Ok(Some(id)); } - // If no existing EC ID found, generate a fresh one - let ec_id = generate_ec_id(settings, services)?; - log::trace!("No existing EC ID found; generated a fresh EC ID"); + // If no existing EC ID found, generate a fresh one through the provider. + let ec_id = generate_ec_id(settings, services, Some(req.headers()))?; + if ec_id.is_some() { + log::trace!("No existing EC ID found; generated a fresh EC ID"); + } Ok(ec_id) } @@ -130,7 +166,7 @@ pub fn get_or_generate_ec_id( settings: &Settings, services: &RuntimeServices, req: &Request, -) -> Result> { +) -> Result, Report> { get_or_generate_ec_id_from_http_request(settings, services, req) } @@ -141,6 +177,7 @@ mod tests { use http::{HeaderName, header}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use crate::ec::generation::generate_ec_id as generate_canonical_ec_id; use crate::platform::test_support::{noop_services, noop_services_with_client_ip}; use crate::test_support::tests::create_test_settings; @@ -155,13 +192,24 @@ mod tests { 0x2001, 0x0db8, 0x85a3, 0x0000, 0x8a2e, 0x0370, 0x7334, 0x1234, )); - let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) - .expect("should generate EC ID via edge_cookie"); - let id_canonical = generate_canonical_ec_id(&settings, &normalize_ip(ip)) + let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + .expect("should generate EC ID via edge_cookie") + .expect("should configure the hmac provider in test settings"); + let passphrase = settings + .ec + .providers + .hmac + .as_ref() + .map(|hmac| hmac.passphrase.expose().as_str()) + .unwrap_or(""); + let id_canonical = generate_canonical_ec_id(passphrase, &normalize_ip(ip)) .expect("should generate EC ID via canonical generator"); + let bare_here = id_here + .strip_prefix("hmac~") + .expect("should carry the hmac provider code"); assert_eq!( - crate::ec::ec_hash(&id_here), + crate::ec::ec_hash(bare_here), crate::ec::ec_hash(&id_canonical), "should produce the same identity hash prefix as the canonical generator" ); @@ -178,6 +226,10 @@ mod tests { } fn is_ec_id_format(value: &str) -> bool { + // The coded envelope: hmac~<64hex>.<6alnum>. + let Some(value) = value.strip_prefix("hmac~") else { + return false; + }; let mut parts = value.split('.'); let hmac_part = match parts.next() { Some(part) => part, @@ -206,11 +258,27 @@ mod tests { fn test_generate_ec_id() { let settings: Settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, &noop_services()).expect("should generate EC ID"); + let ec_id = generate_ec_id(&settings, &noop_services(), None) + .expect("should generate EC ID") + .expect("should configure the hmac provider in test settings"); log::debug!("Generated EC ID: {}", ec_id); assert!( is_ec_id_format(&ec_id), - "should match EC ID format: {{64hex}}.{{6alnum}}" + "should match the coded EC ID format: hmac~{{64hex}}.{{6alnum}}" + ); + } + + #[test] + fn generate_ec_id_returns_none_when_no_provider_is_configured() { + let mut settings = create_test_settings(); + // No provider selected: Trusted Server runs statelessly. + settings.ec.provider = None; + + let id = generate_ec_id(&settings, &noop_services(), None) + .expect("generation should not error when no provider is configured"); + assert!( + id.is_none(), + "no Edge Cookie provider should mean no Edge Cookie is minted" ); } @@ -219,10 +287,12 @@ mod tests { let settings = create_test_settings(); let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)); - let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) - .expect("should generate EC ID with client IP"); - let id_without_ip = generate_ec_id(&settings, &noop_services()) - .expect("should generate EC ID without client IP"); + let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + .expect("should generate EC ID with client IP") + .expect("should configure the hmac provider in test settings"); + let id_without_ip = generate_ec_id(&settings, &noop_services(), None) + .expect("should generate EC ID without client IP") + .expect("should configure the hmac provider in test settings"); let hmac_with_ip = id_with_ip.split_once('.').expect("should contain dot").0; let hmac_without_ip = id_without_ip.split_once('.').expect("should contain dot").0; @@ -235,22 +305,28 @@ mod tests { #[test] fn test_is_ec_id_format_accepts_valid_value() { - let value = format!("{}.{}", "a".repeat(64), "Ab12z9"); + let value = format!("hmac~{}.{}", "a".repeat(64), "Ab12z9"); assert!( is_ec_id_format(&value), - "should accept a valid EC ID format" + "should accept a valid coded EC ID format" ); } #[test] fn test_is_ec_id_format_rejects_invalid_values() { - let missing_suffix = "a".repeat(64); + let bare_legacy_shape = format!("{}.{}", "a".repeat(64), "Ab12z9"); + assert!( + !is_ec_id_format(&bare_legacy_shape), + "a fresh mint always carries the provider code" + ); + + let missing_suffix = format!("hmac~{}", "a".repeat(64)); assert!( !is_ec_id_format(&missing_suffix), "should reject missing suffix" ); - let invalid_hex = format!("{}.{}", "a".repeat(63) + "g", "Ab12z9"); + let invalid_hex = format!("hmac~{}.{}", "a".repeat(63) + "g", "Ab12z9"); assert!( !is_ec_id_format(&invalid_hex), "should reject non-hex HMAC content" @@ -278,7 +354,8 @@ mod tests { assert_eq!(ec_id, Some("existing_ec_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should reuse header EC ID"); + .expect("should reuse header EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_ec_id"); } @@ -294,7 +371,8 @@ mod tests { assert_eq!(ec_id, Some("existing_cookie_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should reuse cookie EC ID"); + .expect("should reuse cookie EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_cookie_id"); } @@ -326,7 +404,8 @@ mod tests { .expect("should build test request"); let ec_id = get_or_generate_ec_id_from_http_request(&settings, &noop_services(), &req) - .expect("should reuse cookie EC ID from http request"); + .expect("should reuse cookie EC ID from http request") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_http_cookie_id"); } @@ -344,7 +423,8 @@ mod tests { let req = create_test_request(&[]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should get or generate EC ID"); + .expect("should get or generate EC ID") + .expect("should configure the hmac provider in test settings"); assert!(!ec_id.is_empty()); } @@ -369,7 +449,8 @@ mod tests { let req = create_test_request(&[(HEADER_X_TS_EC, "evil;injected")]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should generate fresh ID on invalid header"); + .expect("should generate fresh ID on invalid header") + .expect("should configure the hmac provider in test settings"); assert_ne!( ec_id, "evil;injected", "should not use tampered header value" diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs new file mode 100644 index 000000000..804251d0b --- /dev/null +++ b/crates/trusted-server-core/src/evidence.rs @@ -0,0 +1,307 @@ +//! Service interfaces injected into providers. +//! +//! Trusted Server wires providers by dependency injection. A provider's +//! constructor takes the services it needs as `Arc`, and the adapter +//! (the composition root) supplies instances per request. A provider that needs +//! a service the host does not supply cannot be built, so the request stops +//! rather than silently degrading. +//! +//! These traits are the service interfaces. Request-scoped data outlives the +//! live request only when snapshotted, so an implementation owns its data where +//! needed ([`OwnedRequestInfo`] is the built-in owned snapshot). + +use http::HeaderMap; + +/// Read-only access to the current request's basic information. +/// +/// The request data any host can supply: the normalized client IP, the +/// User-Agent, and request headers. A provider receives it by reference at call +/// time (`generate`/`detect`), reads what it needs, and does not retain it. +pub trait RequestInfo: Send + Sync + core::fmt::Debug { + /// The normalized client IP, or `""` when the host cannot determine it. + fn client_ip(&self) -> &str; + + /// The `User-Agent` header value, or `""` when absent. + fn user_agent(&self) -> &str; + + /// An arbitrary request header by name (case-insensitive), or `None`. + /// + /// Request cookies are read through this, from the `Cookie` header (a + /// provider that stores values in cookies parses them from it). + fn header(&self, name: &str) -> Option<&str>; + + /// The names of all request headers present, for a provider that enumerates + /// evidence (for example to forward client hints). The default is empty. + fn header_names(&self) -> Vec<&str> { + Vec::new() + } + + /// The request path (the URL path, without the query string), or `""` when + /// request info was built without a URL. + /// + /// A provider reads the request target through this together with + /// [`query`](Self::query); `RequestInfo` is the evidence abstraction, so more + /// request accessors can be added here (as defaulted methods) without + /// breaking existing implementations. + fn path(&self) -> &str { + "" + } + + /// The raw request query string (the part after `?`, without the leading + /// `?`), or `""` when the request carried none. + /// + /// A provider reads request parameters through this, or the + /// [`query_param`](Self::query_param) convenience. The default is empty, for + /// request info built without a URL. + fn query(&self) -> &str { + "" + } + + /// The first value of query parameter `name`, percent-decoded, or `None` + /// when the parameter is absent. + /// + /// Parses [`query`](Self::query) with `application/x-www-form-urlencoded` + /// rules, matching how the browser encodes query parameters. + fn query_param(&self, name: &str) -> Option { + url::form_urlencoded::parse(self.query().as_bytes()) + .find_map(|(key, value)| (&*key == name).then(|| value.into_owned())) + } +} + +/// An owned [`RequestInfo`] built from a request snapshot. +/// +/// Owns the client IP and a header snapshot, for a context that cannot borrow +/// the live request for the duration of the call. The request path uses +/// [`BorrowedRequestInfo`]; this owned variant serves tests and any future +/// host whose request data cannot be borrowed. +#[derive(Debug, Default, Clone)] +pub struct OwnedRequestInfo { + client_ip: String, + headers: HeaderMap, + path: String, + query: String, +} + +impl OwnedRequestInfo { + /// Builds owned request info from the client IP and a header snapshot. + /// + /// The request target ([`path`](RequestInfo::path) and + /// [`query`](RequestInfo::query)) is empty; attach it with + /// [`with_request_target`](Self::with_request_target) when the caller has the + /// URL. + #[must_use] + pub fn new(client_ip: String, headers: HeaderMap) -> Self { + Self { + client_ip, + headers, + path: String::new(), + query: String::new(), + } + } + + /// Attaches the request target (URL path and query string) to this snapshot, + /// so a provider can read request parameters through + /// [`query_param`](RequestInfo::query_param). + #[must_use] + pub fn with_request_target(mut self, path: String, query: String) -> Self { + self.path = path; + self.query = query; + self + } +} + +impl RequestInfo for OwnedRequestInfo { + fn client_ip(&self) -> &str { + &self.client_ip + } + + fn user_agent(&self) -> &str { + self.headers + .get(http::header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + } + + fn header(&self, name: &str) -> Option<&str> { + self.headers.get(name).and_then(|value| value.to_str().ok()) + } + + fn header_names(&self) -> Vec<&str> { + self.headers.keys().map(http::HeaderName::as_str).collect() + } + + fn path(&self) -> &str { + &self.path + } + + fn query(&self) -> &str { + &self.query + } +} + +/// A borrowed [`RequestInfo`] over the live request, with no allocation. +/// +/// The composition root builds one per request from the normalized client IP and +/// an optional borrow of the request headers, then passes it to a provider by +/// shared reference at call time (`generate`/`detect`). It borrows rather than +/// owns, so it must not outlive the request. A provider reads it during the call +/// and does not retain it, so no per-request `HeaderMap` clone is needed. +#[derive(Debug)] +pub struct BorrowedRequestInfo<'a> { + client_ip: &'a str, + headers: Option<&'a HeaderMap>, + path: &'a str, + query: &'a str, +} + +impl<'a> BorrowedRequestInfo<'a> { + /// Borrows request info from the client IP and optional request headers. + /// + /// Pass `None` for headers on a path that only needs the client IP. The + /// request target ([`path`](RequestInfo::path) and + /// [`query`](RequestInfo::query)) is empty; attach it with + /// [`with_request_target`](Self::with_request_target) when the caller has the + /// URL. + #[must_use] + pub fn new(client_ip: &'a str, headers: Option<&'a HeaderMap>) -> Self { + Self { + client_ip, + headers, + path: "", + query: "", + } + } + + /// Attaches the borrowed request target (URL path and query string), so a + /// provider can read request parameters through + /// [`query_param`](RequestInfo::query_param). + #[must_use] + pub fn with_request_target(mut self, path: &'a str, query: &'a str) -> Self { + self.path = path; + self.query = query; + self + } +} + +impl RequestInfo for BorrowedRequestInfo<'_> { + fn client_ip(&self) -> &str { + self.client_ip + } + + fn user_agent(&self) -> &str { + self.headers + .and_then(|headers| headers.get(http::header::USER_AGENT)) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + } + + fn header(&self, name: &str) -> Option<&str> { + self.headers + .and_then(|headers| headers.get(name)) + .and_then(|value| value.to_str().ok()) + } + + fn header_names(&self) -> Vec<&str> { + self.headers + .map(|headers| headers.keys().map(http::HeaderName::as_str).collect()) + .unwrap_or_default() + } + + fn path(&self) -> &str { + self.path + } + + fn query(&self) -> &str { + self.query + } +} + +/// Host-computed client fingerprints that are not carried in request headers. +/// +/// A host that can compute them supplies an implementation (Fastly exposes the +/// TLS JA4 and HTTP/2 fingerprints). A provider that needs them takes +/// `Arc` in its constructor; on a host that supplies none, the +/// provider cannot be built and the request stops. +pub trait HostSignals: Send + Sync + core::fmt::Debug { + /// The full JA4 TLS fingerprint, or `None` when unavailable. + fn ja4(&self) -> Option<&str>; + + /// The raw HTTP/2 SETTINGS fingerprint, or `None` when unavailable. + fn h2(&self) -> Option<&str>; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn headers_with_cookie() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + "client-id=abc123; ts-ec=xyz" + .parse() + .expect("should parse cookie header"), + ); + headers + } + + #[test] + fn query_param_decodes_and_selects_the_first_value() { + let info = OwnedRequestInfo::new(String::new(), HeaderMap::new()) + .with_request_target("/page".to_owned(), "id=a%20b&id=second&flag=1".to_owned()); + + assert_eq!( + info.query_param("id").as_deref(), + Some("a b"), + "should percent-decode and return the first value for a repeated key" + ); + assert_eq!(info.query_param("flag").as_deref(), Some("1")); + assert_eq!( + info.query_param("missing"), + None, + "an absent parameter should be None" + ); + } + + #[test] + fn path_and_query_accessors_return_the_request_target() { + let info = OwnedRequestInfo::new(String::new(), HeaderMap::new()) + .with_request_target("/a/b".to_owned(), "x=1".to_owned()); + assert_eq!(info.path(), "/a/b"); + assert_eq!(info.query(), "x=1"); + } + + #[test] + fn request_info_defaults_to_an_empty_target() { + let info = OwnedRequestInfo::new("203.0.113.5".to_owned(), HeaderMap::new()); + assert_eq!(info.path(), "", "path should default to empty"); + assert_eq!(info.query(), "", "query should default to empty"); + assert_eq!( + info.query_param("id"), + None, + "query_param over an empty query should be None" + ); + } + + #[test] + fn a_provider_reads_cookies_from_the_header() { + let info = OwnedRequestInfo::new("203.0.113.5".to_owned(), headers_with_cookie()); + assert_eq!( + info.header("cookie"), + Some("client-id=abc123; ts-ec=xyz"), + "cookies are read through the Cookie header" + ); + } + + #[test] + fn borrowed_request_info_exposes_the_same_target() { + let headers = headers_with_cookie(); + let info = BorrowedRequestInfo::new("203.0.113.5", Some(&headers)) + .with_request_target("/page", "id=abc123"); + + assert_eq!(info.path(), "/page"); + assert_eq!(info.query(), "id=abc123"); + assert_eq!(info.query_param("id").as_deref(), Some("abc123")); + assert_eq!(info.header("cookie"), Some("client-id=abc123; ts-ec=xyz")); + } +} diff --git a/crates/trusted-server-core/src/geo.rs b/crates/trusted-server-core/src/geo.rs index 63f7907f5..22a37ad2c 100644 --- a/crates/trusted-server-core/src/geo.rs +++ b/crates/trusted-server-core/src/geo.rs @@ -59,39 +59,6 @@ fn insert_geo_header(headers: &mut http::HeaderMap, name: http::header::HeaderNa } } -use std::collections::HashSet; -use std::sync::LazyLock; - -/// EU-27 + EEA-3 (Iceland, Liechtenstein, Norway) + UK (UK GDPR). -/// -/// Two-letter ISO 3166-1 alpha-2 country codes for jurisdictions where GDPR -/// or equivalent legislation applies. Used to infer GDPR applicability from -/// IP-derived geolocation when a more authoritative signal (e.g. TCF consent -/// string) is not yet available. -static GDPR_COUNTRIES: LazyLock> = LazyLock::new(|| { - [ - // EU-27 - "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", - "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", - // EEA (non-EU) - "IS", "LI", "NO", // UK GDPR - "GB", - ] - .into_iter() - .collect() -}); - -/// Returns `true` if the given two-letter country code falls under GDPR -/// jurisdiction (EU-27, EEA, or UK). -/// -/// The comparison is case-insensitive. Returns `false` for empty or -/// unrecognised codes. -#[must_use] -pub fn is_gdpr_country(country_code: &str) -> bool { - let upper = country_code.to_ascii_uppercase(); - GDPR_COUNTRIES.contains(upper.as_str()) -} - #[cfg(test)] mod tests { use super::*; @@ -217,39 +184,6 @@ mod tests { ); } - #[test] - fn is_gdpr_country_detects_eu_members() { - assert!(is_gdpr_country("DE"), "Germany is EU"); - assert!(is_gdpr_country("FR"), "France is EU"); - assert!(is_gdpr_country("IT"), "Italy is EU"); - } - - #[test] - fn is_gdpr_country_detects_eea_and_uk() { - assert!(is_gdpr_country("NO"), "Norway is EEA"); - assert!(is_gdpr_country("IS"), "Iceland is EEA"); - assert!(is_gdpr_country("GB"), "UK has UK GDPR"); - } - - #[test] - fn is_gdpr_country_rejects_non_gdpr() { - assert!(!is_gdpr_country("US"), "US is not GDPR"); - assert!(!is_gdpr_country("CN"), "China is not GDPR"); - assert!(!is_gdpr_country("BR"), "Brazil is not GDPR"); - } - - #[test] - fn is_gdpr_country_is_case_insensitive() { - assert!(is_gdpr_country("de"), "lowercase should match"); - assert!(is_gdpr_country("De"), "mixed case should match"); - } - - #[test] - fn is_gdpr_country_handles_empty_and_unknown() { - assert!(!is_gdpr_country(""), "empty string is not GDPR"); - assert!(!is_gdpr_country("XX"), "unknown code is not GDPR"); - } - #[test] fn set_response_headers_omits_region_when_none() { let geo = GeoInfo { diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 0dfb5906f..8eaa82a75 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -1537,6 +1537,9 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] @@ -1570,6 +1573,9 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index c9b3f5ded..06b987d6e 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -3024,6 +3024,9 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#; diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 280eae847..4399dc912 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -974,7 +974,7 @@ impl IntegrationRegistry { // may lack consent signals such as the Sec-GPC header. if is_navigation_request(&req) { if let Err(err) = ec_context.generate_if_needed(settings, kv) { - log::warn!("EC generation failed for integration proxy: {err:?}"); + log::error!("EC generation failed for integration proxy: {err:?}"); } } else { log::debug!( diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 48e92faed..3801ed9f3 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -47,6 +47,7 @@ pub mod creative_opportunities; pub mod ec; pub(crate) mod edge_cookie; pub mod error; +pub mod evidence; pub mod geo; pub mod host_header; pub(crate) mod host_rewrite; diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 1c5bf4c2a..1bb200555 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -76,6 +76,48 @@ pub use types::{ /// Default first-byte timeout for platform backends. pub(crate) const DEFAULT_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); +use std::net::IpAddr; +use std::sync::Arc; + +use error_stack::Report; + +use crate::settings::Settings; + +/// A geo provider that resolves nothing. +/// +/// Installed when `[geo] provider = "none"` is selected, so a client IP is +/// never sent to any host geo service. Every geo consumer already treats +/// [`GeoInfo`] as optional, so a `None` result degrades gracefully (the +/// jurisdiction is unknown, the auction omits geo, and so on). +pub struct DisabledGeo; + +impl PlatformGeo for DisabledGeo { + fn lookup(&self, _client_ip: Option) -> Result, Report> { + Ok(None) + } +} + +/// Selects the geo provider named by the `[geo] provider` selector. +/// +/// The host platform's geo lookup is the default: with no selector, +/// `host_default` (the adapter's platform geo implementation) resolves the +/// location, matching the behavior before the selector existed, and +/// `provider = "platform"` spells the same choice explicitly. Selecting +/// `provider = "none"` returns [`DisabledGeo`] instead, so a client IP is +/// never sent to any host geo service. A selected-but-unknown provider is +/// rejected at startup by +/// [`GeoConfig::validate_provider_selection`](crate::settings::GeoConfig::validate_provider_selection). +#[must_use] +pub fn build_geo_provider( + settings: &Settings, + host_default: Arc, +) -> Arc { + match settings.geo.provider.as_deref() { + Some("none") => Arc::new(DisabledGeo), + _ => host_default, + } +} + #[cfg(test)] mod tests { use std::net::{IpAddr, Ipv4Addr}; @@ -168,6 +210,48 @@ mod tests { assert!(result.is_none(), "should return None when no IP is present"); } + #[test] + fn build_geo_provider_defaults_to_the_host_geo() { + let settings = Settings::default(); + let host: Arc = Arc::new(test_support::NoopGeo); + let selected = build_geo_provider(&settings, Arc::clone(&host)); + assert!( + Arc::ptr_eq(&host, &selected), + "default settings should use the host geo" + ); + } + + #[test] + fn build_geo_provider_none_selects_no_geo() { + let mut settings = Settings::default(); + settings.geo.provider = Some("none".to_owned()); + let host: Arc = Arc::new(test_support::NoopGeo); + let selected = build_geo_provider(&settings, Arc::clone(&host)); + assert!( + !Arc::ptr_eq(&host, &selected), + "provider none should not use the host geo" + ); + assert!( + selected + .lookup(Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)))) + .expect("disabled geo lookup should not fail") + .is_none(), + "the disabled geo provider should resolve nothing" + ); + } + + #[test] + fn build_geo_provider_uses_host_geo_when_platform_is_selected() { + let mut settings = Settings::default(); + settings.geo.provider = Some("platform".to_owned()); + let host: Arc = Arc::new(test_support::NoopGeo); + let selected = build_geo_provider(&settings, Arc::clone(&host)); + assert!( + Arc::ptr_eq(&host, &selected), + "the platform selector should use the host geo" + ); + } + #[test] fn runtime_services_with_kv_store_replaces_only_the_new_clone() { let services = noop_services_with_client_ip(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 7))); diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 917f1bf50..cecb902fa 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -688,6 +688,28 @@ pub(crate) fn noop_services() -> RuntimeServices { build_services_with_config(NoopConfigStore) } +/// Build a [`RuntimeServices`] with an injected Edge Cookie provider, so a test +/// can exercise the adapter-injection path an opaque-identifier vendor provider +/// reaches core through. +pub(crate) fn noop_services_with_ec_provider( + ec_provider: Arc, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + // A fixed client IP so the generate path (which requires one) can run. + .client_info(ClientInfo { + client_ip: Some("203.0.113.10".parse().expect("should parse test client IP")), + ..ClientInfo::default() + }) + .ec_provider(ec_provider) + .build() +} + /// Build a [`RuntimeServices`] whose auction telemetry sink is the supplied /// recording (or otherwise custom) sink, so tests can assert which terminal /// auction events were emitted. diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index c6af0a307..6bceabd7c 100644 --- a/crates/trusted-server-core/src/platform/traits.rs +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -138,6 +138,13 @@ pub trait PlatformBackend: Send + Sync { pub trait PlatformGeo: Send + Sync { /// Look up geographic information for the given client IP address. /// + /// An implementation must return [`GeoInfo`] with the country as an + /// ISO 3166-1 alpha-2 code (for example `US`) and the region as the + /// ISO 3166-2 subdivision code without the country prefix (for example + /// `CA`). The permission model keys its country and region rules on these + /// codes, matched case-insensitively, so the Fastly and other geo + /// providers feed the same rules without translation. + /// /// # Errors /// /// Returns [`PlatformError::Geo`] when the platform geo lookup fails diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 7a3d09334..1c34b0f14 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -9,6 +9,8 @@ use super::{ PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, }; +use crate::ec::provider::EdgeCookieProvider; +use crate::evidence::HostSignals; /// Geographic information extracted from a request. /// @@ -18,7 +20,7 @@ use super::{ pub struct GeoInfo { /// City name. pub city: String, - /// Two-letter country code. + /// ISO 3166-1 alpha-2 country code, for example `US` or `GB`. pub country: String, /// Continent name. pub continent: String, @@ -28,7 +30,8 @@ pub struct GeoInfo { pub longitude: f64, /// DMA (Designated Market Area) / metro code. pub metro_code: i64, - /// Region code. + /// ISO 3166-2 subdivision code without the country prefix, for example `CA` + /// for California, or `None` when no region resolves. pub region: Option, /// Autonomous System Number (e.g. `7922` = Comcast). /// Used to distinguish home ISP vs. corporate VPN. @@ -188,6 +191,16 @@ pub struct RuntimeServices { pub(crate) auction_telemetry_sink: Arc, /// Per-request client metadata extracted at the entry point. pub(crate) client_info: ClientInfo, + /// Host-computed client fingerprints (TLS JA4, HTTP/2), when the host + /// supplies them. `None` on a host that exposes none, so a provider that + /// requires them cannot be built and the request stops. + pub(crate) host_signals: Option>, + /// A vendor or host Edge Cookie provider the adapter injects, selected when + /// `[ec] provider` names it. `None` when only the built-in providers are in + /// use. This is the seam that lets a vendor Edge Cookie provider live in its + /// own crate and be injected, so core never names a vendor (the same pattern + /// as [`geo`](Self::geo) and [`host_signals`](Self::host_signals)). + pub(crate) ec_provider: Option>, } impl RuntimeServices { @@ -275,6 +288,29 @@ impl RuntimeServices { &self.client_info } + /// Returns the host-computed client fingerprints, when the host supplies + /// them. + /// + /// A provider that derives identity from the TLS JA4 or HTTP/2 fingerprints + /// takes these as an injected service. The result is `None` on a host that + /// exposes none, so such a provider cannot be built there and the request + /// stops rather than minting a degraded identifier. + #[must_use] + pub fn host_signals(&self) -> Option> { + self.host_signals.clone() + } + + /// Returns the adapter-injected Edge Cookie provider, when one is wired. + /// + /// `None` when the deployment uses only the built-in providers (which core + /// builds itself). A vendor or host provider is injected here by the + /// adapter, so [`build_provider`](crate::ec::provider::build_provider) can + /// return it without core naming the vendor. + #[must_use] + pub fn ec_provider(&self) -> Option> { + self.ec_provider.clone() + } + /// Wrap the KV store in a [`super::KvHandle`] for ergonomic access to /// JSON helpers, pagination, and validation. #[must_use] @@ -342,6 +378,8 @@ pub struct RuntimeServicesBuilder { geo: Option>, auction_telemetry_sink: Option>, client_info: Option, + host_signals: Option>, + ec_provider: Option>, } impl RuntimeServicesBuilder { @@ -357,6 +395,8 @@ impl RuntimeServicesBuilder { geo: None, auction_telemetry_sink: None, client_info: None, + host_signals: None, + ec_provider: None, } } @@ -436,6 +476,29 @@ impl RuntimeServicesBuilder { self } + /// Set the host-computed client fingerprints service. + /// + /// Optional: a host that exposes no TLS/HTTP-2 fingerprints leaves this + /// unset, so a provider that requires them cannot be built and the request + /// stops. + #[must_use] + pub fn host_signals(mut self, host_signals: Arc) -> Self { + self.host_signals = Some(host_signals); + self + } + + /// Set the adapter-injected Edge Cookie provider. + /// + /// Optional: leave it unset for a deployment that uses only the built-in + /// providers. Set it to inject a vendor or host provider selected by + /// `[ec] provider`, so the provider lives in its own crate and core never + /// names it. + #[must_use] + pub fn ec_provider(mut self, ec_provider: Arc) -> Self { + self.ec_provider = Some(ec_provider); + self + } + /// Construct [`RuntimeServices`] from the accumulated configuration. /// /// # Panics @@ -476,6 +539,8 @@ impl RuntimeServicesBuilder { client_info: self .client_info .expect("should set client_info before building RuntimeServices"), + host_signals: self.host_signals, + ec_provider: self.ec_provider, } } } diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 8674429ea..d2a413a71 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -219,6 +219,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index ddc8ac612..a5838163d 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -423,9 +423,38 @@ impl EcPartner { #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct Ec { - /// Publisher passphrase used as HMAC key for EC generation. - #[validate(custom(function = Ec::validate_passphrase))] - pub passphrase: Redacted, + /// The key of the Edge Cookie identity provider to activate. + /// + /// Names one of the blocks under [`providers`](Self::providers), for + /// example `"hmac"`. Set it in the `[ec]` TOML section or override it with + /// the `TRUSTED_SERVER__ec__provider` environment variable so the same + /// compiled WebAssembly can switch providers at deployment. When absent, no + /// Edge Cookie is generated and Trusted Server runs statelessly; the + /// explicit `"none"` spells the same choice. Selecting a provider whose + /// block is missing is rejected at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + #[serde(default)] + pub provider: Option, + + /// Deprecated location of the HMAC passphrase, read so a configuration + /// written for the previous release still starts. + /// + /// [`migrate_legacy_passphrase`](Self::migrate_legacy_passphrase) maps it + /// to `provider = "hmac"` with the passphrase in the `[ec.providers.hmac]` + /// block and logs a deprecation warning, so a fleet can move configuration + /// and binaries independently. A configuration carrying both the old and + /// the new form is rejected rather than guessed at. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passphrase: Option>, + + /// Configuration blocks for the available Edge Cookie identity providers. + /// + /// Each provider has its own optional `[ec.providers.]` block. The + /// [`provider`](Self::provider) selector names which one is active, so a + /// block can be configured (or kept) without being the one in use. + #[serde(default)] + #[validate(nested)] + pub providers: EcProviders, /// Fastly KV store name for the EC identity graph. #[serde(default)] @@ -513,6 +542,305 @@ impl Ec { } Ok(()) } + + /// Validates that the selected provider names a configured block. + /// + /// When [`provider`](Self::provider) is set, the matching block under + /// [`providers`](Self::providers) must be present, so a deployment that + /// selects a provider (in TOML or via the environment override) but has not + /// configured it fails fast at startup rather than silently running + /// stateless. When no provider is selected, Trusted Server runs statelessly + /// and this check passes. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the selected provider + /// key is unknown or its `[ec.providers.]` block is absent. + pub fn validate_provider_selection(&self) -> Result<(), Report> { + let Some(key) = self.provider.as_deref() else { + if !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec.providers.*] blocks are configured but no [ec] provider is \ + selected. Set [ec] provider = \"\" to activate one, or \ + remove the blocks to run statelessly" + .to_owned(), + })); + } + return Ok(()); + }; + + // `"none"` is explicit statelessness: the same meaning as omitting + // the selector, spelled out. It is subject to the same rule that no + // provider blocks may be left configured. + if key == "none" { + if !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider = \"none\" selects stateless operation, but \ + [ec.providers.*] blocks are configured. Remove the blocks, or \ + select the provider they configure" + .to_owned(), + })); + } + return Ok(()); + } + + let configured = match key { + "hmac" => self.providers.hmac.is_some(), + "host-signals" => self.providers.host_signals.is_some(), + // A vendor or host provider the adapter injects is configured when + // its `[ec.providers.]` block is present. The adapter validates + // the block's own contents when it builds the provider. + other => self.providers.has_vendor(other), + }; + + if !configured { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Edge Cookie provider `{key}` is selected but has no `[ec.providers.{key}]` configuration" + ), + })); + } + + // Every configured block must be the selected one. An unreferenced + // block is almost always a mistake (a mistyped selector or a stale + // block), and accepting it silently invites configuration drift. + let mut unreferenced: Vec = Vec::new(); + if self.providers.hmac.is_some() && key != "hmac" { + unreferenced.push("hmac".to_owned()); + } + for vendor_key in self.providers.vendor_keys() { + if vendor_key != key { + unreferenced.push(vendor_key.to_owned()); + } + } + if unreferenced.is_empty() { + Ok(()) + } else { + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[ec.providers.{}] is configured but `{key}` is selected. Remove the \ + unselected block, or correct the selector", + unreferenced.join("], [ec.providers.") + ), + })) + } + } + + /// Migrates the deprecated `[ec] passphrase` form to the provider layout. + /// + /// A configuration still carrying the old key keeps working for one + /// release cycle: it maps to `provider = "hmac"` with the passphrase in + /// the `[ec.providers.hmac]` block, and a deprecation warning names the + /// new location. A configuration carrying both forms is rejected so a + /// half-edited file fails loudly instead of one form silently winning. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when both the deprecated + /// key and any part of the provider configuration are present. + pub fn migrate_legacy_passphrase(&mut self) -> Result<(), Report> { + let Some(passphrase) = self.passphrase.take() else { + return Ok(()); + }; + if self.provider.is_some() || !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] passphrase (deprecated) and the [ec] provider configuration \ + are both present. Keep exactly one form: move the passphrase to \ + [ec.providers.hmac] and delete the old key" + .to_owned(), + })); + } + log::warn!( + "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ + set [ec] provider = \"hmac\"" + ); + self.provider = Some("hmac".to_owned()); + self.providers.hmac = Some(HmacProviderConfig { passphrase }); + Ok(()) + } +} + +/// Configuration blocks for the available Edge Cookie identity providers. +/// +/// Each provider is configured in its own `[ec.providers.]` block, for +/// example: +/// +/// ```toml +/// [ec.providers.hmac] +/// passphrase = "replace-with-32-plus-byte-random-secret" +/// ``` +/// +/// The active provider is chosen by the [`Ec::provider`] selector, so a block +/// can be present without being in use. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +pub struct EcProviders { + /// The built-in HMAC-over-client-IP provider, keyed `hmac`. + #[serde(default)] + #[validate(nested)] + pub hmac: Option, + + /// The built-in host-signal provider, keyed `host-signals`. Mints the Edge + /// Cookie from the host's TLS/HTTP-2 fingerprints plus the client IP, so it + /// requires a host that supplies those fingerprints. + #[serde(default, rename = "host-signals")] + #[validate(nested)] + pub host_signals: Option, + + /// Configuration blocks for vendor or host providers that live in their own + /// crates and are injected by the adapter. Any `[ec.providers.]` block + /// whose key is not a built-in is captured here as raw values, and the + /// adapter that constructs the provider deserializes its own block into the + /// vendor crate's config type. Core never names a vendor, so a new provider + /// adds nothing here. + #[serde(flatten)] + vendor: HashMap, +} + +impl EcProviders { + /// Returns the raw configuration block for a vendor provider `key`, or + /// `None` when no `[ec.providers.]` block is present. The adapter that + /// builds the provider deserializes this into its own config type. + #[must_use] + pub fn vendor_config(&self, key: &str) -> Option<&JsonValue> { + self.vendor.get(key) + } + + /// Whether a vendor provider configuration block is present for `key`. + #[must_use] + pub fn has_vendor(&self, key: &str) -> bool { + self.vendor.contains_key(key) + } + + /// The keys of the configured vendor provider blocks. + pub(crate) fn vendor_keys(&self) -> impl Iterator { + self.vendor.keys().map(String::as_str) + } + + /// Whether any provider configuration block is present. + /// + /// Used by [`Ec::validate_provider_selection`] to reject a half-migrated + /// configuration that carries provider blocks with no selector, which + /// would otherwise silently run stateless. + #[must_use] + pub fn is_empty(&self) -> bool { + self.hmac.is_none() && self.host_signals.is_none() && self.vendor.is_empty() + } +} + +/// Configuration for the built-in HMAC Edge Cookie provider. +/// +/// Mapped from the `[ec.providers.hmac]` TOML block. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct HmacProviderConfig { + /// Publisher passphrase used as the HMAC key for EC generation. + #[validate(custom(function = Ec::validate_passphrase))] + pub passphrase: Redacted, +} + +/// Configuration for the built-in host-signal Edge Cookie provider. +/// +/// Mapped from the `[ec.providers.host-signals]` TOML block. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct HostSignalsProviderConfig { + /// Passphrase used as the HMAC key over the host fingerprints and client IP. + #[validate(custom(function = Ec::validate_passphrase))] + pub passphrase: Redacted, +} + +/// Device-detection configuration. +/// +/// Mapped from the `[device]` TOML section. Selects which device-detection +/// provider classifies a request into device signals, mirroring the Edge +/// Cookie provider selection in [`Ec`]. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct DeviceConfig { + /// The key of the device-detection provider to activate. + /// + /// Defaults to the built-in `builtin` provider when absent, which classifies + /// from the User-Agent alone and makes no host-specific call, so the default + /// path stays host-neutral. The opt-in `fastly` provider strengthens the + /// browser/bot gate with the host's TLS/H2 fingerprints. Override it with the + /// `TRUSTED_SERVER__device__provider` environment variable so the same + /// compiled WebAssembly can switch providers at deployment. An unknown key is + /// rejected at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + #[serde(default)] + pub provider: Option, +} + +impl DeviceConfig { + /// Returns the active device-detection provider key, defaulting to the + /// built-in heuristic. + #[must_use] + pub fn provider_key(&self) -> &str { + self.provider.as_deref().unwrap_or("builtin") + } + + /// Validates that the selected device-detection provider is available in + /// this build. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the selected provider + /// key is not one this build provides. + pub fn validate_provider_selection(&self) -> Result<(), Report> { + match self.provider_key() { + "builtin" | "fastly" => Ok(()), + key => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Device detection provider `{key}` is not available in this build" + ), + })), + } + } +} + +/// Geo / IP intelligence configuration. +/// +/// Mapped from the `[geo]` TOML section. Selects which provider resolves a +/// client IP into [`GeoInfo`](crate::platform::GeoInfo), mirroring the Edge +/// Cookie provider selection in [`Ec`]. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct GeoConfig { + /// The key of the geo provider to activate. + /// + /// The host platform's geo lookup is the default when absent, matching the + /// behavior before this selector existed; `provider = "platform"` spells + /// the same choice explicitly. Selecting `provider = "none"` resolves no + /// geolocation and makes no host geo call, so a deployment can opt out of + /// any host geo service. Override it with the + /// `TRUSTED_SERVER__geo__provider` environment variable so the same compiled + /// WebAssembly can switch providers at deployment. An unknown key is rejected + /// at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + #[serde(default)] + pub provider: Option, +} + +impl GeoConfig { + /// Validates that the selected geo provider is available in this build. + /// + /// No selector is valid and is the default, selecting the host platform's + /// geo lookup. The explicit `"none"` runs without geolocation, the same + /// way the Edge Cookie provider runs statelessly when `"none"` is + /// selected. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the selected provider + /// key is not one this build provides. + pub fn validate_provider_selection(&self) -> Result<(), Report> { + match self.provider.as_deref() { + None | Some("platform") | Some("none") => Ok(()), + Some(key) => Err(Report::new(TrustedServerError::Configuration { + message: format!("Geo provider `{key}` is not available in this build"), + })), + } + } } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] @@ -2633,6 +2961,12 @@ pub struct Settings { pub tinybird: TinybirdSettings, #[serde(default)] pub debug: DebugConfig, + #[serde(default)] + #[validate(nested)] + pub device: DeviceConfig, + #[serde(default)] + #[validate(nested)] + pub geo: GeoConfig, } impl Settings { @@ -2720,6 +3054,10 @@ impl Settings { }) })?; + settings.ec.migrate_legacy_passphrase()?; + settings.ec.validate_provider_selection()?; + settings.device.validate_provider_selection()?; + settings.geo.validate_provider_selection()?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -2810,8 +3148,15 @@ impl Settings { pub fn reject_placeholder_secrets(&self) -> Result<(), Report> { let mut insecure_fields: Vec = Vec::new(); - if Ec::is_placeholder_passphrase(self.ec.passphrase.expose()) { - insecure_fields.push("ec.passphrase".to_owned()); + if let Some(hmac) = &self.ec.providers.hmac + && Ec::is_placeholder_passphrase(hmac.passphrase.expose()) + { + insecure_fields.push("ec.providers.hmac.passphrase".to_owned()); + } + if let Some(host_signals) = &self.ec.providers.host_signals + && Ec::is_placeholder_passphrase(host_signals.passphrase.expose()) + { + insecure_fields.push("ec.providers.host-signals.passphrase".to_owned()); } if Publisher::is_placeholder_proxy_secret(self.publisher.proxy_secret.expose()) { insecure_fields.push("publisher.proxy_secret".to_owned()); @@ -3629,9 +3974,14 @@ mod tests { ); assert_eq!(settings.publisher.origin_host_header_override, None); assert_eq!( - settings.ec.passphrase.expose(), - "test-secret-key-32-bytes-minimum" + settings.ec.provider.as_deref(), + Some("hmac"), + "test settings should select the hmac EC provider" ); + let Some(hmac) = &settings.ec.providers.hmac else { + panic!("test settings should configure the hmac EC provider"); + }; + assert_eq!(hmac.passphrase.expose(), "test-secret-key-32-bytes-minimum"); settings.validate().expect("Failed to validate settings"); } @@ -3798,6 +4148,34 @@ mod tests { ); } + #[test] + fn provider_selection_allows_no_provider_for_stateless_operation() { + let ec = Ec::default(); + assert!(ec.provider.is_none(), "default Ec selects no provider"); + ec.validate_provider_selection() + .expect("should allow no provider selected and run statelessly"); + } + + #[test] + fn provider_selection_rejects_a_selector_without_a_configured_block() { + // Point the selector at a provider whose `[ec.providers.]` block is + // absent, mirroring a deployment that sets the env override to a + // provider it never configured. + let toml_str = + crate_test_settings_str().replace(r#"provider = "hmac""#, r#"provider = "acme""#); + + let err = Settings::from_toml(&toml_str) + .expect_err("selecting an unconfigured provider should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "unconfigured provider selection should be a configuration error, got: {:?}", + err.current_context() + ); + } + #[test] fn cache_asset_rule_globs_respect_path_separators() { let toml_str = format!( @@ -3904,6 +4282,29 @@ mod tests { ); } + #[test] + fn provider_blocks_without_a_selector_are_rejected() { + // A half-migrated configuration that carries an [ec.providers.hmac] + // block but never selects it would silently run stateless; reject it + // at startup instead. + let toml_str = crate_test_settings_str().replace( + "provider = \"hmac\" +", + "", + ); + + let err = Settings::from_toml(&toml_str) + .expect_err("a provider block with no selector should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + #[test] fn cache_asset_rule_policy_validation_rejects_unsafe_config() { let missing_ttl = format!( @@ -4017,6 +4418,35 @@ mod tests { ); } + #[test] + fn legacy_passphrase_migrates_to_the_hmac_provider() { + let mut ec = Ec { + passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), + ..Ec::default() + }; + ec.migrate_legacy_passphrase() + .expect("should migrate the deprecated form"); + assert_eq!( + ec.provider.as_deref(), + Some("hmac"), + "the deprecated passphrase should select the hmac provider" + ); + assert_eq!( + ec.providers + .hmac + .as_ref() + .expect("should configure the hmac block") + .passphrase + .expose(), + "test-secret-key-32-bytes-minimum", + "the passphrase should move into the hmac block" + ); + assert!( + ec.passphrase.is_none(), + "the deprecated field should be consumed by the migration" + ); + } + #[test] fn cache_asset_rule_validation_rejects_invalid_config() { let duplicate_ids = format!( @@ -4094,6 +4524,163 @@ mod tests { ); } + #[test] + fn legacy_passphrase_alongside_provider_config_is_rejected() { + let mut ec = Ec { + passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), + provider: Some("hmac".to_owned()), + ..Ec::default() + }; + let err = ec + .migrate_legacy_passphrase() + .expect_err("both forms present should be rejected"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + + #[test] + fn provider_none_is_explicit_stateless() { + let ec = Ec { + provider: Some("none".to_owned()), + ..Ec::default() + }; + ec.validate_provider_selection() + .expect("explicit none with no blocks should be valid"); + } + + #[test] + fn provider_none_with_configured_blocks_is_rejected() { + let ec = Ec { + provider: Some("none".to_owned()), + providers: EcProviders { + hmac: Some(HmacProviderConfig { + passphrase: Redacted::new("test-secret-key-32-bytes-minimum".to_owned()), + }), + ..EcProviders::default() + }, + ..Ec::default() + }; + assert!( + ec.validate_provider_selection().is_err(), + "none alongside configured blocks should be rejected" + ); + } + + #[test] + fn device_provider_defaults_to_builtin_and_rejects_unknown() { + let config = DeviceConfig::default(); + assert_eq!( + config.provider_key(), + "builtin", + "no selector should default to the built-in provider" + ); + config + .validate_provider_selection() + .expect("should validate the built-in default"); + + let fastly = DeviceConfig { + provider: Some("fastly".to_owned()), + }; + fastly + .validate_provider_selection() + .expect("should validate the fastly opt-in"); + + let unknown = DeviceConfig { + provider: Some("acme".to_owned()), + }; + assert!( + unknown.validate_provider_selection().is_err(), + "an unknown device provider should be rejected at startup" + ); + } + + #[test] + fn an_unselected_provider_block_is_rejected() { + // A vendor selector with the vendor block present, plus a stray hmac + // block, is almost always a stale or mistyped configuration. + let toml_str = crate_test_settings_str().replace( + "provider = \"hmac\"", + "provider = \"acme\"\n\n [ec.providers.acme]\n api_key = \"example\"", + ); + let err = Settings::from_toml(&toml_str) + .expect_err("a configured but unselected block should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + + #[test] + fn geo_provider_accepts_default_platform_and_none_and_rejects_unknown() { + let config = GeoConfig::default(); + assert!( + config.provider.is_none(), + "geo should default to no selector, which selects the host geo" + ); + config + .validate_provider_selection() + .expect("should validate the default host geo selection"); + + let platform = GeoConfig { + provider: Some("platform".to_owned()), + }; + platform + .validate_provider_selection() + .expect("should validate the explicit platform selection"); + + let none = GeoConfig { + provider: Some("none".to_owned()), + }; + none.validate_provider_selection() + .expect("should validate the explicit opt-out of geolocation"); + + let unknown = GeoConfig { + provider: Some("acme".to_owned()), + }; + assert!( + unknown.validate_provider_selection().is_err(), + "an unknown geo provider should be rejected at startup" + ); + } + + #[test] + fn unknown_keys_in_provider_sections_are_rejected() { + // A mistyped key must fail at startup rather than silently selecting + // a default behind the operator's back. + for (section, bad_key) in [ + ("[geo]", "providr = \"platform\""), + ("[device]", "providr = \"builtin\""), + ] { + let toml_str = format!( + "{}\n\n {section}\n {bad_key}\n", + crate_test_settings_str() + ); + assert!( + Settings::from_toml(&toml_str).is_err(), + "an unknown key in {section} should be rejected" + ); + } + + let toml_str = crate_test_settings_str().replace( + "[ec.providers.hmac]", + "[ec.providers.hmac]\n unexpected = \"value\"", + ); + assert!( + Settings::from_toml(&toml_str).is_err(), + "an unknown key in [ec.providers.hmac] should be rejected" + ); + } + #[test] fn validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( @@ -4415,7 +5002,9 @@ origin_host_header_overide = "www.example.com""#, let mut settings = Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); settings.publisher.proxy_secret = Redacted::new("unit-test-proxy-secret".to_owned()); - settings.ec.passphrase = Redacted::new("test-secret-key-32-bytes-minimum".to_owned()); + settings.ec.providers.hmac = Some(HmacProviderConfig { + passphrase: Redacted::new("test-secret-key-32-bytes-minimum".to_owned()), + }); settings.handlers[0].password = Redacted::new("replace-with-admin-password-32-bytes".to_owned()); @@ -5034,7 +5623,11 @@ origin_host_header_overide = "www.example.com""#, proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + "#, ) .expect("should parse settings without max_buffered_body_bytes"); @@ -5065,12 +5658,16 @@ origin_host_header_overide = "www.example.com""#, max_buffered_body_bytes = 0 [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ); + let error = result.expect_err("should reject a zero buffered-body cap"); assert!( - result.is_err(), - "publisher.max_buffered_body_bytes = 0 must fail config validation" + error.to_string().contains("max_buffered_body_bytes"), + "the rejection should be for the zero cap, not another validation, got: {error}" ); } @@ -6219,6 +6816,9 @@ origin_host_header_overide = "www.example.com""#, proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -6552,6 +7152,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -6636,6 +7239,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -6672,6 +7278,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -6714,6 +7323,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 5f094c0d2..f755a0bcd 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -31,7 +31,11 @@ pub mod tests { rewrite_attributes = ["href", "link", "url"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [request_signing] config_store_id = "test-config-store-id" secret_store_id = "test-secret-store-id" diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index d8e35d179..497205d4c 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -9,11 +9,20 @@ cookie_domain = "localhost" origin_url = "http://127.0.0.1:8888" proxy_secret = "integration-test-proxy-secret" +# The lifecycle scenarios need a resolved jurisdiction for the consent gate, +# and Viceroy supplies the host geo lookup. The permission model replaces this +# with a [geo] default_country baseline in the next PR of the series. +[geo] +provider = "platform" + [ec] -passphrase = "integration-test-ec-secret-padded-32" +provider = "hmac" ec_store = "ec_identity_store" pull_sync_concurrency = 3 +[ec.providers.hmac] +passphrase = "integration-test-ec-secret-padded-32" + [[ec.partners]] name = "Integration Test Partner" source_domain = "inttest.example.com" diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index acf7f5f4b..853a48ee9 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -43,6 +43,9 @@ fn test_settings() -> Settings { proxy_secret = "parity-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/docs/superpowers/specs/2026-07-30-pluggable-providers-design.md b/docs/superpowers/specs/2026-07-30-pluggable-providers-design.md new file mode 100644 index 000000000..5075c54bf --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-pluggable-providers-design.md @@ -0,0 +1,585 @@ +# Design Spec: Pluggable Edge Cookie, Device, and Geo Providers + +**Status:** Implemented in PR #1043 (Edge Cookie provider seam) and PR #1044 +(device and geo selection); revised against the implementation, 2026-08-25. +**Author:** Engineering +**Issue references:** #777, #778, #780, #781 +**Related specs:** `2026-07-30-permission-model-design.md`, +`2026-07-30-provider-migration-rollout-design.md`, +`2026-07-30-client-cycle-ec-resolve-design.md` +**Last updated:** 2026-08-25 + +> **Context.** PR #838 proposed a first implementation of this epic in a single +> change. Review of that PR surfaced design gaps this spec exists to close +> before a second implementation pass: an identity abstraction that owned +> minting but not recognition, per-adapter divergence in provider selection, +> silent misconfiguration modes, and speculative trait surface with no +> production caller. This spec is the authoritative statement of what the +> provider architecture must do; where it contradicts PR #838, this spec wins. +> The second pass has now landed (PR #1043 and PR #1044, with permission +> enforcement in PR #1045), and this revision restates the spec to match the +> implemented code. A final section records every divergence from the +> 2026-07-31 draft. + +--- + +## 1. Overview and goals + +Trusted Server makes three per-request data decisions that were previously +hard-wired: whether to create or keep an Edge Cookie (EC) identity, how to +classify the requesting device, and whether to resolve geolocation. Each is +now a **provider**, a selectable component chosen in operator configuration, +with a deliberately neutral default. + +Goals, as implemented: + +- A deployment picks an implementation per concern (including none) without a + code change to Trusted Server core. +- Defaults are neutral. With no configuration, no EC is created, device + classification uses only the User-Agent, and no geolocation is performed. A + default deployment makes no third-party or host-specific call. +- An **EC provider declares** the permissions its data use requires + (`required_permissions` on the trait), and **core enforces** that + declaration before minting or using an identity. A provider cannot + authorize itself. The enforcement machinery is the permission model's + subject and lands with it in PR #1045 (see the permission model spec). + Geo and device carry the same declaration method with an empty default, + for the reasons spelled out in section 5. +- All adapters (Fastly, Axum, Cloudflare, Spin) route selection through the + same core builders, so identical configuration selects identical providers + everywhere. A selection the deployment cannot satisfy fails loudly rather + than degrading. The EC API routes (identify, batch-sync, ec/resolve) are + registered by the Fastly entry point only today, because the portability + adapters do not yet wire a platform KV store. The Spin adapter's route + list documents that gap explicitly rather than leaving those paths silent. + +Non-goals: + +- No vendor provider ships in this epic beyond the host-platform + implementations named below. The `crates/edgecookie/` directory holds a + README describing where vendor EC crates will live. +- The client-cycle (browser round-trip) provider type has its own spec. The + trait ships the seam for it (`resolve_from_client`, a no-op by default) + and a demonstration provider (`client-fixed`) compiled only into test and + demonstration builds. Production selection of the demo provider is a + startup error. + +## 2. Provider taxonomy + +| Concern | Trait | Built-in default | Opt-in implementations | +| ----------- | -------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| EC identity | `EdgeCookieProvider` | none (stateless) | `hmac` (in core, HMAC over client IP, preserves today's identity), `host-signals` (in core, see below), and `client-fixed` (demo builds only) | +| Device | `DeviceProvider` | `builtin` (User-Agent only) | `fastly` (TLS JA4 and HTTP/2 signals through an injected `HostSignals` service) | +| Geo | `PlatformGeo` | none (no location) | `platform` (host geo lookup) | + +The geo trait is the existing `PlatformGeo` in `platform/traits.rs` rather +than a new `GeoProvider` name. The EC trait lives in `ec/provider.rs` and the +device trait in `ec/device.rs`. + +Selection keys are strings in operator configuration +(`trusted-server.example.toml` carries the commented template): + +```toml +[ec] +provider = "hmac" + +[ec.providers.hmac] +passphrase = "replace-with-32-plus-byte-random-secret" + +[device] +provider = "builtin" # default. "fastly" opts into TLS/H2 signal evidence + +[geo] +provider = "platform" # default is none (no location, no host call) +default_country = "FR" # required (section 6) +# assume_single_jurisdiction = true # required when EC runs with no geo +``` + +**The `host-signals` EC provider** (identity from HMAC over the host TLS JA4 +and HTTP/2 fingerprints plus the client IP) was deliberately dropped from the +2026-07-31 draft. It has since shipped in PR #1044 as an opt-in built-in +(`[ec.providers.host-signals]`), implemented against the host-agnostic +`HostSignals` capability rather than a Fastly API, so any host that supplies +the fingerprints can run it and a host that supplies none cannot build it. +When the host supplies no fingerprint at all the provider defers with a +warning instead of degrading to an IP-only identifier under the host-signals +name. **An open review question stands on whether this provider should ship +in the series at all**, because its identifier shape shares the built-in +HMAC grammar and a sign-off row defers host fingerprint processing. The +question is flagged for the series review and this spec does not present +the provider as settled either way. + +## 3. The identity lifecycle contract + +This is the section PR #838 lacked. Its trait abstracted **minting** an +identifier but left **recognition** and **KV key normalization** hard-coded +to the built-in HMAC shape, so a provider whose identifiers did not match +`{64hex}.{6alnum}` minted cookies that the very next request discarded. + +The implemented contract routes every lifecycle operation core performs on +an EC value through the selected provider: + +| Lifecycle operation | Where core uses it | Contract | +| ------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Mint** | EC generation on first eligible request, and the client-cycle resolve endpoint | The provider returns the identifier (`generate` server-side, `resolve_from_client` for the client cycle) and only core writes the cookie, after enforcing the global bounds below. | +| **Recognize** | Reading `ts-ec` back from the request, deciding `ec_was_present`, withdrawal checks | `accepts_id` answers whether a value is a well-formed identifier the provider issues. A value the selected provider does not recognize is treated as absent, so it is never used or egressed, while the raw cookie value stays visible to withdrawal handling. | +| **KV key** | Identity-graph row reads and writes | `normalize_id_for_kv` returns the key form. The default lowercases the built-in HMAC hash segment and preserves the suffix, keeping today's keys. An opaque or case-sensitive provider overrides to the identity function so distinct identifiers never collapse into one row. | +| **Withdraw** | Expiring the cookie and writing revocation markers | The identifiers eligible for withdrawal are exactly those the selected provider accepts through `accepts_id`, never a shape check the provider cannot influence. | + +**Invariant:** for every provider `P` and every identifier `id` minted by +`P`, `id` round-trips read-back byte for byte. A test in `ec/mod.rs` proves +the round-trip with a non-default provider whose identifiers are opaque, and +a second test in `ec/resolve.rs` proves the client-cycle value survives the +full scenario verbatim. + +The draft's richer lifecycle surface, a canonicalizing `parse` with +per-provider equivalence fixtures, a core-constructed graph key built from a +provider `graph_key_suffix`, a declared cluster-prefix capability, declared +namespace descriptors with a startup disjointness proof, and a reusable +conformance suite driven by fixtures, is **not implemented in these PRs**. +Recognition plus KV normalization proved sufficient for the operations core +actually performs today, and each deferred piece is tracked as follow-up +work rather than silently dropped (see the revision record). Until the key +grammar lands, the KV key is the provider's normalized identifier verbatim, +which keeps every pre-epic HMAC row reachable, and the pre-epic IP-cluster +prefix listing (keyed on the 64-hex HMAC hash prefix) continues unchanged. + +One global rule sits above every provider, and it is implemented: + +- **Identifier bounds.** A minted identifier obeys a global cookie-safe + alphabet (normatively `[A-Za-z0-9._~-]`, valid cookie octets with no + separators, whitespace, or control characters) and a global maximum of + **256 bytes**, stated here so dependent documents reference one number. + The bound applies to the identifier itself, not only its key form. Core + enforces the bound wherever an identifier enters the system, at mint + (both `generate` and the resolve endpoint), at cookie read-back, and at + cookie write. The constant is `MAX_EC_ID_LEN` in `ec/cookies.rs`. A + violating value is rejected outright and logged. No sanitizing rewrite + exists anywhere on the path, so an identifier survives byte for byte or + not at all, and the cookie value and the identity-graph key can never + silently diverge. + +## 4. Trait surface: minimalism rule + +Every trait method must have at least one production (non-test) caller in +the same change that introduces it. How the surface observed in PR #838 +resolved in the implementation: + +- `keys_equal`: **not shipped.** Its legitimate purpose (equivalent-envelope + comparison, #778) is served structurally, because read-back acceptance and + KV normalization both route through the provider, so no comparison method + exists to leave uncalled. +- `GeneratedEdgeCookie::response_headers`: **shipped, with a production + caller.** EC finalization applies provider-requested headers to the + outbound response, and the client-cycle resolve path returns them, which + is how a client-side provider requests further evidence from the page. + The draft banned the field when nothing consumed it. The consumer landed + in the same series, satisfying the rule the ban enforced. +- `IdentityInput.permissions` / `IdentityInput.consent`: **shipped and + populated.** The organic mint path passes the request's resolved + permission state and consent context so a provider can read them for + behavior beyond gating. The gate itself has already run before `generate` + is called, so a provider cannot use the fields to authorize itself. +- `required_permissions` on `DeviceProvider` and `PlatformGeo`: **present, + with an empty default and no enforcement point.** The draft removed the + method from both traits because PR #838's copies were decorative. The + implementation keeps one uniform declaration seam across all three traits + instead. The built-in device and geo providers declare empty sets, and + core enforces the declaration only for the EC provider (section 5), so + nothing reads as a gate that is not one. The geo circularity argument + stands unchanged and is restated in section 5. + +The implemented `EdgeCookieProvider` surface (`ec/provider.rs`): + +```rust +pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { + /// Stable configuration key ("hmac"). + fn id(&self) -> &'static str; + /// Registered four-character code (provider-code-registry.md), the + /// `{code}~` namespace of every identifier the provider mints. + /// Mandatory, no default: a provider cannot exist without a unique + /// code, so identifiers from different providers can never collide. + fn code(&self) -> ProviderCode; + /// Derives an identifier from the provider's injected services and the + /// request evidence passed at call time. A client-side provider defers + /// here (returns no id) and mints later in resolve_from_client. + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result>; + /// Whether `value` is a well-formed identifier this provider issues. + /// Default: the built-in HMAC shape (`<64 hex>.<6 alphanumeric>`). + fn accepts_id(&self, value: &str) -> bool { /* built-in shape */ } + /// The KV-key form of `value`. Default: lowercase the HMAC hash + /// segment, preserve the suffix. Opaque providers return the value + /// unchanged. + fn normalize_id_for_kv(&self, value: &str) -> String { /* ... */ } + /// Permissions this provider's data use requires. Default: none, so a + /// vendor-neutral provider requires no permission. + fn required_permissions(&self) -> PermissionSet { /* none */ } + /// Client-cycle counterpart to generate: mints from a value the page + /// posted to the resolve endpoint, after verifying it. Default: no-op, + /// so a server-side provider does not participate. See the + /// client-cycle spec. + fn resolve_from_client( + &self, + input: &ClientResolveInput<'_>, + ) -> Result> { /* ... */ } +} +``` + +Core owns the code envelope. At mint it prefixes the provider's value with +`{code}~`, at read-back it strips and checks the code before the provider's +`accepts_id` sees the value part, and the identity graph key preserves the +code verbatim around the provider's canonical form. A cookie carrying +another provider's code is treated as absent, never adopted, so switching +providers cannot silently mix identity populations, and a withdrawal always +acts on a key that can only belong to one provider. The built-in HMAC +provider mints `hmac~<64 hex>.<6 alphanumeric>` and dual-reads its +pre-envelope bare form for one release cycle so deployed cookies keep +working; the bare form belongs to hmac alone. Codes are allocated +append-only in `provider-code-registry.md`, and a leading digit is valid +(`51dd`). + +The draft's alternative shape (`parse` returning a typed `EcId`, +`graph_key_suffix`, `cluster_prefix`, `verify`, and a version-carrying +`GeneratedIdentity`) was not adopted. `verify`, provider versions, and +`mint_version` are tracked follow-up work with the migration spec. +Request data reaches a provider through injected services and the +`RequestInfo` passed at call time, not through a fixed parameter struct, so +a provider can read headers, cookies, client hints, and the URL without a +trait change. + +## 5. Permission enforcement is core's job, for EC providers + +Before minting through an EC provider, core resolves the request's +permission state and refuses when the provider's `required_permissions()` +are not all set. The gate is implemented in `EcContext`. The selected +provider is built once at request read time, its declaration is checked +against the resolved state, and generation is skipped (with a log line +naming the jurisdiction) when the requirement is not met. With no provider +selected, nothing may mint or use an identifier, so the gate is closed +rather than open by default. The enforcement point lands with the +permission model in PR #1045, and the permission model spec governs the +resolution machinery (country and region baselines, signals, and the +requires-signal floor). + +**Recognition and withdrawal always run**, permissions or not. Read-back +acceptance and withdrawal eligibility go through `accepts_id` with no +permission check, and withdrawal handling keeps the raw cookie value even +when the identifier is treated as absent, so an opt-out can always reach +the identity it revokes. A blanket execution gate would refuse to run the +provider in exactly the state an opt-out produces. + +The draft additionally specified an identity activation protocol (a +two-record commit point before any egress), rowless-cookie classification +and per-prefix withdrawal records, negative-record admission rules, and a +typed egress boundary (`AuthorizedIdentity`, +`RedactedRequestView`). **None of that is implemented in these PRs.** +Those positions remain recorded in the draft and are tracked as follow-up +work with the permission model spec, which owns identity-state persistence +and egress typing. The revision record lists them as deferred. + +The gate applies to EC providers **only**. Geo and device are ungated for +two different reasons, stated separately because only one of them is +structural: + +- **Geo: circularity.** The permission set is resolved from jurisdiction, + which is resolved by the geo provider. Gating geo on the resolved set is + unsatisfiable. `PlatformGeo::required_permissions` exists with an empty + default for interface uniformity, and nothing consults it on the lookup + path. +- **Device: host evidence is an explicit opt-in, not authorized by + selection defaults.** Device classification is not an input to permission + resolution. The neutral `builtin` classifier reads only the User-Agent + and makes no host call. The draft went further and made selecting a + fingerprint-reading device provider a startup error pending a separate + security design. The implementation instead ships `[device] provider = +"fastly"` as a selectable opt-in. The Fastly adapter injects a + `HostSignals` service carrying the TLS JA4 and HTTP/2 fingerprints, and + the provider uses them to strengthen the browser/bot gate that guards EC + writes. Identity rows persist the derived classification fields (the JA4 + class segment and a 12-hex-character hash prefix of the HTTP/2 SETTINGS + fingerprint), not raw fingerprints, and the neutral default persists + neither because the builtin provider produces no such fields. + +## 6. Selection, validation, and failure modes + +All configuration validation happens at **settings construction** +(`Settings::finalize_deserialized` runs every check below), so a +misconfiguration expressible in configuration alone is a startup error, +never a silent behavior change. A selection that only the running host can +satisfy (an injected vendor provider, or host fingerprints) fails loudly +when the provider is built, stopping the request rather than degrading. + +| Configuration state | Behavior | +| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[ec] provider` set, its `[ec.providers.]` block missing | Startup error naming the missing block. There is no closed key list in core for EC, because a vendor key is legitimate when its block is present, so an unknown key with no block fails this same check. | +| `[ec.providers.]` block present, `provider` unset | **Startup error.** (In PR #838 this silently ran stateless. The half-migrated config becomes a production identity outage detected by revenue drop. Rejecting it is the fix.) An operator who genuinely wants stateless deletes the block. | +| `provider = "none"` (explicit stateless) | Valid, and means exactly what omitting the selector means. Any configured provider block alongside it is a startup error, the same stray-block rule as below. | +| A configured `[ec.providers.]` block that is not the selected one | **Startup error** (checked for the `hmac` block and every vendor block). An unreferenced block is almost always a mistyped selector or a stale block, and accepting it silently invites configuration drift. | +| A selected vendor key whose provider the adapter did not inject | Loud failure when the provider is built, naming the key, so the deployment never silently runs stateless. | +| `provider = "host-signals"` on a host that supplies no fingerprints | Loud failure when the provider is built. A host that cannot produce `HostSignals` cannot run the provider. | +| `provider = "client-fixed"` in a production build | Startup error. The demonstration provider is compiled only behind the `client-fixed-demo` cargo feature. | +| No `provider`, no providers block | Valid, the neutral default for that concern. | +| Deprecated `[ec] passphrase` | Migrated to `provider = "hmac"` with the passphrase in `[ec.providers.hmac]`, with a deprecation warning naming the new location. Both forms together are rejected so a half-edited file fails loudly instead of one form silently winning. | +| Any unknown key in `[ec]`, `[device]`, `[geo]`, or a built-in provider block | Startup error. `deny_unknown_fields` is on `Ec`, `DeviceConfig`, `GeoConfig`, and both built-in provider config structs, so a typo like `providr`, or a key from a deferred feature (`legacy_providers`, `rewrite_legacy`, `versions`), fails loudly. | +| `[device] provider` names an unknown key | Startup error. Valid keys are `builtin` (default) and `fastly`. | +| `[geo] provider` names an unknown key | Startup error. Valid states are unset (default, no geolocation), `none` (the same, spelled out), and `platform`. | +| `[geo] default_country` unset, or matching no `permissions.yaml` rule | **Startup error.** The value is the permission baseline for a request the geo provider leaves unmatched, so there must always be one and the value must resolve to a real rule. | +| An EC provider configured, no geo provider, `assume_single_jurisdiction` unset | **Startup error.** With geolocation off, every request resolves as `default_country`, so a visitor from any other jurisdiction silently receives the default jurisdiction's rules. That is acceptable only as an explicit operator decision. | + +One draft row was not adopted, the startup error for a minting provider +with no identity-graph store. `[ec] ec_store` remains optional, because the +portability adapters run without platform KV. The client-cycle resolve +endpoint refuses to mint when no graph is available (a cookie without a row +could never be withdrawn through the graph), and the organic path persists +the row whenever the graph is configured. Whether configuration should +force the pairing is follow-up work with the migration spec. + +Vendor provider blocks deserve their own note. Any `[ec.providers.]` +block whose key is not a built-in is captured in core as raw values (a +flattened map), and the adapter that injects the vendor provider +deserializes its own block into the vendor crate's config type. Core never +names a vendor, so a new provider adds nothing to core. The vendor crate +applies its own `deny_unknown_fields` when it deserializes. + +### 6.1 Provider switching: active writer, legacy readers + +Switching `[ec] provider` must not strand the identities the previous +provider minted, and above all must not make a later opt-out unable to +revoke them. The draft specified an ordered `legacy_providers` reader list, +provider `versions` with `mint_version` rotation, provenance tagging, and +retirement evidence rules. **None of that is implemented in these PRs.** +The keys are rejected as unknown, and the design is tracked follow-up work +with the migration spec. + +What the implementation provides today is shape-based continuity. +Read-back and withdrawal go +through the selected provider's `accepts_id`, so after a switch the old +cookies remain recognized exactly when the newly selected provider accepts +their shape (a provider inheriting the default accepts the built-in HMAC +grammar). Old cookies the new provider does not recognize are treated as +absent and never egress. The `cluster_fallback` degradation policy from the +draft is likewise deferred with the cluster capability itself. + +### 6.2 Runtime failure modes + +Startup validation covers configuration. This covers a healthy +configuration meeting an unhealthy runtime. Implemented behavior, each row +logged, none silent: + +| Failure | Behavior | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `generate` returns an error | No identity this request. The organic caller logs at error level and the request proceeds stateless. No cookie is written. | +| A provider mints an identifier outside the global bounds | Rejected at mint, never rewritten. The organic path yields no identity. The resolve endpoint returns 400. | +| Identity-graph write fails at mint | The mint is undone (no identifier, no cookie), with the error logged. The resolve endpoint returns 503. The next eligible request retries. | +| The host-signals provider finds no TLS/HTTP-2 fingerprints | Defers with a warning. No identity this request, and no degraded IP-only identifier is minted under the host-signals name. | +| Geo lookup **fails** (the provider errors) | Every permission resolves to the requires-signal floor, and the failure is logged at error level. The failure is **not** papered over with the `default_country` baseline. | +| Geo resolves **no location**, or a country/region with no rule | The `[geo] default_country` baseline applies. This is the configured-default case, deliberately distinct from the failure row above (`GeoStatus` in `ec/consent.rs`). | +| An incoming cookie value fails the bounds at read-back | Treated as absent, with a warning naming the source. | + +The distinction between a failed lookup and no location is resolved in +core, where `EcContext::read_from_request_resolving_geo` runs the +configured geo provider itself and classifies the outcome, so every +adapter reports the two states identically. The draft's remaining matrix rows (rowless +withdrawal records, promotion, the negative-intent outbox, the identity +safety breaker, cluster-listing degradation) belong to the deferred +material of sections 5 and 6.3. + +### 6.3 Storage contract + +The draft specified a delimiter-free physical key grammar with fixed-width +segments, a provider-code registry, record classes for family revocation, +authority state, negative-intent outbox, rowless withdrawal, and deployment +metadata, wire schemas with known-answer vectors, and a per-field graph-row +contract. The provider-code registry is now implemented: codes are +allocated in `provider-code-registry.md`, carried as the `{code}~` prefix +of every minted identifier, and therefore present in every graph key. The +key grammar differs from the draft in one deliberate way, a tilde separator +instead of delimiter-free fixed width, because pre-envelope bare +identifiers remain deployed and a code such as `51dd` is valid hex, so +delimiter-free parsing could misread a legacy identifier during the +migration window. The remainder (record classes, family revocation, +authority state, outbox, rowless withdrawal, wire schemas, per-field +contract) is not implemented in these PRs and stands as recorded design for +the follow-ups. + +The implemented storage today keys the identity graph by the selected +provider's `normalize_id_for_kv` output verbatim. For the built-in HMAC +provider that is the identifier with the hash segment lowercased, which is +today's key, so every pre-epic row stays reachable and the pre-epic +cluster prefix listing stays intact. For an opaque provider the identifier +itself is the key. Rows carry the same JSON envelope as before the epic, +extended with the derived device-classification fields noted in section 5. + +## 7. Composition root and adapter parity + +Provider construction happens in one place per concern, in core, called by +every adapter. No adapter wires a concrete implementation directly into the +request path: + +- `build_provider` (`ec/provider.rs`) constructs the selected EC provider, + injecting the host's `HostSignals` when supplied and matching an + adapter-injected vendor provider by its `id()`. The provider is built + once per request during `EcContext` construction and reused for + read-back, the permission gate, and minting, so the per-request + triple-build observed in PR #838 (cloning the secret into a fresh box up + to three times per request) is gone. +- `build_device_provider` (`ec/device.rs`) returns the builtin classifier + unless `fastly` is selected, in which case the adapter's closure builds + the host-evidence provider. +- `build_geo_provider` (`platform/mod.rs`) returns `DisabledGeo` unless + `platform` is selected, in which case the adapter's host geo + implementation is used. All four adapters (Fastly, Axum, Cloudflare, + Spin) route their host geo through this selector when they assemble + their runtime services, verified in each adapter's platform wiring. + +All four adapters construct the EC request state through the same core +constructors (`EcContext::read_from_request_resolving_geo` and its +variants), so selector behavior and the geo failure classification are +identical everywhere. The cross-adapter parity suite +(`trusted-server-integration-tests`) asserts geo response parity across +adapters. The EC API routes are Fastly-only today, as section 1 notes, and +the Spin adapter's route list records why. + +The draft's adapter capability matrix (declared per-record-class +consistency semantics, durability and retention proofs, activation and +lease qualification) is **not implemented in these PRs** and is tracked +follow-up work. The matrix's motivating rule is preserved for that +follow-up, which is that "has KV" says nothing about whether a revocation +is observable, so eligibility for identity features must eventually be +declared and checked, not assumed. + +## 8. Crate layout and CI + +Host and vendor provider crates live in nested directories grouped by +capability, with flat package names following the existing convention: + +- `crates/device/fastly` is package `trusted-server-device-fastly`. +- `crates/geo/fastly` is package `trusted-server-geo-fastly`. +- `crates/edgecookie/` is the documented home for vendor EC + crates. The directory currently holds only a README, because the + built-in providers live in core and no vendor crate exists yet. + +The draft mandated flat directories (`crates/trusted-server-geo-fastly`) +and banned placeholder directories. The implementation diverges on both +points. Nested directories scale per vendor as providers multiply, package +names already carry the flat convention, and the README stakes out the +location before the first vendor crate lands. Both divergences are +recorded in the revision table. + +Every new crate is in the `.cargo/config.toml` aliases (`check-fastly`, +`clippy-fastly`, `test-fastly`, `build-fastly`), so the provider crates are +linted with `-D warnings` and tested by the same gates as every other +workspace member, closing the PR #838 gap where new crates compiled only +transitively. + +## 9. Behavior preservation notes + +Two defaults chosen for neutrality change effective behavior on existing +Fastly deployments. Both are called out in the migration spec and must be +prominent in release notes: + +- **Bot gate.** The pre-provider EC bot gate required JA4 and platform + class. The default `builtin` classifier is User-Agent only, so the gate + is weaker by default. The stronger gate is available as `[device] +provider = "fastly"` rather than being startup-rejected as the draft + specified. Release notes call out the weaker default rather than + presenting selection alone as authorization. +- **Geo.** With no geo provider, jurisdiction resolution falls to the + required `[geo] default_country`. The permission model constrains the + combination so it cannot silently grant permissions to mis-attributed + traffic. The default must resolve to a real `permissions.yaml` rule, a + deployment running an EC provider without geo must set + `assume_single_jurisdiction = true`, and a failed lookup resolves to the + requires-signal floor instead of the default. The default flip landed in + the same series as those constraints, honoring the draft's sequencing + requirement that the constraint exist before the flip. + +## 10. Testing strategy + +Implemented, in the crates named: + +- Round-trip tests with a non-default provider, proving an opaque + identifier survives read-back byte for byte (`ec/mod.rs`) and the + client-cycle value survives the full scenario as cookie and KV key + (`ec/resolve.rs`). +- Delegation tests proving the injected-provider wrapper forwards + `accepts_id` and `normalize_id_for_kv` to the inner provider, so a + vendor identifier is never dropped by the built-in defaults. +- Gate tests proving the HMAC provider's declared requirement blocks + generation until the permission is set, and that a provider declaring + nothing requires nothing. +- Settings validation tests covering the section 6 table, including the + missing block, the block without a selector, explicit `none`, the stray + block, unknown selector keys for all three concerns, unknown fields in + every section, the deprecated passphrase migration with its both-forms + rejection, `default_country` validation, and the jurisdiction + acknowledgment. +- Geo builder tests showing the default selects no geo, `none` selects no + geo explicitly, and `platform` selects the host implementation. +- Host-signals provider tests covering minting from fingerprints, + deferring without them, and the loud failure of a selected but + uninjected vendor provider. + +Deferred with their features are the fixture-driven provider conformance +suite, legacy-reader tests, and the parity cases for capability-mismatch +startup failures. + +## 11. Implementation order + +As landed: + +1. **PR #1043, the Edge Cookie provider seam.** The trait with recognition + and KV normalization, the global identifier bounds, selection and + validation, the vendor block capture, the deprecated-passphrase + migration, and the round-trip proof with a non-default provider. +2. **PR #1044, device and geo selection.** `DeviceProvider` with the + builtin default and the opt-in Fastly host-evidence provider, + `PlatformGeo` selection with the no-geo default, all four adapters + routed through the shared builders, and the opt-in host-signals EC + provider (carrying the open review question of section 2). +3. **PR #1045, the permission model.** The enforcement point for + `required_permissions`, the `default_country` requirement and + jurisdiction acknowledgment, and the failed-lookup floor. That change + has its own spec, which this document cross-references rather than + restates. + +The draft's step 4 warning (do not flip the geo neutral default before the +permission model exists) was honored. The flip and its constraints landed +together in the permission model change. + +## 12. Divergences from issue #778 + +This spec supersedes #778 on the following points, so implementation has +one acceptance contract: + +| #778 says | This spec says | Why | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Identifier comparison is a provider operation (`keys_equal`) | Comparison is structural. Read-back acceptance and KV normalization route through the provider, so no comparison method exists (§3, §4) | Satisfies the same requirement with no method to leave uncalled | +| A provider can return response headers | Kept, with a production consumer. EC finalization applies them, and the client-cycle path uses them (§4) | The caller the minimalism rule demands landed in the same series | +| One built-in provider (HMAC) preserving today's behavior | HMAC preserved verbatim, plus the opt-in host-signals built-in (open question, §2) and the demo client-cycle provider | Switching semantics beyond shape-based recognition (`legacy_providers`) remain follow-up work with the migration spec | + +## 13. Revision record vs the 2026-07-31 draft + +One row per divergence between the 2026-07-31 draft and the implementation +this revision describes. + +| Draft position | Implemented position | Why | +| ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Trait surface is a canonicalizing `parse` returning a typed id, plus `graph_key_suffix`, `cluster_prefix`, and `verify` | `accepts_id` (recognition) plus `normalize_id_for_kv` (KV key form), defaults matching the built-in shape. No typed id, key suffix, cluster capability, or `verify`. `keys_equal` stays out, as the draft required. | Recognition and KV keying are the two operations core performs today. A byte-for-byte round-trip test with a non-default provider pins the contract. | +| `GeneratedEdgeCookie::response_headers` and `IdentityInput.permissions` / `.consent` banned as speculative surface | Shipped with production consumers. Finalization applies provider headers, the resolve path returns them, and the organic mint path populates the input fields. | The client-cycle resolve path landed in the same series and is their caller, satisfying the minimalism rule the ban enforced. | +| Identifier bounds enforced at mint and parse | Enforced at mint (`generate` and the resolve endpoint), cookie read-back, and cookie write. Violations rejected outright, never rewritten. `MAX_EC_ID_LEN` in `ec/cookies.rs`. | Every identifier entry point is covered, and the pre-epic sanitizing rewrite was removed as a silent-divergence hazard. | +| `provider = "none"` is valid alongside `legacy_providers` blocks | `none` (or an omitted selector) with any configured provider block is a startup error. | No `legacy_providers` exists in these PRs, so a block alongside statelessness can only be a mistake. | +| Every selection key is closed and unknown keys are startup errors | Device and geo keys are closed. EC vendor keys are open. Unknown blocks are captured as raw values in core, the adapter deserializes its own block, and a selected key with no injected provider fails loudly. | Core never names a vendor, so a vendor provider adds no core change. | +| Capability mismatch is a startup error at adapter wiring time | Configuration coherence fails at startup. A host-capability mismatch (missing `HostSignals`, uninjected vendor) fails loudly when the provider is built, stopping the request. | The adapter capability declaration that would move the check to startup is deferred with the capability matrix. | +| A minting provider with no identity-graph store is a startup error | Not implemented. `ec_store` stays optional. The resolve endpoint refuses to mint without a graph. The organic path persists rows whenever the graph is configured. | Portability adapters run without platform KV. Whether configuration should force the pairing is follow-up work. | +| `[device] provider = "fastly"` is startup-rejected pending a separate security design | Shipped as a selectable opt-in. The Fastly adapter injects `HostSignals`, the provider strengthens the browser/bot gate, and rows persist derived classes, not raw fingerprints. | Selection is an explicit operator opt-in and the neutral default makes no host fingerprint call. | +| The `host-signals` EC provider is deliberately dropped and its selection rejected | Shipped in PR #1044 as an opt-in built-in that defers with a warning when the host supplies no signals. **Open, flagged for the series review**, not settled either way. | Its identifier shape shares the HMAC grammar, and a sign-off row defers host fingerprint processing, so the review decides whether the provider ships in the series. | +| Geo default flip sequenced into the later permission-model step, with an acknowledgment guard | Landed as specified in the same series, with the default of none, `default_country` required and validated against `permissions.yaml`, the `assume_single_jurisdiction` acknowledgment, and a failed lookup resolving to the requires-signal floor with error logging (`GeoStatus`, resolved in core so all adapters agree). | The permission model shipped in PR #1045, so the constraints exist where the draft required them. | +| All adapters serve the full EC feature set identically | Selector behavior is identical through the shared builders and core constructors. The EC API routes (identify, batch-sync, ec/resolve) are Fastly-only, documented in the Spin route list. | The portability adapters do not yet wire platform KV, and the gap is documented rather than silent. | +| Conformance suite, adapter capability matrix, delimiter-free key grammar, `verify`, `legacy_providers`, `versions` / `mint_version` | None of these are in PR #1043 or #1044. All are tracked follow-up work, deferred, not silently dropped. | The shipped seam did not need them, and each returns with the feature that gives it a production caller, per the spec's own minimalism rule. | +| `required_permissions` removed from the device and geo traits, added to the EC trait only at the permission-model step | Present on all three traits from the start, with empty defaults. Core enforces the EC declaration (gate in `EcContext`, landing in PR #1045). No device or geo enforcement point exists. | One uniform declaration seam, with an empty default that gates nothing, avoids the decorative-gate hazard while keeping the interface stable. The geo circularity stands. | +| Flat crate directories (`crates/trusted-server-geo-fastly`), no placeholder directories | Nested directories per capability (`crates/device/fastly`, `crates/geo/fastly`, `crates/edgecookie/`), flat package names. `crates/edgecookie` ships a README before its first crate. | Nested directories scale per vendor, package names already carry the naming convention, and the README stakes out the vendor location. | diff --git a/docs/superpowers/specs/provider-code-registry.md b/docs/superpowers/specs/provider-code-registry.md new file mode 100644 index 000000000..9cad450fb --- /dev/null +++ b/docs/superpowers/specs/provider-code-registry.md @@ -0,0 +1,30 @@ +# Provider-code registry (normative, append-only, never reused) + +Four-character codes (`[a-z0-9]`, zero-padded) that namespace Edge Cookie +identifiers. Every EC provider MUST allocate a code here before it can +exist: the `EdgeCookieProvider::code()` trait method is mandatory, and core +applies the code as the `{code}~` prefix of every identifier the provider +mints, checks it at read-back, and keys the identity graph with it. A +provider only ever sees its own value part, so identifiers from different +providers can never collide in the cookie, the graph, or a withdrawal, and +every identifier records which provider created it. + +Allocation is a reviewed commit to this file; codes are immutable and never +recycled, including for retired providers. A leading digit is valid. The +tilde separator keeps parsing exact while pre-envelope identifiers remain +deployed: a legacy bare identifier contains no tilde and dual-reads under +the built-in HMAC provider only. + +The class of provider expected to grow this table is one that consumes a +web-browser-supplied unique identifier, arriving either as a new web +platform feature or from a user-installed extension, delivered to the edge +through the client-cycle resolve path and verified by the provider before +minting. + +| Code | Provider | Allocated | Status | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------- | +| `hmac` | Built-in HMAC EC provider. Mints `hmac~<64 hex>.<6 alnum>`; dual-reads its pre-envelope bare form for one release cycle so deployed cookies keep working | 2026-08-02 | active | +| `hs00` | Built-in host-signal EC provider (opt-in; TLS JA4 plus HTTP/2 signals plus client IP) | 2026-08-25 | active | +| `cfix` | Client-fixed demonstration provider (compiled only behind the `client-fixed-demo` cargo feature) | 2026-08-25 | active, test and demo only | +| `51dd` | 51Degrees Identifier (51Did) vendor provider | 2026-08-25 | reserved | +| `t0..` | Prefix family reserved for in-tree test providers (`t0cc`, `t0op`, and similar); never valid in configuration | 2026-08-25 | reserved | diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 7c7e83635..f6a8226cb 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -11,13 +11,34 @@ origin_url = "https://origin.example.com" # origin_host_header_override = "www.example.com" proxy_secret = "change-me-proxy-secret" +# Device detection provider selection. The built-in provider classifies from +# the User-Agent alone and makes no host-specific call. The opt-in "fastly" +# provider strengthens the browser/bot gate with the host's TLS/H2 signals. +# [device] +# provider = "builtin" + +# Geo / IP intelligence provider selection. The platform host geo is the +# default. Set provider = "none" to resolve no location and make no host geo +# call. +# [geo] +# provider = "platform" + [ec] -passphrase = "trusted-server-placeholder-secret" +# Edge Cookie identity is OFF by default: with no provider selected, Trusted +# Server runs statelessly and generates no Edge Cookie. Activate one by +# uncommenting the selector AND its [ec.providers.] block together (a +# block with no selector is rejected at startup), or set the selector with the +# TRUSTED_SERVER__ec__provider environment variable. The built-in hmac provider +# is host-neutral; a vendor provider needs its own cargo feature. +# provider = "hmac" ec_store = "ec_identity_store" pull_sync_concurrency = 3 # cluster_trust_threshold = 10 # cluster_recheck_secs = 3600 +# [ec.providers.hmac] +# passphrase = "replace-with-32-plus-byte-random-secret" + # Example partner configuration. Replace the token before validating/pushing. # [[ec.partners]] # name = "Example Partner"