From 99b1acd69c34dac45c8830ffee7d07ec6a3caa07 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 19 Aug 2026 10:03:47 +0100 Subject: [PATCH 01/16] Add device and geo provider selection with the host-signal Edge Cookie provider Second slice of the PR 838 decomposition. Device classification and geolocation become selectable providers, mirroring the Edge Cookie provider seam: - [device] provider selects the classifier. The built-in default reads the User-Agent alone and makes no host call; the opt-in fastly provider strengthens the browser/bot gate with the host's TLS JA4 and HTTP/2 signals (crates/device/fastly). - [geo] provider selects geolocation. The host platform's lookup is the default, matching the behavior before the selector existed, and provider = "platform" spells the same choice explicitly (crates/geo/fastly wraps the Fastly host lookup behind the PlatformGeo trait). provider = "none" opts out entirely, so a client IP is never sent to any host geo service. The disabled-by-default flip ships with the permission model in the next slice, which adds the jurisdiction baseline that makes a no-geo deployment viable. - Every adapter routes its host geo through the same build_geo_provider selector: Fastly, Axum, Cloudflare, and Spin all honor [geo] provider identically, so the selector is not a Fastly-only behavior. - The provider configuration sections ([device], [geo], [ec.providers.hmac], [ec.providers.host-signals]) reject unknown keys at startup, so a mistyped key fails loudly instead of silently selecting a default. - The host-signal Edge Cookie provider arrives with the capability it needs: the Fastly adapter injects the TLS/HTTP-2 signals as a HostSignals service, and the provider mints from them plus the client IP. With no host signals at all it defers with a warning rather than degrading to an IP-only identifier. - Device signals move to a field-based DeviceSignals derived in the adapter (derive_ua_only for hosts without host signals). - The new crates join the fastly cargo aliases so they build, lint, and test in CI rather than compiling only transitively. --- .cargo/config.toml | 8 +- Cargo.lock | 19 + Cargo.toml | 4 + crates/device/README.md | 10 + crates/device/fastly/Cargo.toml | 18 + crates/device/fastly/src/lib.rs | 97 ++++ crates/fastly.toml | 13 + crates/geo/README.md | 20 + crates/geo/fastly/Cargo.toml | 19 + crates/geo/fastly/src/lib.rs | 45 ++ crates/trusted-server-adapter-axum/src/app.rs | 10 +- .../src/platform.rs | 14 +- .../src/app.rs | 17 +- .../src/platform.rs | 11 +- .../trusted-server-adapter-fastly/Cargo.toml | 2 + .../trusted-server-adapter-fastly/src/app.rs | 61 ++- .../trusted-server-adapter-fastly/src/main.rs | 87 +++- .../src/platform.rs | 38 +- crates/trusted-server-adapter-spin/src/app.rs | 14 +- .../src/platform.rs | 16 +- crates/trusted-server-core/src/ec/device.rs | 350 ++++++++++++-- crates/trusted-server-core/src/ec/mod.rs | 10 +- crates/trusted-server-core/src/ec/provider.rs | 454 +++++++++++++++--- crates/trusted-server-core/src/edge_cookie.rs | 6 +- crates/trusted-server-core/src/evidence.rs | 44 ++ crates/trusted-server-core/src/geo.rs | 66 --- .../trusted-server-core/src/platform/mod.rs | 84 ++++ .../src/platform/traits.rs | 7 + .../trusted-server-core/src/platform/types.rs | 31 ++ crates/trusted-server-core/src/settings.rs | 247 +++++++++- .../configs/trusted-server.integration.toml | 6 + trusted-server.example.toml | 12 + 32 files changed, 1571 insertions(+), 269 deletions(-) create mode 100644 crates/device/README.md create mode 100644 crates/device/fastly/Cargo.toml create mode 100644 crates/device/fastly/src/lib.rs create mode 100644 crates/fastly.toml create mode 100644 crates/geo/README.md create mode 100644 crates/geo/fastly/Cargo.toml create mode 100644 crates/geo/fastly/src/lib.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 1302091e0b..bc8c20d715 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 8e1ceb1f3e..3a8c33f27a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5400,6 +5400,8 @@ dependencies = [ "serde_json", "sha2 0.10.9", "trusted-server-core", + "trusted-server-device-fastly", + "trusted-server-geo-fastly", "url", "urlencoding", ] @@ -5513,6 +5515,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 7faba75535..8e65872a95 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 0000000000..3aa4cb04ae --- /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 0000000000..3ff1b6e5e6 --- /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 0000000000..822e4cc37d --- /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/fastly.toml b/crates/fastly.toml new file mode 100644 index 0000000000..718e87ad30 --- /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 0000000000..f3c5d11fb5 --- /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 0000000000..d1e3c4f7c6 --- /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 0000000000..a93f7dd726 --- /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 a42d86d8b3..c3b7de7309 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -87,9 +87,9 @@ fn build_state_with_settings( // whose application state is built once at start-up while theirs is rebuilt // for every request. It injects and threads no provider, so `EcContext` // resolves the selection itself on every request, building a fresh built-in - // provider that reads no request data; this dev server accepts that - // per-request construction rather than caching a resolved provider. - ensure_provider_available(&settings.ec, None)?; + // provider that reads no request data. It supplies no host signals either, + // so the host-signals argument is `None`. + ensure_provider_available(&settings.ec, None, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -149,7 +149,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, @@ -726,7 +726,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build test request"); let ctx = RequestContext::new(req, PathParams::default()); - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &state.settings); let req = ctx.into_request(); let error = build_ec_context(&state, &services, &req) diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index a511daab2d..4886bd6e41 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-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index d517ce38b3..395a023204 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -18,7 +18,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; -use trusted_server_core::ec::provider::{EdgeCookieProvider, build_shared_provider}; +use trusted_server_core::ec::provider::{EdgeCookieProvider, build_reusable_provider}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -129,9 +129,10 @@ fn build_state_with_settings( // is served, so a selection this adapter can never supply fails here rather // than on the first request. Keeping what the resolution produced is what // stops the request path resolving the same settings again. This adapter - // injects no vendor Edge Cookie provider, so `None` is the injected - // argument, and one is passed here once this adapter supplies it. - let ec_provider = build_shared_provider(&settings.ec, None)?; + // supplies no host signals and injects no vendor Edge Cookie provider, so + // both arguments are `None`, and each is passed here once this adapter + // supplies it. + let ec_provider = build_reusable_provider(&settings.ec, None, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -149,9 +150,11 @@ fn build_state_with_settings( /// Builds the per-request services, carrying the Edge Cookie provider the /// composition root already resolved so the request path does not resolve -/// `[ec] provider` a second time. +/// `[ec] provider` a second time. Nothing is carried when the composition root +/// found nothing safe to keep, and the request path resolves for itself. fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { - build_runtime_services(ctx).with_resolved_ec_provider(state.ec_provider.clone()) + build_runtime_services(ctx, &state.settings) + .with_resolved_ec_provider(state.ec_provider.clone()) } /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, @@ -716,7 +719,7 @@ mod tests { // No resolved provider is threaded here, so the request path resolves // the selection itself, which is what an embedder driving core // directly does and where the loud failure has to stay. - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &settings); let req = ctx.into_request(); let error = build_ec_context(&settings, &services, &req) diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index fff0bfed12..658915b737 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-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 47cc609b26..d0a43e6703 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 1d1fa04dac..196f16c82a 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -114,7 +114,7 @@ use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::provider::request_provider; -use trusted_server_core::ec::provider::{EdgeCookieProvider, build_shared_provider}; +use trusted_server_core::ec::provider::{EdgeCookieProvider, build_reusable_provider}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::is_navigation_request; @@ -122,7 +122,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, @@ -206,11 +210,22 @@ pub(crate) fn build_state_from_settings( // Composition root: resolve the provider selection once, before any request // is served, so a selection this adapter can never supply fails here rather - // than on the first request. Keeping what the resolution produced is what - // stops the request path resolving the same settings again. This adapter + // than on the first request, and keep what the resolution produced so the + // request path does not resolve the same settings again. This adapter // injects no vendor Edge Cookie provider, so `None` is the injected // argument, and one is passed here once this adapter supplies it. - let ec_provider = build_shared_provider(&settings.ec, None)?; + // + // This adapter injects host signals on every request, so a startup instance + // with no captured fingerprints answers the only question the check asks, + // which is whether the service exists at all. That same emptiness is why + // `build_reusable_provider` hands back nothing for a provider built from + // those fingerprints, leaving it to be resolved per request against the + // fingerprints that request actually carried. + let ec_provider = build_reusable_provider( + &settings.ec, + Some(Arc::new(FastlyHostSignals::default())), + None, + )?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -292,6 +307,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); + let builder = RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) @@ -303,14 +335,19 @@ 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); + .client_info(client_info) + .host_signals(Arc::new(FastlyHostSignals::new(tls_ja4, h2_fingerprint))); // Hand every request the provider resolved at the composition root, so the // request path reuses that instance instead of resolving `[ec] provider` - // again. Nothing is set for a deployment that selects no provider, which - // resolves to nothing either way. + // again. Nothing is set for a deployment that selects no provider, or one + // whose provider is built from this request's own host signals, and both + // are resolved on the request path instead. match state.ec_provider.clone() { Some(provider) => builder.resolved_ec_provider(provider).build(), None => builder.build(), @@ -436,7 +473,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={})", @@ -753,7 +790,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); @@ -1578,7 +1615,7 @@ mod tests { // Resolved the same way the composition root resolves it, so this // router behaves like a served one. let ec_provider = - trusted_server_core::ec::provider::build_shared_provider(&settings.ec, None) + trusted_server_core::ec::provider::build_reusable_provider(&settings.ec, None, None) .expect("should resolve the Edge Cookie provider selection"); let state = Arc::new(super::AppState { auction_telemetry_sink: Arc::new( diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 1b49eba4dd..106af48bfc 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -8,14 +8,16 @@ use edgezero_core::body::Body as EdgeBody; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::env_config::EnvConfig; 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::{ @@ -23,13 +25,15 @@ 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_core::settings_data::config_store_name; +use trusted_server_device_fastly::{FastlyDeviceProvider, FastlyHostSignals}; mod app; mod backend; @@ -166,7 +170,42 @@ fn edgezero_main(mut req: FastlyRequest, env: &EnvConfig) { // request extensions for build_per_request_services and EC bot classification. let client_info = client_info_from_request(&req, resolved_client_ip); let client_ip = client_info.client_ip; - 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 @@ -284,8 +323,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 }) @@ -491,16 +534,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).with_headers(&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)] diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 638aed82be..ff00c543f1 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -9,14 +9,13 @@ 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, @@ -667,33 +666,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; fn single_utf8_header<'a>(req: &'a Request, name: &str) -> Option<&'a str> { let mut values = req.get_header_all(name); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index d11f73eff5..23f9322597 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -16,7 +16,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; -use trusted_server_core::ec::provider::{EdgeCookieProvider, build_shared_provider}; +use trusted_server_core::ec::provider::{EdgeCookieProvider, build_reusable_provider}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -101,9 +101,10 @@ fn build_state_with_settings( // is served, so a selection this adapter can never supply fails here rather // than on the first request. Keeping what the resolution produced is what // stops the request path resolving the same settings again. This adapter - // injects no vendor Edge Cookie provider, so `None` is the injected - // argument, and one is passed here once this adapter supplies it. - let ec_provider = build_shared_provider(&settings.ec, None)?; + // supplies no host signals and injects no vendor Edge Cookie provider, so + // both arguments are `None`, and each is passed here once this adapter + // supplies it. + let ec_provider = build_reusable_provider(&settings.ec, None, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -543,7 +544,8 @@ impl TrustedServerApp { /// composition root already resolved so the request path does not resolve /// `[ec] provider` a second time. fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { - build_runtime_services(ctx).with_resolved_ec_provider(state.ec_provider.clone()) + build_runtime_services(ctx, &state.settings) + .with_resolved_ec_provider(state.ec_provider.clone()) } fn build_router(state: &Arc) -> RouterService { @@ -1001,7 +1003,7 @@ mod tests { // No resolved provider is threaded here, so the request path resolves // the selection itself, which is what an embedder driving core // directly does and where the loud failure has to stay. - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &settings); let req = ctx.into_request(); let error = build_ec_context(&settings, &services, &req) diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 6c6d1345db..642c82b876 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -760,7 +760,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"))] @@ -789,7 +792,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, @@ -1039,7 +1048,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-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index fbefa9586a..bf705b41d6 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()).with_headers(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/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 37b44ffa95..fff9e89a19 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -215,9 +215,10 @@ impl EcContext { // Take the selected provider once. It is used here to decide whether // the incoming cookie value is a usable identifier, and again by // generation, which reuses this one rather than asking for another. - // Resolving needs no request data, so an adapter that resolved the - // selection while it built application state hands the same instance - // back here and nothing is built a second time on this request. + // `request_provider` hands back the instance the composition root + // resolved when there is one, and otherwise resolves the selection from + // this request's own services, the host signals among them, so a + // provider built from request evidence reads this request's evidence. let selected_provider: Option> = provider::request_provider(&settings.ec, services)?; @@ -948,8 +949,9 @@ mod tests { settings.ec.provider = Some(EcProviderSelection::from("opaque")); let ec_config = settings.ec.clone(); - let resolved = crate::ec::provider::build_shared_provider( + let resolved = crate::ec::provider::build_reusable_provider( &ec_config, + None, Some(Arc::new(OpaqueIdProvider)), ) .expect("the composition root should resolve the selection") diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 4cc209f942..bc43f8bf93 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -4,11 +4,12 @@ //! selected by configuration, with no default, and [`build_provider`] is the //! composition root that builds the selected one. A built-in provider is //! constructed from its `[ec.providers.]` block, and a vendor provider is -//! taken from the adapter that injected it. Construction reads no request data, -//! so a selection this deployment cannot satisfy fails at startup rather than -//! leaving it running without an identity. Fastly, Cloudflare and Spin resolve -//! the provider once per application state and thread the result. Axum and -//! embedders resolve per request. +//! taken from the adapter that injected it. A built-in provider that also needs +//! a host service, as the host-signal provider needs the [`HostSignals`] +//! fingerprints, is built only on a host that supplies that service. +//! Construction happens once, while application state is built, and reads no +//! request data, so a selection this deployment cannot satisfy fails at startup +//! rather than leaving it running without an identity. //! //! Request evidence reaches a provider at call time rather than at //! construction. [`EdgeCookieProvider::generate`] borrows a [`RequestInfo`], @@ -29,7 +30,7 @@ use serde::{Deserialize, Serialize}; use crate::consent::ConsentContext; use crate::error::TrustedServerError; -use crate::evidence::RequestInfo; +use crate::evidence::{HostSignals, RequestInfo}; use crate::redacted::Redacted; use crate::settings::Ec; @@ -109,7 +110,7 @@ impl From for String { } } -/// The configuration name of the provider still built into core. +/// The configuration name of the HMAC provider still built into core. /// /// The name lives in the same open-ended namespace every vendor provider name /// comes from, and nothing branches on it outside the resolution in @@ -118,15 +119,25 @@ impl From for String { /// built-in provider becomes a module of its own. pub const HMAC_PROVIDER_KEY: &str = "hmac"; +/// The configuration name of the host-signal provider still built into core. +/// +/// An ordinary name in the same open-ended namespace as [`HMAC_PROVIDER_KEY`], +/// spelled exactly the way a vendor crate spells its own, and nothing branches +/// on it outside the resolution in [`build_provider`]. It is also +/// [`HostSignalProvider::id`]'s return value, and it goes with that resolution +/// arm when the host-signal provider becomes a module of its own. +pub const HOST_SIGNALS_PROVIDER_KEY: &str = "host-signals"; + /// The provider names core supplies itself. /// /// A name in this list is already taken, so an adapter that injects a provider /// under one of them has two suppliers claiming a single name and -/// [`build_provider`] refuses the pair rather than picking one. The list grows -/// and shrinks with the resolution arms in [`resolve_named_provider`], and it -/// empties when the built-in HMAC provider becomes a module like every other -/// provider, at which point no name is reserved and every provider is injected. -const BUILTIN_PROVIDER_KEYS: &[&str] = &[HMAC_PROVIDER_KEY]; +/// [`build_provider`] refuses the pair rather than picking one. The list holds +/// one entry per resolution arm in [`resolve_named_provider`], so it grows and +/// shrinks with them, and it empties when the providers still built into core +/// become modules like every other provider, at which point no name is +/// reserved and every provider is injected. +const BUILTIN_PROVIDER_KEYS: &[&str] = &[HMAC_PROVIDER_KEY, HOST_SIGNALS_PROVIDER_KEY]; /// The registry code of the built-in HMAC provider. /// @@ -612,6 +623,23 @@ pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { /// sees its own value part. fn code(&self) -> ProviderCode; + /// Whether this provider was built from evidence about one request. + /// + /// Almost every provider is built from configuration and services that are + /// the same for every request, so one instance can be resolved once and + /// handed to all of them. [`HostSignalProvider`] is the exception, because + /// it is built from the TLS and HTTP/2 fingerprints of a single request and + /// answers `true` here. A composition root reads this through + /// [`build_reusable_provider`] to decide whether keeping the instance is + /// safe, and keeping a request-scoped one would serve every later request + /// from the first request's evidence. + /// + /// The default is `false`, which is right for a provider whose constructor + /// takes only configuration and long-lived services. + fn is_request_scoped(&self) -> bool { + false + } + /// Derives an Edge Cookie identifier from the request evidence in /// `request_info` and the gating context in `input`. /// @@ -711,6 +739,70 @@ impl EdgeCookieProvider for HmacProvider { } } +/// 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_PROVIDER_KEY + } + + // Built from the fingerprints of one request, so it is only ever valid for + // that request and must never be kept and reused. + fn is_request_scoped(&self) -> bool { + true + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("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(), + }) + } +} + /// Refuses an injected provider that claims a name core supplies itself. /// /// Two suppliers cannot own one name. Core ships the `hmac` provider, and once @@ -754,22 +846,23 @@ fn ensure_no_name_collision( /// Builds the Edge Cookie provider named by the `[ec] provider` selector. /// -/// This is the composition root for the Edge Cookie provider. It reads the -/// `[ec]` configuration and takes the adapter's optional injected provider, -/// resolving the selector to the built-in provider or the injected one. 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. +/// 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 named provider cannot be -/// built: a built-in name whose configuration block is missing, or a name this -/// deployment's adapter does not inject. Both fail loudly rather than leaving -/// the deployment running stateless under a selector that says otherwise. +/// built: a built-in name whose configuration block is missing, a built-in name +/// whose host capability this host does not supply, or a name this deployment's +/// adapter does not inject. All fail loudly rather than leaving the deployment +/// running stateless under a selector that says otherwise. pub fn build_provider( ec: &Ec, + host_signals: Option>, injected: Option>, ) -> Result>, Report> { ensure_no_name_collision(injected.as_deref())?; @@ -782,7 +875,9 @@ pub fn build_provider( // Every provider is named, and this is the one place a name is resolved // to an implementation. Nothing else in the codebase asks whether a // name is built in. - EcProviderSelection::Named(key) => Some(resolve_named_provider(key, ec, injected)?), + EcProviderSelection::Named(key) => { + Some(resolve_named_provider(key, ec, host_signals, injected)?) + } }; Ok(provider) } @@ -803,17 +898,19 @@ pub fn build_provider( /// # Errors /// /// Returns [`TrustedServerError::EdgeCookie`] when the name matches no provider -/// this deployment can build, or when a built-in name has no configuration -/// block. Both fail loudly rather than silently running stateless. +/// this deployment can build, when a built-in name has no configuration block, +/// or when a built-in name needs a host capability this host does not supply. +/// All fail loudly rather than silently running stateless. fn resolve_named_provider( key: &str, ec: &Ec, + host_signals: Option>, injected: Option>, ) -> Result, Report> { // The only place that knows a provider is built into core rather than - // supplied as a module. It disappears, along with `HMAC_PROVIDER_KEY`, - // when the HMAC provider becomes a module like every other provider, after - // which `hmac` resolves through the injected path below and nothing else + // supplied as a module. Each arm disappears, along with its name constant, + // when that provider becomes a module like every other provider, after + // which its name resolves through the injected path below and nothing else // changes. // // Settings validation rejects a built-in name with no block before this @@ -832,6 +929,30 @@ fn resolve_named_provider( return Ok(Box::new(HmacProvider::new(config.passphrase.clone()))); } + // The host-signal provider needs fingerprints only some hosts supply, and + // that check cannot be made in settings validation at all, so it is made + // here rather than minting a degraded identifier under this name. + if key == HOST_SIGNALS_PROVIDER_KEY { + let config = ec.providers.host_signals.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: "Edge Cookie provider `host-signals` is selected but has no \ + `[ec.providers.host-signals]` configuration" + .to_owned(), + }) + })?; + 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(), + }) + })?; + return Ok(Box::new(HostSignalProvider::new( + config.passphrase.clone(), + signals, + ))); + } + injected .filter(|provider| provider.id() == key) .map(|provider| Box::new(SharedProvider(provider)) as Box) @@ -849,44 +970,95 @@ fn resolve_named_provider( /// by the `[ec] provider` selector. /// /// The composition root calls this while it builds application state, passing -/// the same injected provider it will put into +/// the same services it will put into /// [`RuntimeServices`](crate::platform::RuntimeServices) on every request. /// [`build_provider`] reads no request data, so the answer is the same for /// every request and a selection the adapter can never supply fails at startup /// rather than on the first request. A stateless deployment (no selector, or /// `"none"`) passes. /// +/// `host_signals` answers whether this adapter supplies a [`HostSignals`] +/// service at all, which is fixed per deployment, rather than what any one +/// request fingerprints to. An adapter that injects host signals on every +/// request passes an instance here even though its values are empty at +/// startup, and an adapter that never injects them passes `None`. +/// /// # Errors /// /// Returns [`TrustedServerError::EdgeCookie`] when the selected provider cannot /// be built from the services this deployment injects. pub fn ensure_provider_available( ec: &Ec, + host_signals: Option>, injected: Option>, ) -> Result<(), Report> { - build_shared_provider(ec, injected)?; + build_shared_provider(ec, host_signals, injected)?; Ok(()) } -/// Resolves the selected provider into a shared handle the composition root can -/// keep and the request path can reuse. +/// Resolves the selected provider into a shared handle. /// /// The same resolution as [`build_provider`], returned as an `Arc` rather than -/// a `Box` so one instance can be threaded into +/// a `Box` so one instance can be held in /// [`RuntimeServices`](crate::platform::RuntimeServices) and read by every -/// request without being built again. An adapter that calls this while it -/// builds application state gets the startup check -/// [`ensure_provider_available`] performs and the provider itself for one piece -/// of work rather than two. +/// request. Use [`build_reusable_provider`] at a composition root, which adds +/// the one check that decides whether keeping the instance is safe. /// /// # Errors /// /// The same errors as [`build_provider`]. pub fn build_shared_provider( ec: &Ec, + host_signals: Option>, + injected: Option>, +) -> Result>, Report> { + Ok(build_provider(ec, host_signals, injected)?.map(Arc::from)) +} + +/// The provider a composition root may keep and hand to every request, when +/// the selection is one that can be kept at all. +/// +/// Resolving is also the startup check, so a selection this deployment cannot +/// satisfy fails here rather than on the first request, exactly as +/// [`ensure_provider_available`] makes it fail. What this adds is the answer to +/// a second question, which is whether the provider that came back is the same +/// for every request. Most are, because they are built from configuration +/// alone, and keeping one saves resolving the same settings again on every +/// request. +/// +/// [`HostSignalProvider`] is not, because it is built from the fingerprints of +/// one request and reports +/// [`is_request_scoped`](EdgeCookieProvider::is_request_scoped). Keeping that +/// one would freeze the fingerprints captured while application state was built, +/// which on every adapter here are empty, so every later request would find no +/// fingerprints and defer. `Ok(None)` comes back for it, the adapter threads +/// nothing, and the request path resolves it per request against that request's +/// own fingerprints. +/// +/// `Ok(None)` therefore means "nothing to keep", which covers both a stateless +/// deployment and a provider that must be resolved per request. Both leave the +/// request path resolving for itself, which is what it did before anything was +/// kept. +/// +/// # Errors +/// +/// The same errors as [`build_provider`]. +pub fn build_reusable_provider( + ec: &Ec, + host_signals: Option>, injected: Option>, ) -> Result>, Report> { - Ok(build_provider(ec, injected)?.map(Arc::from)) + let Some(provider) = build_shared_provider(ec, host_signals, injected)? else { + return Ok(None); + }; + if provider.is_request_scoped() { + log::debug!( + "Edge Cookie provider `{}` is built from request evidence, so it is resolved per request rather than kept", + provider.id(), + ); + return Ok(None); + } + Ok(Some(provider)) } /// The Edge Cookie provider to use for this request. @@ -902,8 +1074,8 @@ pub fn build_shared_provider( /// /// # Errors /// -/// The same errors as [`build_provider`], and only when the adapter threaded -/// nothing, because a threaded provider has already been resolved successfully. +/// The same errors as [`build_provider`], and only when nothing was threaded, +/// because a threaded provider has already been resolved successfully. pub fn request_provider( ec: &Ec, services: &crate::platform::RuntimeServices, @@ -911,7 +1083,7 @@ pub fn request_provider( if let Some(resolved) = services.resolved_ec_provider() { return Ok(Some(resolved)); } - build_shared_provider(ec, None) + build_shared_provider(ec, services.host_signals(), None) } /// Adapts an injected, shared [`EdgeCookieProvider`] to the owned `Box` that @@ -933,6 +1105,10 @@ impl EdgeCookieProvider for SharedProvider { self.0.id() } + fn is_request_scoped(&self) -> bool { + self.0.is_request_scoped() + } + fn generate( &self, request_info: &dyn RequestInfo, @@ -953,7 +1129,8 @@ impl EdgeCookieProvider for SharedProvider { #[cfg(test)] mod tests { use super::*; - use crate::settings::{EcProviders, HmacProviderConfig}; + use crate::evidence::OwnedRequestInfo; + use crate::settings::{EcProviders, HmacProviderConfig, HostSignalsProviderConfig}; #[test] fn a_malformed_provider_code_is_refused_rather_than_panicking() { @@ -1257,7 +1434,7 @@ mod tests { ..Ec::default() }; assert!( - build_provider(&none, None) + build_provider(&none, None, None) .expect("explicit statelessness should build") .is_none(), "`none` should select no provider" @@ -1273,7 +1450,7 @@ mod tests { providers, ..Ec::default() }; - let built = build_provider(&hmac, None) + let built = build_provider(&hmac, None, None) .expect("the hmac selection should build") .expect("the hmac selection should yield a provider"); assert_eq!( @@ -1293,7 +1470,7 @@ mod tests { provider: Some(EcProviderSelection::Named("acme".to_owned())), ..Ec::default() }; - let built = build_provider(&vendor, Some(Arc::new(VendorProvider))) + let built = build_provider(&vendor, None, Some(Arc::new(VendorProvider))) .expect("the vendor selection should build") .expect("the vendor selection should yield a provider"); assert_eq!( @@ -1328,6 +1505,26 @@ mod tests { Redacted::from("a-test-passphrase-32-bytes-minimum".to_owned()) } + fn test_request_info() -> OwnedRequestInfo { + OwnedRequestInfo::new("203.0.113.1".to_owned()) + } + + /// 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()); @@ -1447,8 +1644,12 @@ mod tests { ..Ec::default() }; - let err = build_provider(&selected_hmac, Some(Arc::new(VendorNamedHmacProvider))) - .expect_err("two providers claiming `hmac` should be refused"); + let err = build_provider( + &selected_hmac, + None, + Some(Arc::new(VendorNamedHmacProvider)), + ) + .expect_err("two providers claiming `hmac` should be refused"); let message = err.to_string(); assert!( message.contains(HMAC_PROVIDER_KEY), @@ -1466,9 +1667,12 @@ mod tests { provider: Some(EcProviderSelection::None), ..Ec::default() }; - let err = - ensure_provider_available(&selected_elsewhere, Some(Arc::new(VendorNamedHmacProvider))) - .expect_err("the clash should be refused whatever the selector says"); + let err = ensure_provider_available( + &selected_elsewhere, + None, + Some(Arc::new(VendorNamedHmacProvider)), + ) + .expect_err("the clash should be refused whatever the selector says"); assert!( err.to_string().contains(HMAC_PROVIDER_KEY), "the startup check should name the contested name too, got: {err}" @@ -1479,10 +1683,44 @@ mod tests { provider: Some(EcProviderSelection::Named("acme".to_owned())), ..Ec::default() }; - build_provider(&vendor, Some(Arc::new(VendorProvider))) + build_provider(&vendor, None, Some(Arc::new(VendorProvider))) .expect("a vendor provider under its own name should still build"); } + #[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 { @@ -1490,7 +1728,7 @@ mod tests { ..Ec::default() }; - let err = build_provider(&ec, None) + 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"), @@ -1509,7 +1747,7 @@ mod tests { ..Ec::default() }; - let err = build_provider(&ec, None) + let err = build_provider(&ec, None, None) .expect_err("selecting hmac with no [ec.providers.hmac] block should error"); assert!( err.to_string().contains("[ec.providers.hmac]"), @@ -1517,6 +1755,76 @@ mod tests { ); } + #[test] + fn a_provider_built_from_request_evidence_is_never_kept_and_reused() { + // The host-signal provider captures the fingerprints of the request it + // was built for. A composition root builds application state with an + // empty host-signal service, because there is no request yet, so + // keeping that instance would serve every later request from empty + // fingerprints and the provider would defer forever. It must come back + // as nothing to keep, leaving the request path to resolve it against + // the fingerprints each request actually carried. + let mut providers = EcProviders::default(); + providers.host_signals = Some(HostSignalsProviderConfig { + passphrase: test_passphrase(), + }); + let host_signals_selected = Ec { + provider: Some(EcProviderSelection::from(HOST_SIGNALS_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + let startup_signals: Arc = Arc::new(TestHostSignals { + ja4: None, + h2: None, + }); + + // It resolves, so the startup check still passes on a host that + // supplies the service. + let resolved = build_shared_provider( + &host_signals_selected, + Some(Arc::clone(&startup_signals)), + None, + ) + .expect("the host-signal selection should resolve on a host that supplies signals") + .expect("the selection should yield a provider"); + assert!( + resolved.is_request_scoped(), + "the host-signal provider should declare itself built from request evidence" + ); + + // It is not offered for reuse. + assert!( + build_reusable_provider( + &host_signals_selected, + Some(Arc::clone(&startup_signals)), + None + ) + .expect("the host-signal selection should still pass the startup check") + .is_none(), + "a provider built from request evidence must never be kept for later requests" + ); + + // A provider built from configuration alone is still kept, so the + // saving stands for every selection that can take it. + let mut providers = EcProviders::default(); + providers.hmac = Some(HmacProviderConfig { + passphrase: test_passphrase(), + }); + let hmac_selected = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + let kept = build_reusable_provider(&hmac_selected, None, None) + .expect("the hmac selection should resolve") + .expect("a provider built from configuration alone should be kept"); + assert_eq!( + kept.id(), + HMAC_PROVIDER_KEY, + "the kept provider should be the selected one" + ); + } + #[test] fn the_request_path_reuses_the_provider_the_composition_root_resolved() { // A composition root resolves the selection once while it builds @@ -1529,7 +1837,7 @@ mod tests { provider: Some(EcProviderSelection::Named("acme".to_owned())), ..Ec::default() }; - let resolved = build_shared_provider(&ec, Some(Arc::new(VendorProvider))) + let resolved = build_reusable_provider(&ec, None, Some(Arc::new(VendorProvider))) .expect("the composition root should resolve the selection") .expect("the selection should yield a provider"); @@ -1567,7 +1875,7 @@ mod tests { provider: Some(EcProviderSelection::from("acme")), ..Ec::default() }; - let err = ensure_provider_available(&selected, None) + let err = ensure_provider_available(&selected, None, None) .expect_err("an uninjected provider should fail the startup check"); assert!( err.to_string().contains("acme"), @@ -1576,13 +1884,45 @@ mod tests { // Statelessness is a supported deployment, spelled either way, and must // never be turned into a startup error. - ensure_provider_available(&Ec::default(), None) + ensure_provider_available(&Ec::default(), None, None) .expect("should allow a deployment that selects no provider"); let explicit_none = Ec { provider: Some(EcProviderSelection::None), ..Ec::default() }; - ensure_provider_available(&explicit_none, None) + ensure_provider_available(&explicit_none, None, None) .expect("should allow the explicit `none` selection"); } + + #[test] + fn the_startup_check_rejects_host_signals_on_a_host_that_supplies_none() { + // Whether the adapter injects a host-signal service is fixed per + // deployment, so selecting the host-signal provider on an adapter that + // injects none is knowable without a request. + let mut providers = EcProviders::default(); + providers.host_signals = Some(HostSignalsProviderConfig { + passphrase: test_passphrase(), + }); + let selected = Ec { + provider: Some(EcProviderSelection::from(HOST_SIGNALS_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + + let err = ensure_provider_available(&selected, None, None).expect_err( + "the host-signal provider should fail the startup check with no host signals", + ); + assert!( + err.to_string().contains("TLS/HTTP-2 fingerprints"), + "the error should say the host supplies no fingerprints, got: {err}" + ); + + let signals: Arc = Arc::new(TestHostSignals { + ja4: None, + h2: None, + }); + ensure_provider_available(&selected, Some(signals), None).expect( + "should pass on a host that injects host signals, whatever this request fingerprints to", + ); + } } diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index 944533b936..722c03ad0f 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -58,8 +58,10 @@ pub fn generate_ec_id( return Ok(None); }; - // The provider reads request data (for example the client IP) borrowed at - // call time, so nothing is cloned. + // The provider reads request data (the client IP and the request headers) + // borrowed at call time, so nothing is cloned. A provider that also reads + // host fingerprints takes them from the injected host signals rather than + // from this request info. 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 diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index 8f36807946..7a991d761f 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -294,4 +294,48 @@ mod tests { assert_eq!(info.query_param("id").as_deref(), Some("abc123")); assert_eq!(info.header("cookie"), Some("client-id=abc123; ts-ec=xyz")); } + + fn headers_with_user_agent(user_agent: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::USER_AGENT, + user_agent + .parse() + .expect("should parse a User-Agent header value"), + ); + headers + } + + #[test] + fn request_info_reads_the_user_agent_from_attached_headers() { + let headers = headers_with_user_agent("ExampleAgent/1.0"); + + let owned = OwnedRequestInfo::new(String::new()).with_headers(headers.clone()); + let borrowed = BorrowedRequestInfo::new("").with_headers(&headers); + + assert_eq!( + owned.user_agent(), + "ExampleAgent/1.0", + "the owned snapshot should report the attached User-Agent" + ); + assert_eq!( + borrowed.user_agent(), + "ExampleAgent/1.0", + "the borrowed view should report the attached User-Agent" + ); + } + + #[test] + fn request_info_reports_no_user_agent_without_headers() { + assert_eq!( + OwnedRequestInfo::new(String::new()).user_agent(), + "", + "a snapshot built without headers should report no User-Agent" + ); + assert_eq!( + BorrowedRequestInfo::new("").user_agent(), + "", + "a borrowed view built without headers should report no User-Agent" + ); + } } diff --git a/crates/trusted-server-core/src/geo.rs b/crates/trusted-server-core/src/geo.rs index 63f7907f53..22a37ad2c0 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/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 1c5bf4c2a2..1bb2005554 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/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index c6af0a307d..6bceabd7cc 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 3e18d1cdcd..2bc6ff143c 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -10,6 +10,7 @@ use super::{ PlatformSecretStore, }; use crate::ec::provider::EdgeCookieProvider; +use crate::evidence::HostSignals; /// Geographic information extracted from a request. /// @@ -190,6 +191,10 @@ 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>, /// The Edge Cookie provider this deployment already resolved from /// `[ec] provider` while it built application state. /// @@ -284,6 +289,18 @@ 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 Edge Cookie provider the composition root already resolved, /// when the adapter threaded one through. /// @@ -385,6 +402,7 @@ pub struct RuntimeServicesBuilder { geo: Option>, auction_telemetry_sink: Option>, client_info: Option, + host_signals: Option>, resolved_ec_provider: Option>, } @@ -401,6 +419,7 @@ impl RuntimeServicesBuilder { geo: None, auction_telemetry_sink: None, client_info: None, + host_signals: None, resolved_ec_provider: None, } } @@ -481,6 +500,17 @@ 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 Edge Cookie provider the composition root already resolved. /// /// Optional. This is the provider the selector actually chose, so setting @@ -533,6 +563,7 @@ impl RuntimeServicesBuilder { client_info: self .client_info .expect("should set client_info before building RuntimeServices"), + host_signals: self.host_signals, resolved_ec_provider: self.resolved_ec_provider, } } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 02262ec7fc..2f1acb1549 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -20,7 +20,7 @@ use crate::cache_policy::{CachePolicy, CacheVisibility}; use crate::consent_config::ConsentConfig; use crate::constants::INTERNAL_HEADERS; use crate::creative_opportunities::CreativeOpportunitiesConfig; -use crate::ec::provider::{EcProviderSelection, HMAC_PROVIDER_KEY}; +use crate::ec::provider::{EcProviderSelection, HMAC_PROVIDER_KEY, HOST_SIGNALS_PROVIDER_KEY}; use crate::error::TrustedServerError; use crate::host_header::validate_host_header_override_value; use crate::platform::PlatformImageOptimizerRegion; @@ -754,6 +754,13 @@ pub struct EcProviders { #[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 @@ -785,15 +792,16 @@ impl EcProviders { /// The keys of every configured `[ec.providers.]` block. /// - /// The typed built-in block is reported under the name it is configured + /// Each typed built-in block is reported under the name it is configured /// with, so it appears alongside the vendor blocks rather than being /// counted separately by each caller. This is the one place that mapping is - /// made, and it goes away when the built-in provider becomes a module and - /// its block joins the others. + /// made, and each entry goes away when its built-in provider becomes a + /// module and its block joins the others. pub(crate) fn configured_keys(&self) -> impl Iterator { self.hmac .iter() .map(|_| HMAC_PROVIDER_KEY) + .chain(self.host_signals.iter().map(|_| HOST_SIGNALS_PROVIDER_KEY)) .chain(self.vendor.keys().map(String::as_str)) } @@ -821,6 +829,111 @@ pub struct HmacProviderConfig { 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, PartialEq, Eq, 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, PartialEq, Eq, 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)] #[serde(deny_unknown_fields)] pub struct Rewrite { @@ -2793,6 +2906,18 @@ fn is_default_auction_debug_comment_options(value: &AuctionDebugCommentOptions) *value == AuctionDebugCommentOptions::default() } +// The provider selectors are new sections, so a serialized blob that carries +// them is rejected by a base-revision binary that has never heard of them. +// Omitting the default table keeps an unchanged `ts config push` readable +// across a rollout or a rollback. +fn is_default_device_config(value: &DeviceConfig) -> bool { + *value == DeviceConfig::default() +} + +fn is_default_geo_config(value: &GeoConfig) -> bool { + *value == GeoConfig::default() +} + /// Behavior of the `` auction dump. Only consulted when /// [`DebugConfig::auction_html_comment`] is true. /// @@ -3083,6 +3208,12 @@ pub struct Settings { pub tinybird: TinybirdSettings, #[serde(default)] pub debug: DebugConfig, + #[serde(default, skip_serializing_if = "is_default_device_config")] + #[validate(nested)] + pub device: DeviceConfig, + #[serde(default, skip_serializing_if = "is_default_geo_config")] + #[validate(nested)] + pub geo: GeoConfig, } impl Settings { @@ -3172,6 +3303,8 @@ impl Settings { settings.ec.migrate_legacy_ec_layout()?; settings.ec.validate_provider_selection()?; + settings.device.validate_provider_selection()?; + settings.geo.validate_provider_selection()?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -3267,6 +3400,11 @@ impl Settings { { 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()); } @@ -4941,7 +5079,11 @@ mod tests { // 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\"\n", ""); + 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"); @@ -5305,6 +5447,34 @@ mod tests { ); } + #[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 @@ -5325,6 +5495,67 @@ mod tests { ); } + #[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( @@ -6364,6 +6595,7 @@ origin_host_header_overide = "www.example.com""#, [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + "#, ) .expect("should parse settings without max_buffered_body_bytes"); @@ -6400,9 +6632,10 @@ origin_host_header_overide = "www.example.com""#, 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}" ); } 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 fa6fef6e83..497205d4c1 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,6 +9,12 @@ 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] provider = "hmac" ec_store = "ec_identity_store" diff --git a/trusted-server.example.toml b/trusted-server.example.toml index c05cb9bd6b..d73c436d0f 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -123,6 +123,18 @@ pull_sync_concurrency = 3 # OPTIONAL — Core features (disabled/omitted by default) # ============================================================================= +# 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" + # Custom headers added to every response (e.g. X-Robots-Tag: noindex). # [response_headers] # X-Robots-Tag = "noindex" From 20fee6aca63fe55bbf0c05766f944a88dc75630b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 19:48:17 +0100 Subject: [PATCH 02/16] Omit an unset provider selector from the serialized config The geo and device selectors are optional, and both serialized as `"provider": null` when unset rather than being left out. That is invisible today, because the whole section is skipped while every field in it is default. It stops being invisible in the permission model PR, which makes `[geo] default_country` required, so the geo section is always emitted and the null selector goes with it. A pushed config blob then carries a key that a binary predating the selector rejects, which breaks an otherwise unchanged `ts config push` during a rollout or a rollback. That is the same compatibility rule the surrounding fields already follow. Both fields now skip serialization when unset. The test serializes each section directly rather than through Settings, because through Settings the section is skipped as a whole here and the fault would not show until two PRs later. Addresses: crates/trusted-server-core/src/settings.rs, optional selectors serialized as null --- crates/trusted-server-core/src/settings.rs | 27 ++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 2f1acb1549..fb01f8ec30 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -858,7 +858,7 @@ pub struct DeviceConfig { /// compiled WebAssembly can switch providers at deployment. An unknown key is /// rejected at startup by /// [`validate_provider_selection`](Self::validate_provider_selection). - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, } @@ -908,7 +908,7 @@ pub struct GeoConfig { /// WebAssembly can switch providers at deployment. An unknown key is rejected /// at startup by /// [`validate_provider_selection`](Self::validate_provider_selection). - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, } @@ -8394,6 +8394,29 @@ formats = [{{ width = 300, height = 250 }}] ); } + /// An unset selector must not serialize as `"provider": null`. + /// + /// The section as a whole is skipped while every field is default, so this + /// serializes the struct directly. Once a later change makes another field + /// required, the section is always emitted and a null selector would then + /// reach a config blob, where a binary that predates the field rejects it. + #[test] + fn an_unset_provider_selector_is_omitted_from_the_serialized_section() { + let geo = GeoConfig::default(); + let json = serde_json::to_string(&geo).expect("should serialize the geo section"); + assert!( + !json.contains("provider"), + "an unset geo selector should be omitted rather than serialized as null, got {json}" + ); + + let device = DeviceConfig::default(); + let json = serde_json::to_string(&device).expect("should serialize the device section"); + assert!( + !json.contains("provider"), + "an unset device selector should be omitted rather than serialized as null, got {json}" + ); + } + #[test] fn admin_endpoints_match_fastly_router() { let router_source = include_str!("../../trusted-server-adapter-fastly/src/app.rs"); From 74047c1b6c9cb5d26d282bf5e788944e4b4cebfa Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 21:07:06 +0100 Subject: [PATCH 03/16] Read the Cloudflare visitor region so US state opt-outs are honored The Cloudflare adapter resolves no region on upstream/main today and this fixes it here. `build_geo` read no region header and `CloudflareGeo`'s `lookup` returned `region: None` unconditionally, so no Cloudflare deployment could ever place a visitor in a US state. I traced the consequence rather than assuming it, and it holds. `detect_jurisdiction` in `consent/jurisdiction.rs` reaches its US branch only when the country is `US` and a region is present, so with no region every US visitor fell through to `Jurisdiction::NonRegulated`. In `allows_ec_creation`, the `NonRegulated` arm returns true and never reads `ctx.gpc`, while the `UsState` arm blocks on `ctx.gpc` first and then on a GPP US sale opt-out and the US Privacy string. A US visitor sending Sec-GPC therefore had that opt-out ignored on Cloudflare, and an Edge Cookie was created anyway. The same is true for the GPP and US Privacy opt-outs, because none of them are consulted on the unregulated arm. The region now comes from `cf-region-code`, which is the ISO 3166-2 subdivision code, rather than `cf-region`, which is the subdivision name. The state rules in `[consent.us_states] privacy_states` are written as two-letter codes, so a name would never match one and the fix would look applied while changing nothing. This is a request header, read the same way the adapter already reads `cf-ipcountry` and `cf-ipcity`, and like those it needs the visitor-location managed transform, which the doc comment now says. The new test walks the whole chain in one place, from the header through `build_geo` and `lookup` to `detect_jurisdiction` and then to `allows_ec_creation` with Sec-GPC set, and asserts the opt-out is honored. Putting `region: None` back fails it. Addresses: crates/trusted-server-adapter-cloudflare/src/platform.rs, where `CloudflareGeo::lookup` hardcoded no region and `build_geo` read no region header, so US state privacy signals failed open. --- .../src/platform.rs | 96 ++++++++++++++++++- .../trusted-server-adapter-fastly/src/main.rs | 2 +- crates/trusted-server-core/src/ec/device.rs | 3 +- crates/trusted-server-core/src/ec/provider.rs | 3 +- crates/trusted-server-core/src/evidence.rs | 38 +++++++- 5 files changed, 131 insertions(+), 11 deletions(-) diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 658915b737..4daff5bfea 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -663,15 +663,22 @@ pub fn build_runtime_services( /// Reads Cloudflare geo headers injected by the Workers runtime. /// /// `cf-ipcountry` is available on all plans. `cf-ipcity`, `cf-ipcontinent`, -/// `cf-iplatitude`, and `cf-iplongitude` require an Enterprise plan. Absent or -/// unparseable values default to empty strings or `0.0`. Country code `XX` -/// (Cloudflare's "unknown" sentinel) is treated as absent. +/// `cf-iplatitude`, `cf-iplongitude` and `cf-region-code` require an Enterprise +/// plan and the visitor-location managed transform. Absent or unparseable +/// values default to empty strings or `0.0`. Country code `XX` (Cloudflare's +/// "unknown" sentinel) is treated as absent. +/// +/// The region is the ISO 3166-2 subdivision code from `cf-region-code`, for +/// example `CA`, and not the subdivision name from `cf-region`, because the US +/// state privacy rules in `[consent.us_states] privacy_states` are written as +/// two-letter codes and a name would never match one. struct CloudflareGeo { country: String, city: String, continent: String, latitude: f64, longitude: f64, + region: Option, } impl PlatformGeo for CloudflareGeo { @@ -686,7 +693,7 @@ impl PlatformGeo for CloudflareGeo { latitude: self.latitude, longitude: self.longitude, metro_code: 0, - region: None, + region: self.region.clone(), asn: None, })) } @@ -720,12 +727,19 @@ fn build_geo(ctx: &edgezero_core::context::RequestContext) -> CloudflareGeo { .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()) .unwrap_or(0.0); + let region = headers + .get("cf-region-code") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned); CloudflareGeo { country, city, continent, latitude, longitude, + region, } } @@ -783,6 +797,80 @@ mod tests { RequestContext::new(req, PathParams::default()) } + /// Builds a request context carrying several headers at once. + fn make_ctx_with_headers(headers: &[(&str, &str)]) -> RequestContext { + let mut builder = request_builder().method("GET").uri("https://example.com/"); + for (name, value) in headers { + builder = builder.header( + *name, + HeaderValue::from_str(value).expect("should parse test header value"), + ); + } + let req = builder + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::default()) + } + + #[test] + fn a_us_state_visitor_reaches_the_us_state_jurisdiction_and_its_opt_out() { + // This adapter used to hardcode `region: None` and read no region + // header, and the consequence ran all the way to the privacy outcome. + // `detect_jurisdiction` reaches its US branch only when the country is + // `US` and a region is present, so every US visitor fell through to + // `NonRegulated`, where `allows_ec_creation` returns true without ever + // reading `ctx.gpc`. A Sec-GPC opt-out was therefore ignored for every + // US visitor on Cloudflare. + let config = trusted_server_core::consent_config::ConsentConfig::default(); + + let ctx = make_ctx_with_headers(&[("cf-ipcountry", "US"), ("cf-region-code", "CA")]); + let geo = build_geo(&ctx) + .lookup(None) + .expect("should look up without failing") + .expect("a country header should resolve a location"); + assert_eq!( + geo.region.as_deref(), + Some("CA"), + "the ISO 3166-2 subdivision code should reach the geo info" + ); + + let jurisdiction = + trusted_server_core::consent::jurisdiction::detect_jurisdiction(Some(&geo), &config); + assert_eq!( + jurisdiction, + trusted_server_core::consent::jurisdiction::Jurisdiction::UsState("CA".to_owned()), + "a Californian visitor should reach the US state jurisdiction" + ); + + // Reaching that jurisdiction is what this adapter is responsible for, + // and it is the switch every downstream opt-out hangs off. A Sec-GPC + // signal, a GPP US sale opt-out and a US Privacy opt-out are all + // consulted on the US state branch and none of them are consulted on + // the unregulated one, so a visitor who never reaches the US state + // jurisdiction has every one of those signals ignored. Which gate reads + // the jurisdiction is core's business and changes across this stack, so + // it is core that tests the reading. + + // Without the region header nothing can place the visitor in a state, + // so the jurisdiction is the unregulated one and the same opt-out is + // ignored. That is the behavior this fix removes for any deployment + // whose plan supplies the header. + let ctx = make_ctx_with_headers(&[("cf-ipcountry", "US")]); + let geo = build_geo(&ctx) + .lookup(None) + .expect("should look up without failing") + .expect("a country header should resolve a location"); + assert_eq!( + geo.region, None, + "no region header should mean no region, not an invented one" + ); + assert_eq!( + trusted_server_core::consent::jurisdiction::detect_jurisdiction(Some(&geo), &config), + trusted_server_core::consent::jurisdiction::Jurisdiction::NonRegulated, + "with no region a US visitor cannot be placed in a privacy state" + ); + } + fn make_ctx_without_header() -> RequestContext { let req = request_builder() .method("GET") diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 106af48bfc..ec7ed355ed 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -554,7 +554,7 @@ pub(crate) fn derive_device_signals(settings: &Settings, req: &FastlyRequest) -> .get_client_ip_addr() .map(|ip| ip.to_string()) .unwrap_or_default(); - let request_info = BorrowedRequestInfo::new(&client_ip).with_headers(&headers); + let request_info = BorrowedRequestInfo::new(&client_ip, None).with_headers(&headers); build_device_provider(settings, || { let host_signals: Arc = Arc::new(FastlyHostSignals::from_request(req)); Box::new(FastlyDeviceProvider::new(host_signals)) as Box diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index bf705b41d6..ecc0979945 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -370,6 +370,7 @@ fn evaluate_known_browser(ja4_class: Option<&str>, h2_fp_hash: Option<&str>) -> mod tests { use super::*; use crate::evidence::OwnedRequestInfo; + use http::HeaderMap; // Chrome Mac UA const CHROME_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ @@ -792,7 +793,7 @@ mod tests { http::HeaderValue::from_str(user_agent) .expect("should build a valid User-Agent header"), ); - OwnedRequestInfo::new(String::new()).with_headers(headers) + OwnedRequestInfo::new(String::new(), headers) } /// A stand-in for the host-specific provider the adapter injects, so the diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index bc43f8bf93..ac49bfcb5d 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -1131,6 +1131,7 @@ mod tests { use super::*; use crate::evidence::OwnedRequestInfo; use crate::settings::{EcProviders, HmacProviderConfig, HostSignalsProviderConfig}; + use http::HeaderMap; #[test] fn a_malformed_provider_code_is_refused_rather_than_panicking() { @@ -1506,7 +1507,7 @@ mod tests { } fn test_request_info() -> OwnedRequestInfo { - OwnedRequestInfo::new("203.0.113.1".to_owned()) + OwnedRequestInfo::new("203.0.113.1".to_owned(), HeaderMap::new()) } /// Test host signals with fixed JA4/H2 values. diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index 7a991d761f..52a5714063 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -105,6 +105,14 @@ impl OwnedRequestInfo { /// so a provider can read request parameters through /// [`query_param`](RequestInfo::query_param). #[must_use] + /// Replaces the header snapshot, for a caller that builds the info first + /// and has the headers second. + #[must_use] + pub fn with_headers(mut self, headers: HeaderMap) -> Self { + self.headers = headers; + self + } + pub fn with_request_target(mut self, path: String, query: String) -> Self { self.path = path; self.query = query; @@ -180,6 +188,14 @@ impl<'a> BorrowedRequestInfo<'a> { /// provider can read request parameters through /// [`query_param`](RequestInfo::query_param). #[must_use] + /// Borrows a different header map, for a caller that builds the info first + /// and has the headers second. + #[must_use] + pub fn with_headers(mut self, headers: &'a HeaderMap) -> Self { + self.headers = Some(headers); + self + } + pub fn with_request_target(mut self, path: &'a str, query: &'a str) -> Self { self.path = path; self.query = query; @@ -220,6 +236,20 @@ impl RequestInfo for BorrowedRequestInfo<'_> { } } +/// 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, and 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::*; @@ -310,8 +340,8 @@ mod tests { fn request_info_reads_the_user_agent_from_attached_headers() { let headers = headers_with_user_agent("ExampleAgent/1.0"); - let owned = OwnedRequestInfo::new(String::new()).with_headers(headers.clone()); - let borrowed = BorrowedRequestInfo::new("").with_headers(&headers); + let owned = OwnedRequestInfo::new(String::new(), headers.clone()); + let borrowed = BorrowedRequestInfo::new("", Some(&headers)); assert_eq!( owned.user_agent(), @@ -328,12 +358,12 @@ mod tests { #[test] fn request_info_reports_no_user_agent_without_headers() { assert_eq!( - OwnedRequestInfo::new(String::new()).user_agent(), + OwnedRequestInfo::new(String::new(), HeaderMap::new()).user_agent(), "", "a snapshot built without headers should report no User-Agent" ); assert_eq!( - BorrowedRequestInfo::new("").user_agent(), + BorrowedRequestInfo::new("", None).user_agent(), "", "a borrowed view built without headers should report no User-Agent" ); From cf8d0982b5417b6c258d6ae2f6e045cc4c4a1a32 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 31 Aug 2026 01:50:43 +0100 Subject: [PATCH 04/16] Reunite the request-info builders with their own documentation Restoring the evidence accessors added a `with_headers` builder to `OwnedRequestInfo` and `BorrowedRequestInfo`, and on both types it landed between `with_request_target`'s doc comment and the function that comment describes. So `with_request_target` was left undocumented and without its `#[must_use]`, while `with_headers` carried a stray attribute and a comment describing its neighbour. The compiler reported the stray attribute and clippy separately asked for a `#[must_use]` on `with_request_target`, which is the attribute that had been severed from it. Both builders now sit with the documentation and the attribute that belong to them, so a caller that drops either builder's result is caught again. This went unnoticed because the checks run against this branch were `cargo check` and a cached build, neither of which re-reports warnings, and the branch fails `cargo clippy` with warnings denied. It is fixed here rather than further up the stack because this is the change that introduced the builders, so every branch above inherited the fault. Addresses: crates/trusted-server-core/src/evidence.rs, where both `with_request_target` builders lost their documentation and their `#[must_use]`. --- crates/trusted-server-core/src/evidence.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index 52a5714063..92d29521ce 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -101,10 +101,6 @@ impl OwnedRequestInfo { } } - /// 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] /// Replaces the header snapshot, for a caller that builds the info first /// and has the headers second. #[must_use] @@ -113,6 +109,10 @@ impl OwnedRequestInfo { self } + /// 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; @@ -184,10 +184,6 @@ impl<'a> BorrowedRequestInfo<'a> { } } - /// 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] /// Borrows a different header map, for a caller that builds the info first /// and has the headers second. #[must_use] @@ -196,6 +192,10 @@ impl<'a> BorrowedRequestInfo<'a> { self } + /// 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; From de989349fe8f436623014930e89e57eb2506a70e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 31 Aug 2026 11:45:52 +0100 Subject: [PATCH 05/16] Documentation and house-style cleanup for the device and geo provider PR --- crates/device/README.md | 2 +- crates/device/fastly/src/lib.rs | 23 +++---- crates/geo/fastly/src/lib.rs | 16 +++-- .../src/platform.rs | 2 +- .../trusted-server-adapter-fastly/src/app.rs | 10 +-- .../trusted-server-adapter-fastly/src/main.rs | 15 +++-- crates/trusted-server-core/src/ec/device.rs | 66 ++++++++++--------- crates/trusted-server-core/src/ec/provider.rs | 60 +++++++++-------- crates/trusted-server-core/src/edge_cookie.rs | 4 +- crates/trusted-server-core/src/evidence.rs | 8 +-- .../trusted-server-core/src/platform/types.rs | 12 ++-- crates/trusted-server-core/src/settings.rs | 16 +++-- 12 files changed, 122 insertions(+), 112 deletions(-) diff --git a/crates/device/README.md b/crates/device/README.md index 3aa4cb04ae..3d2ca5f9f2 100644 --- a/crates/device/README.md +++ b/crates/device/README.md @@ -2,7 +2,7 @@ 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 +HTTP/2 signals. Future vendor providers (for example `crates/device/`) slot in alongside it. The built-in default provider (User-Agent only) ships in `trusted-server-core` diff --git a/crates/device/fastly/src/lib.rs b/crates/device/fastly/src/lib.rs index 822e4cc37d..9b36ea3697 100644 --- a/crates/device/fastly/src/lib.rs +++ b/crates/device/fastly/src/lib.rs @@ -1,11 +1,11 @@ //! 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 +//! with the host's TLS (JA4) and HTTP/2 signals, 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 +//! [`FastlyHostSignals`] captures those signals 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 @@ -21,9 +21,9 @@ 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. +/// Host-computed client signals captured from a live Fastly request. /// -/// Reads the TLS JA4 and HTTP/2 fingerprints once through the Fastly SDK and +/// Reads the TLS JA4 and HTTP/2 signals 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. @@ -34,9 +34,9 @@ pub struct FastlyHostSignals { } impl FastlyHostSignals { - /// Builds host signals from already-captured fingerprint values. + /// Builds host signals from already-captured signal values. /// - /// Use this when the adapter has read the fingerprints once (for example + /// Use this when the adapter has read the signals 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] @@ -44,7 +44,7 @@ impl FastlyHostSignals { Self { ja4, h2 } } - /// Captures the TLS JA4 and HTTP/2 fingerprints from a live Fastly request. + /// Captures the TLS JA4 and HTTP/2 signals from a live Fastly request. #[must_use] pub fn from_request(req: &FastlyRequest) -> Self { Self { @@ -66,10 +66,11 @@ impl HostSignals for FastlyHostSignals { /// 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. +/// Classifies a request with [`DeviceSignals::derive`], which strengthens the +/// User-Agent classification with the host signals. It reads the User-Agent +/// from its injected [`RequestInfo`] and the TLS and HTTP/2 signals from its +/// injected [`HostSignals`], so the browser/bot gate is backed by the live +/// request. pub struct FastlyDeviceProvider { host_signals: Arc, } diff --git a/crates/geo/fastly/src/lib.rs b/crates/geo/fastly/src/lib.rs index a93f7dd726..5128fa7c62 100644 --- a/crates/geo/fastly/src/lib.rs +++ b/crates/geo/fastly/src/lib.rs @@ -2,13 +2,14 @@ //! //! [`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. +//! injected by the Fastly adapter via `build_geo_provider`. With no selector, +//! or `provider = "platform"`, this host lookup resolves the location, and +//! `provider = "none"` disables geo instead. //! -//! 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`. +//! Like the Fastly device provider, this crate calls the Fastly 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; @@ -33,7 +34,8 @@ fn geo_from_fastly(geo: &Geo) -> GeoInfo { /// 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. +/// `build_geo_provider`. With no selector, or `provider = "platform"`, it +/// resolves the location, and `provider = "none"` disables geo instead. pub struct FastlyPlatformGeo; impl PlatformGeo for FastlyPlatformGeo { diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 4daff5bfea..3a10082ed2 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -797,7 +797,7 @@ mod tests { RequestContext::new(req, PathParams::default()) } - /// Builds a request context carrying several headers at once. + /// Builds a request context carrying multiple headers at once. fn make_ctx_with_headers(headers: &[(&str, &str)]) -> RequestContext { let mut builder = request_builder().method("GET").uri("https://example.com/"); for (name, value) in headers { diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 196f16c82a..0818f6fa1f 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -216,11 +216,11 @@ pub(crate) fn build_state_from_settings( // argument, and one is passed here once this adapter supplies it. // // This adapter injects host signals on every request, so a startup instance - // with no captured fingerprints answers the only question the check asks, + // with no captured signals answers the only question the check asks, // which is whether the service exists at all. That same emptiness is why // `build_reusable_provider` hands back nothing for a provider built from - // those fingerprints, leaving it to be resolved per request against the - // fingerprints that request actually carried. + // those signals, leaving it to be resolved per request against the + // signals that request actually carried. let ec_provider = build_reusable_provider( &settings.ec, Some(Arc::new(FastlyHostSignals::default())), @@ -307,10 +307,10 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime ..ClientInfo::default() }); - // The TLS JA4 and HTTP/2 fingerprints arrive as trusted internal headers + // The TLS JA4 and HTTP/2 signals 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. + // service is always set even when a request carried no signal. let tls_ja4 = ctx .request() .headers() diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index ec7ed355ed..2e16f1480f 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -171,7 +171,7 @@ fn edgezero_main(mut req: FastlyRequest, env: &EnvConfig) { let client_info = client_info_from_request(&req, resolved_client_ip); let client_ip = client_info.client_ip; - // Strip and re-inject the TLS JA4 and HTTP/2 fingerprints from the + // Strip and re-inject the TLS JA4 and HTTP/2 signals 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 @@ -536,12 +536,13 @@ pub(crate) fn extract_cookie_value(req: &HttpRequest, name: &str) -> Option DeviceSignals { let mut headers = HeaderMap::new(); if let Some(value) = req diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index ecc0979945..adb61b6ea9 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -1,11 +1,11 @@ //! Device signal derivation for bot detection and browser 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. +//! Fastly SDK calls. A [`DeviceProvider`] is wired by dependency injection. It +//! reads the [`RequestInfo`] for the User-Agent from the borrowed argument +//! passed to `detect` at call time, and on a host that supplies them the +//! [`HostSignals`](crate::evidence::HostSignals) for the TLS and HTTP/2 signals +//! injected into its constructor, then classifies the request from both. //! //! # Signals //! @@ -37,14 +37,14 @@ pub struct DeviceSignals { /// Coarse OS family: `"mac"`, `"windows"`, `"ios"`, `"android"`, /// `"linux"`. pub platform_class: Option, - /// SHA256 prefix (12 hex chars) of raw H2 SETTINGS fingerprint. + /// SHA256 prefix (12 hex chars) of the raw H2 SETTINGS signal. 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. + /// the TLS and HTTP/2 signals. pub looks_like_browser: bool, } @@ -52,10 +52,10 @@ impl DeviceSignals { /// 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`). + /// This is the default path. It touches no Fastly-specific API, so device + /// classification stays host-neutral by default. `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); @@ -72,13 +72,13 @@ impl DeviceSignals { } /// Derives device signals from the User-Agent strengthened with the - /// host's TLS/H2 fingerprints. + /// host's TLS and HTTP/2 signals. /// /// `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()`. These fingerprints are + /// from `req.get_client_h2_fingerprint()`. These signals are /// host-specific (Fastly), so only the opt-in Fastly device provider - /// uses this path; the browser/bot gate then requires a TLS fingerprint. + /// uses this path, and the browser/bot gate then requires a TLS signal. #[must_use] pub fn derive(ua: &str, ja4: Option<&str>, h2_fp: Option<&str>) -> Self { let is_mobile = parse_is_mobile(ua); @@ -86,8 +86,8 @@ 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 + // The gate strengthened by host signals. A real browser produces a valid + // TLS signal 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. @@ -146,8 +146,8 @@ pub trait DeviceProvider: Send + Sync { /// /// 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. +/// only [`RequestInfo::user_agent`] and never a host signal, so device +/// classification stays host-neutral by default. #[derive(Debug, Default)] pub struct BuiltinDeviceProvider; @@ -174,8 +174,10 @@ impl DeviceProvider for BuiltinDeviceProvider { /// 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 +/// provider is selected, so device classification itself reads no host signals +/// by default (see [`BuiltinDeviceProvider`] for the host-neutral default). The +/// Fastly entry point still reads the TLS and HTTP/2 signals on every request to +/// build the host-signal service and client info. 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. @@ -252,7 +254,7 @@ fn parse_platform_class(ua: &str) -> Option { /// 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. +/// TLS and HTTP/2 signals 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) @@ -285,11 +287,11 @@ fn looks_like_bot_ua(ua: &str) -> bool { BOT_MARKERS.iter().any(|marker| lower.contains(marker)) } -/// Extracts Section 1 from a full JA4 fingerprint. +/// Extracts Section 1 from a full JA4 string. /// /// JA4 format: `section1_section2_section3` separated by underscores. /// Section 1 identifies browser family (cipher count, extension count, -/// ALPN) without uniquely fingerprinting a device. +/// ALPN) without uniquely identifying a device. /// /// Returns `None` if the input is empty or has no underscore-delimited /// section. @@ -303,7 +305,7 @@ fn extract_ja4_section1(full_ja4: &str) -> Option { } /// Computes a 12-hex-char prefix of the SHA256 hash of the raw H2 -/// SETTINGS fingerprint string. +/// SETTINGS signal string. /// /// The raw string looks like `"1:65536;2:0;4:6291456;6:262144"`. #[must_use] @@ -314,7 +316,7 @@ fn compute_h2_fp_hash(raw_h2_fp: &str) -> String { hex::encode(&digest[..6]) } -/// Known browser fingerprint allowlist. +/// Known browser signal allowlist. /// /// Each entry is `(ja4_class, h2_fp_prefix, known_browser)`. /// `h2_fp_prefix` is the raw H2 SETTINGS string (not the hash) — we @@ -330,7 +332,7 @@ const KNOWN_BROWSERS: &[(&str, &str, bool)] = &[ ("t13d1717h2", "1:65536;2:0;4:131072;5:16384", true), ]; -/// Returns H2 fingerprint hashes for the known browser allowlist. +/// Returns H2 signal 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)> { @@ -507,7 +509,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Chrome fingerprint should be recognized" + "Chrome signal should be recognized" ); } @@ -518,7 +520,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Safari fingerprint should be recognized" + "Safari signal should be recognized" ); } @@ -529,7 +531,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Firefox fingerprint should be recognized" + "Firefox signal should be recognized" ); } @@ -679,7 +681,7 @@ mod tests { ); assert!( signals.looks_like_browser, - "unknown fingerprint with valid JA4 + platform should pass" + "unknown signal with valid JA4 + platform should pass" ); assert_eq!(signals.known_browser, None, "should not match allowlist"); } @@ -695,7 +697,7 @@ mod tests { #[test] fn looks_like_browser_rejects_missing_ja4() { - // Real UA but no TLS fingerprint (e.g. HTTP/1.1 or missing SDK support) + // Real UA but no TLS signal (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, @@ -771,7 +773,7 @@ mod tests { 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. + // info passed to `detect` alone, recording no host signal. let request_info = request_info_with_ua(CHROME_MAC_UA); let signals = provider.detect(&request_info); assert_eq!( diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index ac49bfcb5d..302d846db2 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -6,10 +6,12 @@ //! constructed from its `[ec.providers.]` block, and a vendor provider is //! taken from the adapter that injected it. A built-in provider that also needs //! a host service, as the host-signal provider needs the [`HostSignals`] -//! fingerprints, is built only on a host that supplies that service. -//! Construction happens once, while application state is built, and reads no -//! request data, so a selection this deployment cannot satisfy fails at startup -//! rather than leaving it running without an identity. +//! service, is built only on a host that supplies that service. +//! Construction reads configuration and long-lived services, so a selection +//! this deployment cannot satisfy fails at startup rather than leaving it +//! running without an identity. The host-signal provider is the exception: it +//! is built per request from that request's TLS and HTTP/2 signals (see +//! [`is_request_scoped`](EdgeCookieProvider::is_request_scoped)). //! //! Request evidence reaches a provider at call time rather than at //! construction. [`EdgeCookieProvider::generate`] borrows a [`RequestInfo`], @@ -628,7 +630,7 @@ pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { /// Almost every provider is built from configuration and services that are /// the same for every request, so one instance can be resolved once and /// handed to all of them. [`HostSignalProvider`] is the exception, because - /// it is built from the TLS and HTTP/2 fingerprints of a single request and + /// it is built from the TLS and HTTP/2 signals of a single request and /// answers `true` here. A composition root reads this through /// [`build_reusable_provider`] to decide whether keeping the instance is /// safe, and keeping a request-scoped one would serve every later request @@ -741,7 +743,7 @@ impl EdgeCookieProvider for HmacProvider { /// The built-in host-signal Edge Cookie provider. /// -/// Derives the identifier from the host fingerprints (TLS JA4 and HTTP/2, read +/// Derives the identifier from the host signals (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 @@ -768,7 +770,7 @@ impl EdgeCookieProvider for HostSignalProvider { HOST_SIGNALS_PROVIDER_KEY } - // Built from the fingerprints of one request, so it is only ever valid for + // Built from the signals of one request, so it is only ever valid for // that request and must never be kept and reused. fn is_request_scoped(&self) -> bool { true @@ -785,11 +787,11 @@ impl EdgeCookieProvider for HostSignalProvider { ) -> 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. + // With no signal at all, creating an identifier would silently degrade + // to an IP-only identifier under the host-signals name. Defer instead, + // meaning 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"); + log::warn!("Host-signal EC provider found no TLS/HTTP-2 signals; deferring"); return Ok(GeneratedEdgeCookie::default()); } let id = generation::generate_hmac_ec_id( @@ -929,9 +931,9 @@ fn resolve_named_provider( return Ok(Box::new(HmacProvider::new(config.passphrase.clone()))); } - // The host-signal provider needs fingerprints only some hosts supply, and + // The host-signal provider needs signals only some hosts supply, and // that check cannot be made in settings validation at all, so it is made - // here rather than minting a degraded identifier under this name. + // here rather than creating a degraded identifier under this name. if key == HOST_SIGNALS_PROVIDER_KEY { let config = ec.providers.host_signals.as_ref().ok_or_else(|| { Report::new(TrustedServerError::EdgeCookie { @@ -943,7 +945,7 @@ fn resolve_named_provider( 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" + TLS/HTTP-2 signals, which this host does not" .to_owned(), }) })?; @@ -979,7 +981,7 @@ fn resolve_named_provider( /// /// `host_signals` answers whether this adapter supplies a [`HostSignals`] /// service at all, which is fixed per deployment, rather than what any one -/// request fingerprints to. An adapter that injects host signals on every +/// request's signals are. An adapter that injects host signals on every /// request passes an instance here even though its values are empty at /// startup, and an adapter that never injects them passes `None`. /// @@ -1026,14 +1028,14 @@ pub fn build_shared_provider( /// alone, and keeping one saves resolving the same settings again on every /// request. /// -/// [`HostSignalProvider`] is not, because it is built from the fingerprints of +/// [`HostSignalProvider`] is not, because it is built from the signals of /// one request and reports /// [`is_request_scoped`](EdgeCookieProvider::is_request_scoped). Keeping that -/// one would freeze the fingerprints captured while application state was built, +/// one would freeze the signals captured while application state was built, /// which on every adapter here are empty, so every later request would find no -/// fingerprints and defer. `Ok(None)` comes back for it, the adapter threads +/// signals and defer. `Ok(None)` comes back for it, the adapter threads /// nothing, and the request path resolves it per request against that request's -/// own fingerprints. +/// own signals. /// /// `Ok(None)` therefore means "nothing to keep", which covers both a stateless /// deployment and a provider that must be resolved per request. Both leave the @@ -1689,7 +1691,7 @@ mod tests { } #[test] - fn host_signal_provider_mints_from_fingerprints() { + fn host_signal_provider_creates_from_signals() { let signals = Arc::new(TestHostSignals { ja4: Some("t13d1516h2_8daaf6152771_e5627efa2ab1".to_owned()), h2: Some("1:65536;4:6291456".to_owned()), @@ -1701,12 +1703,12 @@ mod tests { .expect("should generate"); assert!( generated.id.is_some(), - "the host-signal provider mints an identifier from the fingerprints" + "the host-signal provider should create an identifier from the signals" ); } #[test] - fn host_signal_provider_defers_without_fingerprints() { + fn host_signal_provider_defers_without_signals() { let signals = Arc::new(TestHostSignals { ja4: None, h2: None, @@ -1718,7 +1720,7 @@ mod tests { .expect("should generate"); assert!( generated.id.is_none(), - "with no host fingerprints the provider should defer rather than mint an IP-only identifier" + "with no host signals the provider should defer rather than create an IP-only identifier" ); } @@ -1758,13 +1760,13 @@ mod tests { #[test] fn a_provider_built_from_request_evidence_is_never_kept_and_reused() { - // The host-signal provider captures the fingerprints of the request it + // The host-signal provider captures the signals of the request it // was built for. A composition root builds application state with an // empty host-signal service, because there is no request yet, so // keeping that instance would serve every later request from empty - // fingerprints and the provider would defer forever. It must come back + // signals and the provider would defer forever. It must come back // as nothing to keep, leaving the request path to resolve it against - // the fingerprints each request actually carried. + // the signals each request actually carried. let mut providers = EcProviders::default(); providers.host_signals = Some(HostSignalsProviderConfig { passphrase: test_passphrase(), @@ -1914,8 +1916,8 @@ mod tests { "the host-signal provider should fail the startup check with no host signals", ); assert!( - err.to_string().contains("TLS/HTTP-2 fingerprints"), - "the error should say the host supplies no fingerprints, got: {err}" + err.to_string().contains("TLS/HTTP-2 signals"), + "the error should say the host supplies no signals, got: {err}" ); let signals: Arc = Arc::new(TestHostSignals { @@ -1923,7 +1925,7 @@ mod tests { h2: None, }); ensure_provider_available(&selected, Some(signals), None).expect( - "should pass on a host that injects host signals, whatever this request fingerprints to", + "should pass on a host that injects host signals, whatever this request's signals are", ); } } diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index 722c03ad0f..b24644d972 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -60,8 +60,8 @@ pub fn generate_ec_id( // The provider reads request data (the client IP and the request headers) // borrowed at call time, so nothing is cloned. A provider that also reads - // host fingerprints takes them from the injected host signals rather than - // from this request info. + // host signals takes them from the injected `HostSignals` service rather + // than from this request info. 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 diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index 92d29521ce..ed079e7d49 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -236,17 +236,17 @@ impl RequestInfo for BorrowedRequestInfo<'_> { } } -/// Host-computed client fingerprints that are not carried in request headers. +/// Host-computed client signals 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 +/// TLS JA4 and HTTP/2 signals). A provider that needs them takes /// `Arc` in its constructor, and 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. + /// The full JA4 TLS signal, or `None` when unavailable. fn ja4(&self) -> Option<&str>; - /// The raw HTTP/2 SETTINGS fingerprint, or `None` when unavailable. + /// The raw HTTP/2 SETTINGS signal, or `None` when unavailable. fn h2(&self) -> Option<&str>; } diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 2bc6ff143c..9f5320805f 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -191,7 +191,7 @@ 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 + /// Host-computed client signals (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>, @@ -289,13 +289,13 @@ impl RuntimeServices { &self.client_info } - /// Returns the host-computed client fingerprints, when the host supplies + /// Returns the host-computed client signals, when the host supplies /// them. /// - /// A provider that derives identity from the TLS JA4 or HTTP/2 fingerprints + /// A provider that derives identity from the TLS JA4 or HTTP/2 signals /// 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. + /// stops rather than creating a degraded identifier. #[must_use] pub fn host_signals(&self) -> Option> { self.host_signals.clone() @@ -500,9 +500,9 @@ impl RuntimeServicesBuilder { self } - /// Set the host-computed client fingerprints service. + /// Set the host-computed client signals service. /// - /// Optional: a host that exposes no TLS/HTTP-2 fingerprints leaves this + /// Optional: a host that exposes no TLS or HTTP/2 signals leaves this /// unset, so a provider that requires them cannot be built and the request /// stops. #[must_use] diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index fb01f8ec30..03c3e421af 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -754,9 +754,9 @@ pub struct EcProviders { #[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. + /// The built-in host-signal provider, keyed `host-signals`. Creates the Edge + /// Cookie from the host's TLS and HTTP/2 signals plus the client IP, so it + /// requires a host that supplies those signals. #[serde(default, rename = "host-signals")] #[validate(nested)] pub host_signals: Option, @@ -835,7 +835,7 @@ pub struct HmacProviderConfig { #[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. + /// Passphrase used as the HMAC key over the host signals and client IP. #[validate(custom(function = Ec::validate_passphrase))] pub passphrase: Redacted, } @@ -851,9 +851,11 @@ 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 + /// from the User-Agent alone, so device classification itself makes no + /// host-specific call. The opt-in `fastly` provider strengthens the + /// browser/bot gate with the host's TLS and HTTP/2 signals, which the Fastly + /// entry point reads on every request regardless of this selector. 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 From d3b4b06a09ad284356084aded4c1e64cd126b026 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 1 Sep 2026 08:50:58 +0100 Subject: [PATCH 06/16] Drop the unused HeaderMap import from the device tests CI compiles the test build with -D warnings, where this import is an error because the one place that needs the type names it by its full path. The seam branch already removes it, so remove it here where the device tests first appear and the whole chain stays consistent. --- crates/trusted-server-core/src/ec/device.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index adb61b6ea9..828fab59cf 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -372,7 +372,6 @@ fn evaluate_known_browser(ja4_class: Option<&str>, h2_fp_hash: Option<&str>) -> mod tests { use super::*; use crate::evidence::OwnedRequestInfo; - use http::HeaderMap; // Chrome Mac UA const CHROME_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ From dcdb493c67da1eff802ee4ec21cd7a1d2647ff7a Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 2 Sep 2026 15:23:05 +0100 Subject: [PATCH 07/16] Give each vendor crate a visible maintainers declaration The integration seam spec requires every vendor crate to carry a named maintainer the way Prebid.js requires of every adapter. The two Fastly host crates now declare their owner in package metadata, stating plainly that the Trusted Server maintainers own them until a vendor owner steps up, which is the ownership rule the reviews on #1084 asked the structure to make explicit. --- crates/device/fastly/Cargo.toml | 7 +++++++ crates/geo/fastly/Cargo.toml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/crates/device/fastly/Cargo.toml b/crates/device/fastly/Cargo.toml index 3ff1b6e5e6..8d4b61d489 100644 --- a/crates/device/fastly/Cargo.toml +++ b/crates/device/fastly/Cargo.toml @@ -16,3 +16,10 @@ workspace = true [dependencies] trusted-server-core = { workspace = true } fastly = { workspace = true } + +# The visible owner of this vendor crate, the way Prebid.js requires a named +# maintainer of every adapter. Fastly has not yet adopted this crate, so the +# Trusted Server maintainers own it until a vendor owner steps up. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/geo/fastly/Cargo.toml b/crates/geo/fastly/Cargo.toml index d1e3c4f7c6..79d3acf629 100644 --- a/crates/geo/fastly/Cargo.toml +++ b/crates/geo/fastly/Cargo.toml @@ -17,3 +17,10 @@ workspace = true trusted-server-core = { workspace = true } error-stack = { workspace = true } fastly = { workspace = true } + +# The visible owner of this vendor crate, the way Prebid.js requires a named +# maintainer of every adapter. Fastly has not yet adopted this crate, so the +# Trusted Server maintainers own it until a vendor owner steps up. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" From 8bb598753ad9ea26eeb370d7543f300659c0731d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 3 Sep 2026 18:42:10 +0100 Subject: [PATCH 08/16] Log the jurisdiction class rather than the value when EC creation is refused --- crates/trusted-server-core/src/ec/mod.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index fff9e89a19..dcb9c09bb1 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -68,6 +68,7 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::Report; use http::Request; +use crate::consent::jurisdiction::Jurisdiction; use crate::consent::{self as consent_mod, ConsentContext, ConsentPipelineInput}; use crate::constants::COOKIE_TS_EC; use crate::cookies::handle_request_cookies; @@ -344,9 +345,18 @@ impl EcContext { }; if !self.ec_allowed { + // The jurisdiction class is logged rather than the value. The + // value carries a configured US state code, and the full + // jurisdiction is already logged once when the EC context is + // built, so naming the class here loses nothing. + let jurisdiction = match &self.consent.jurisdiction { + Jurisdiction::Gdpr => "gdpr", + Jurisdiction::UsState(_) => "us-state", + Jurisdiction::NonRegulated => "non-regulated", + Jurisdiction::Unknown => "unknown", + }; log::info!( - "EC generation skipped: EC creation not permitted (jurisdiction={})", - self.consent.jurisdiction, + "EC generation skipped: EC creation not permitted (jurisdiction={jurisdiction})" ); return Ok(()); } From a75571d5cb255c8282bff62c98862ad44a01d6ed Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 14 Sep 2026 12:16:58 +0100 Subject: [PATCH 09/16] Resolve the host-signals passphrase from the secret store Main's #1036 resolves secret settings from the secret store by the paths TrustedServerAppConfig::secret_fields lists, and push validation skips the validators on those paths because they hold key names. Merging main registered the [ec.providers.hmac] passphrase, but not this branch's [ec.providers.host-signals] passphrase. A key name there failed the passphrase length check when the configuration was pushed, and a key name long enough to pass would have been used unresolved as the HMAC key. The path is now listed as an optional secret and checked as a key name. EdgeZero matches secret paths against validation error keys verbatim, and the derived validation keyed the block's errors by the Rust field name host_signals, so EcProviders now validates each built-in block under the key the configuration uses. New tests cover pushing a host-signals key name, resolving one from the mapped store, and still rejecting a short resolved value. --- crates/trusted-server-core/src/config.rs | 38 +++++++++++++ .../trusted-server-core/src/config_payload.rs | 54 +++++++++++++++++++ crates/trusted-server-core/src/settings.rs | 33 ++++++++++-- 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index b40a9b785b..3057080c96 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -154,6 +154,15 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { ], true, ), + field( + vec![ + object("ec"), + optional_object("providers"), + optional_object("host-signals"), + object("passphrase"), + ], + true, + ), field( vec![ object("ec"), @@ -405,6 +414,12 @@ fn validate_secret_key_references(settings: &Settings) -> Result<(), Report "resolved-partner-api-token-32-bytes-ok", "partner-pull-token-key" => "resolved-partner-pull-token-32-bytes-ok", "trusted-client-ip-key" => "resolved-trusted-client-ip-secret-32-bytes", + "host-signals-passphrase-key" => "resolved-host-signals-passphrase-32-bytes-ok", _ => key, }; Ok(value.as_bytes().to_vec()) @@ -835,6 +836,59 @@ mod tests { ); } + #[test] + fn resolves_the_host_signals_passphrase_from_the_mapped_store() { + let mut original = test_settings(); + original.ec.provider = Some(crate::ec::provider::EcProviderSelection::from( + "host-signals", + )); + original.ec.providers.hmac = None; + original.ec.providers.host_signals = Some(crate::settings::HostSignalsProviderConfig { + passphrase: Redacted::new("host-signals-passphrase-key".to_string()), + }); + + let reconstructed = settings_from_config_blob( + &envelope_json(&original), + &UnifiedSecretStore, + &StoreName::from("ts_secrets"), + ) + .expect("should resolve the host-signals passphrase from the mapped store"); + + assert_eq!( + reconstructed + .ec + .providers + .host_signals + .as_ref() + .map(|config| config.passphrase.expose().as_str()), + Some("resolved-host-signals-passphrase-32-bytes-ok") + ); + } + + #[test] + fn runtime_validation_rejects_a_short_resolved_host_signals_passphrase() { + let mut settings = test_settings(); + settings.ec.provider = Some(crate::ec::provider::EcProviderSelection::from( + "host-signals", + )); + settings.ec.providers.hmac = None; + settings.ec.providers.host_signals = Some(crate::settings::HostSignalsProviderConfig { + passphrase: Redacted::new("short_key".to_owned()), + }); + + let err = load_settings(&envelope_json(&settings)) + .expect_err("should reject a short resolved host-signals passphrase"); + + assert!( + err.to_string().contains("short_passphrase"), + "error should name the passphrase check: {err:?}" + ); + assert!( + !err.to_string().contains("short_key"), + "error should not expose the secret value" + ); + } + #[test] fn placeholder_rejection_happens_after_secret_resolution() { let mut settings = test_settings(); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index bc9dc143ac..2ff2d1dd04 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -13,7 +13,7 @@ use std::sync::OnceLock; use std::time::Duration; use subtle::ConstantTimeEq as _; use url::Url; -use validator::{Validate, ValidationError}; +use validator::{Validate, ValidationError, ValidationErrors}; use crate::auction_config_types::AuctionConfig; use crate::cache_policy::{CachePolicy, CacheVisibility}; @@ -811,18 +811,16 @@ impl Ec { /// The active provider is chosen by the [`Ec::provider`] selector, and the one /// block present must be the one it names (see /// [`Ec::validate_provider_selection`]). -#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] 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`. Creates the Edge /// Cookie from the host's TLS and HTTP/2 signals plus the client IP, so it /// requires a host that supplies those signals. #[serde(default, rename = "host-signals")] - #[validate(nested)] pub host_signals: Option, /// Configuration blocks for vendor or host providers that live in their own @@ -835,6 +833,33 @@ pub struct EcProviders { vendor: HashMap, } +/// Validates each built-in provider block under the key the configuration +/// uses for it. +/// +/// The derived implementation would key a nested error by the Rust field name, +/// `host_signals`, while the configuration, the secret-store resolution and the +/// secret paths `TrustedServerAppConfig::secret_fields` registers all use +/// `host-signals`. `edgezero_core::app_config::validate_excluding_secrets` +/// matches those paths against the error keys verbatim, so a derived key would +/// leave the passphrase checked as a value when it holds a key name at push +/// time. Vendor blocks are validated by the adapter that builds the provider. +impl Validate for EcProviders { + fn validate(&self) -> Result<(), ValidationErrors> { + let mut errors = ValidationErrors::new(); + if let Some(hmac) = &self.hmac { + errors.merge_self("hmac", hmac.validate()); + } + if let Some(host_signals) = &self.host_signals { + errors.merge_self("host-signals", host_signals.validate()); + } + if errors.errors().is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + impl EcProviders { /// Returns the raw configuration block for a vendor provider `key`, or /// `None` when no `[ec.providers.]` block is present. The adapter that From 6e61e0b693502bd7ca1ee9b0e388a9d508682894 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 15 Sep 2026 14:33:49 +0100 Subject: [PATCH 10/16] Name the host-signal Edge Cookie provider host_signals Every name an operator types into configuration is snake_case, so the built-in host-signal provider is now selected with `[ec] provider = "host_signals"` and configured with `[ec.providers.host_signals]`. The key constant, the serde name of the typed block, the validation error key, the registered secret path `ec.providers.host_signals.passphrase`, the placeholder-secret report, the two startup messages that spell the key and the provider's own log line all follow. This commit changes only that one name. The `HostSignals` trait, the `host_signals` fields and arguments that carry it, and the prose that calls this the host-signal provider are unchanged, because a host capability is not a configuration value. The test secret-store key name `host-signals-passphrase-key` is unchanged for the same reason, being a name an operator picks for a secret rather than a provider name. The Rust field name and the configuration name are now the same word, so the `#[serde(rename)]` on the typed block goes and the field is declared the same way as `hmac` beside it. That also retires the reason the `EcProviders` validation keys were spelled out by hand, so the note above those keys is rewritten to say what still holds, which is that each key has to match the secret path `TrustedServerAppConfig::secret_fields` registers. The old name is refused when settings load, which every adapter does before it serves a request. The message names `host_signals` and says what to write. The refusal comes before any block is looked for, because a block left behind under the old name is captured as a vendor block, so the old selector would otherwise find that block, pass the settings check, and fail later in provider resolution with a message about an adapter that supplies no such provider. That is a breaking change, accepted for a major release. Tests. `cargo fmt --all -- --check` is clean and `cargo clippy -p trusted-server-core --all-targets --all-features -- -D warnings` passes. The core suite passes 2,764 tests and 5 doc-tests with 4 ignored, the new test among them. The Axum adapter passes 43 tests across its three binaries, `cargo clippy-axum` is clean, and `cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1` builds. --- crates/trusted-server-adapter-axum/src/app.rs | 2 +- crates/trusted-server-core/src/config.rs | 12 +- .../trusted-server-core/src/config_payload.rs | 8 +- crates/trusted-server-core/src/ec/provider.rs | 28 +++- crates/trusted-server-core/src/settings.rs | 136 +++++++++++++++--- 5 files changed, 149 insertions(+), 37 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 5c167b53b1..2b4af7ec95 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -95,7 +95,7 @@ fn build_state_with_settings( // for every request. It injects and threads no provider, so `EcContext` // resolves the selection itself on every request, building a fresh built-in // provider that reads no request data. It supplies no host signals either, - // so the host-signals argument is `None`. + // so the `host_signals` argument is `None`. ensure_provider_available(&settings.ec, None, None)?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 3057080c96..b28218e852 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -158,7 +158,7 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { vec![ object("ec"), optional_object("providers"), - optional_object("host-signals"), + optional_object("host_signals"), object("passphrase"), ], true, @@ -416,7 +416,7 @@ fn validate_secret_key_references(settings: &Settings) -> Result<(), Report]` block is absent, when a provider block - /// is configured with no selector or alongside `"none"`, or when a - /// configured block is not the selected one. Any selector name is accepted - /// so long as its block is present, so there is no unknown-key check. + /// Returns [`TrustedServerError::Configuration`] when the selector is the + /// old `host-signals` spelling, when the selected provider's + /// `[ec.providers.]` block is absent, when a provider block is + /// configured with no selector or alongside `"none"`, or when a configured + /// block is not the selected one. Any other selector name is accepted so + /// long as its block is present, so there is no unknown-key check. pub fn validate_provider_selection(&self) -> Result<(), Report> { let Some(selection) = self.provider.as_ref() else { if !self.providers.is_empty() { @@ -713,11 +721,28 @@ impl Ec { return Ok(()); }; + let key = key.as_str(); + + // The old spelling of the host-signal provider's name is refused + // before any other question is asked. A block left behind under the + // old name is captured as a vendor block, so without the refusal the + // old selector would find that block, pass the block check below, and + // fail later in provider resolution with a message about an adapter + // that supplies no such provider. + if key == RETIRED_HOST_SIGNALS_PROVIDER_KEY { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider = \"host-signals\" is no longer accepted. The \ + host-signal provider is now named \"host_signals\", so set [ec] \ + provider = \"host_signals\" and rename its block to \ + [ec.providers.host_signals]" + .to_owned(), + })); + } + // Every provider is configured by the `[ec.providers.]` block that // carries its own name, so the check is the same lookup for all of // them. A provider the adapter injects has the contents of its block // validated by that adapter when it builds the provider. - let key = key.as_str(); if !self.providers.has_block(key) { return Err(Report::new(TrustedServerError::Configuration { message: format!( @@ -817,10 +842,10 @@ pub struct EcProviders { #[serde(default)] pub hmac: Option, - /// The built-in host-signal provider, keyed `host-signals`. Creates the Edge + /// The built-in host-signal provider, keyed `host_signals`. Creates the Edge /// Cookie from the host's TLS and HTTP/2 signals plus the client IP, so it /// requires a host that supplies those signals. - #[serde(default, rename = "host-signals")] + #[serde(default)] pub host_signals: Option, /// Configuration blocks for vendor or host providers that live in their own @@ -836,13 +861,15 @@ pub struct EcProviders { /// Validates each built-in provider block under the key the configuration /// uses for it. /// -/// The derived implementation would key a nested error by the Rust field name, -/// `host_signals`, while the configuration, the secret-store resolution and the -/// secret paths `TrustedServerAppConfig::secret_fields` registers all use -/// `host-signals`. `edgezero_core::app_config::validate_excluding_secrets` -/// matches those paths against the error keys verbatim, so a derived key would -/// leave the passphrase checked as a value when it holds a key name at push -/// time. Vendor blocks are validated by the adapter that builds the provider. +/// Each key is spelled out here rather than derived because +/// `edgezero_core::app_config::validate_excluding_secrets` matches the secret +/// paths `TrustedServerAppConfig::secret_fields` registers against these error +/// keys verbatim, so the two are read together and a key that drifted from its +/// registered path would leave the passphrase checked as a value when it holds +/// a key name at push time. Every built-in name is `snake_case`, so each key +/// here is also the Rust field name, the configuration name and the name in +/// the registered path. Vendor blocks are validated by the adapter that builds +/// the provider. impl Validate for EcProviders { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = ValidationErrors::new(); @@ -850,7 +877,7 @@ impl Validate for EcProviders { errors.merge_self("hmac", hmac.validate()); } if let Some(host_signals) = &self.host_signals { - errors.merge_self("host-signals", host_signals.validate()); + errors.merge_self("host_signals", host_signals.validate()); } if errors.errors().is_empty() { Ok(()) @@ -920,7 +947,7 @@ pub struct HmacProviderConfig { /// Configuration for the built-in host-signal Edge Cookie provider. /// -/// Mapped from the `[ec.providers.host-signals]` TOML block. +/// Mapped from the `[ec.providers.host_signals]` TOML block. #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct HostSignalsProviderConfig { @@ -3491,7 +3518,7 @@ impl Settings { 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()); + 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()); @@ -5667,6 +5694,77 @@ mod tests { ); } + #[test] + fn the_old_host_signals_spelling_fails_at_startup_and_names_the_new_one() { + // The host-signal provider was renamed to `host_signals` under the + // rule that every name an operator types into configuration is + // `snake_case`. A deployment still configured with the old spelling + // has to stop when settings load, which every adapter does before it + // serves a request, and the error has to name the spelling to write + // instead. + let selecting = |selector: &str, block: &str| { + let toml = crate_test_settings_str() + .replace("[ec.providers.hmac]", block) + .replace("provider = \"hmac\"", &format!("provider = \"{selector}\"")); + assert!( + toml.contains(&format!("provider = \"{selector}\"")) + && toml.contains(block) + && !toml.contains("[ec.providers.hmac]"), + "the test configuration should select `{selector}` with only `{block}` configured" + ); + toml + }; + + // A block left under the old name is captured as a vendor block, so + // the old selector would find it and pass this check if the name were + // not refused before the block is looked for. + let old = selecting( + RETIRED_HOST_SIGNALS_PROVIDER_KEY, + "[ec.providers.host-signals]", + ); + let err = + Settings::from_toml(&old).expect_err("the old spelling should fail when settings load"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "the old spelling should be a configuration error, got: {:?}", + err.current_context() + ); + assert!( + err.to_string().contains(HOST_SIGNALS_PROVIDER_KEY), + "the error should name `host_signals`, got: {err}" + ); + + // With no block at all the answer has to be the same one, naming the + // new spelling rather than asking for a block under the old name. + let old_without_block = crate_test_settings_str() + .replace("[ec.providers.hmac]", "") + .replace("passphrase = \"test-secret-key-32-bytes-minimum\"", "") + .replace( + "provider = \"hmac\"", + &format!("provider = \"{RETIRED_HOST_SIGNALS_PROVIDER_KEY}\""), + ); + let err = Settings::from_toml(&old_without_block) + .expect_err("the old spelling should fail with no block either"); + assert!( + err.to_string().contains(HOST_SIGNALS_PROVIDER_KEY), + "the error should still name `host_signals`, got: {err}" + ); + + // The same configuration written with the new spelling loads. + let settings = Settings::from_toml(&selecting( + HOST_SIGNALS_PROVIDER_KEY, + "[ec.providers.host_signals]", + )) + .expect("the `host_signals` spelling should load"); + assert!( + settings.ec.providers.host_signals.is_some(), + "the renamed block should deserialize into the typed field" + ); + } + #[test] fn geo_provider_accepts_default_platform_and_none_and_rejects_unknown() { let config = GeoConfig::default(); From 2c34ed11855b482fa94a2ad9fede343bfe0361bb Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 6 Oct 2026 20:10:54 +0100 Subject: [PATCH 11/16] Stop refusing the host-signals spelling by name The Edge Cookie settings refused the old host-signals spelling with a message naming the spelling to write. No configuration in this repository uses it, so the constant, the refusal in validate_module_selection, names_retired_module, the test of the refusal, the doc paragraphs describing it and its row in the error reference are gone. An old spelling now fails as any unknown implementation does when the module is built at startup, with a message listing the implementations the deployment has, which an_unknown_implementation_fails_naming_the_known_ones covers. The settings test kept the one assertion that was not about the old spelling, that a host_signals block is read as the built-in module's own settings, as a test of its own. --- crates/trusted-server-core/src/ec/module.rs | 14 --- crates/trusted-server-core/src/settings.rs | 114 ++++---------------- 2 files changed, 21 insertions(+), 107 deletions(-) diff --git a/crates/trusted-server-core/src/ec/module.rs b/crates/trusted-server-core/src/ec/module.rs index 68d2dfc819..c7bcecc47f 100644 --- a/crates/trusted-server-core/src/ec/module.rs +++ b/crates/trusted-server-core/src/ec/module.rs @@ -127,20 +127,6 @@ pub const HMAC_MODULE_KEY: &str = "hmac"; /// arm when the host-signal module becomes a module of its own. pub const HOST_SIGNALS_PROVIDER_KEY: &str = "host_signals"; -/// The name the host-signal module was selected by before it was renamed -/// under the rule that every name an operator types into configuration is -/// `snake_case`. -/// -/// [`Ec::validate_module_selection`] refuses it at startup with a message -/// naming [`HOST_SIGNALS_PROVIDER_KEY`], so a deployment still configured with -/// the old spelling stops there and the operator is told what to write -/// instead. The refusal comes before any block is looked for, because a block -/// left under the old name is read as the block of a module the adapter -/// injects, so without the refusal the old selector would find that block, -/// pass the settings check, and fail later in [`resolve_named_module`] with -/// a message about an adapter that supplies no such module. -pub(crate) const RETIRED_HOST_SIGNALS_PROVIDER_KEY: &str = "host-signals"; - /// The implementation ids core supplies itself, one per resolution arm in /// [`resolve_named_module`]. /// diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index e1676b0257..4989f62bd8 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -21,10 +21,7 @@ use crate::cache_policy::{CachePolicy, CacheVisibility}; use crate::consent_config::ConsentConfig; use crate::constants::INTERNAL_HEADERS; use crate::creative_opportunities::CreativeOpportunitiesConfig; -use crate::ec::module::{ - EcModuleSelection, HMAC_MODULE_KEY, HOST_SIGNALS_PROVIDER_KEY, - RETIRED_HOST_SIGNALS_PROVIDER_KEY, -}; +use crate::ec::module::{EcModuleSelection, HMAC_MODULE_KEY, HOST_SIGNALS_PROVIDER_KEY}; use crate::error::TrustedServerError; use crate::host_header::validate_host_header_override_value; use crate::platform::PlatformImageOptimizerRegion; @@ -697,47 +694,15 @@ impl Ec { /// one place that knows both the implementations built into core and the /// one this deployment's adapter injects. /// - /// The host-signal provider's old name, `host-signals`, is refused here - /// before any block is looked for, so an operator whose configuration - /// still carries that spelling is told the name to write instead. - /// /// # Errors /// - /// Returns [`TrustedServerError::Configuration`] when the selector is the - /// old `host-signals` spelling, when a module name or implementation is - /// not `snake_case`, when a block is configured with no selector or - /// alongside `"none"`, when the selector names a key `[ec]` reads as its - /// own setting, when a block the selector does not name is configured, or - /// when the selected module resolves to an implementation that needs - /// settings and has no block. + /// Returns [`TrustedServerError::Configuration`] when a module name or + /// implementation is not `snake_case`, when a block is configured with no + /// selector or alongside `"none"`, when the selector names a key `[ec]` + /// reads as its own setting, when a block the selector does not name is + /// configured, or when the selected module resolves to an implementation + /// that needs settings and has no block. pub fn validate_module_selection(&self) -> Result<(), Report> { - // The old spelling of the host-signal module's name is refused - // before any other question is asked, as the selector and as a block - // name, so an operator still carrying it is told the spelling to write - // rather than being handed the general `snake_case` rule below, which - // the old name also breaks. A block left behind under the old name is - // read as the block of a module the adapter injects, so without the - // refusal the old selector would find that block, pass the checks - // below, and fail later in module resolution with a message about an - // adapter that supplies no such module. - let selector_is_retired = matches!( - self.module.as_ref(), - Some(EcModuleSelection::Named(name)) if name == RETIRED_HOST_SIGNALS_PROVIDER_KEY - ); - if selector_is_retired - || self - .module_blocks - .contains_key(RETIRED_HOST_SIGNALS_PROVIDER_KEY) - { - return Err(Report::new(TrustedServerError::Configuration { - message: "[ec] module = \"host-signals\" is no longer accepted. The \ - host-signal module is now named \"host_signals\", so set [ec] \ - module = \"host_signals\" and rename its block to \ - [ec.host_signals]" - .to_owned(), - })); - } - for (name, block) in self.module_blocks.iter() { Self::validate_module_name(name)?; if let Some(implementation) = &block.implementation { @@ -6253,56 +6218,20 @@ mod tests { } #[test] - fn the_old_host_signals_spelling_fails_at_startup_and_names_the_new_one() { - // The host-signal provider was renamed to `host_signals` under the - // rule that every name an operator types into configuration is - // `snake_case`. A deployment still configured with the old spelling - // has to stop when settings load, which every adapter does before it - // serves a request, and the error has to name the spelling to write - // instead. - let selecting = |selector: &str| { - crate_test_settings_str_with_ec_section(&format!( - "[ec]\nmodule = \"{selector}\"\n\n[ec.{selector}]\npassphrase = \"test-secret-key-32-bytes-minimum\"\n" - )) - }; + fn a_host_signals_block_is_read_as_the_built_in_modules_settings() { + // The block is the built-in module's own settings, rather than kept as + // the raw values of a module an adapter injects. + let settings = Settings::from_toml(&crate_test_settings_str_with_ec_section(&format!( + "[ec] +module = \"{HOST_SIGNALS_PROVIDER_KEY}\" - // A block left under the old name is read as the block of a provider - // the adapter injects, so the old selector would find it and pass the - // block check if the name were not refused before the block is looked - // for. - let old = selecting(RETIRED_HOST_SIGNALS_PROVIDER_KEY); - let err = - Settings::from_toml(&old).expect_err("the old spelling should fail when settings load"); - assert!( - matches!( - err.current_context(), - TrustedServerError::Configuration { .. } - ), - "the old spelling should be a configuration error, got: {:?}", - err.current_context() - ); - assert!( - err.to_string().contains(HOST_SIGNALS_PROVIDER_KEY), - "the error should name `host_signals`, got: {err}" - ); - - // With no block at all the answer has to be the same one, naming the - // new spelling rather than asking for a block under the old name. - let old_without_block = crate_test_settings_str_with_ec_section(&format!( - "[ec]\nmodule = \"{RETIRED_HOST_SIGNALS_PROVIDER_KEY}\"\n" - )); - let err = Settings::from_toml(&old_without_block) - .expect_err("the old spelling should fail with no block either"); - assert!( - err.to_string().contains(HOST_SIGNALS_PROVIDER_KEY), - "the error should still name `host_signals`, got: {err}" - ); - - // The same configuration written with the new spelling loads, and the - // block is read as the built-in provider's own settings rather than - // kept as the raw values of a provider an adapter injects. - let settings = Settings::from_toml(&selecting(HOST_SIGNALS_PROVIDER_KEY)) - .expect("the `host_signals` spelling should load"); +\ + [ec.{HOST_SIGNALS_PROVIDER_KEY}] +\ + passphrase = \"test-secret-key-32-bytes-minimum\" +" + ))) + .expect("a host_signals selection with its block should load"); assert!( settings .ec @@ -6310,10 +6239,9 @@ mod tests { .get(HOST_SIGNALS_PROVIDER_KEY) .and_then(EcModuleBlock::host_signals_settings) .is_some(), - "the renamed block should be read as the host-signal provider's settings" + "the block should be read as the host-signal module's settings" ); } - #[test] fn a_labeled_block_the_selector_does_not_name_is_rejected() { // A block under a label is still a module block, so it is held to From badb58b3acd4e9af6db4224d0c61e76436447bf2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 6 Oct 2026 20:11:00 +0100 Subject: [PATCH 12/16] Resolve a cross-named built-in Edge Cookie block's passphrase once The fixed secret list already holds ec.hmac.passphrase and ec.host_signals.passphrase, but labeled_provider_block_names also listed a block named after one built-in that configures the other. The same path was then resolved twice, the second time reading the secret as a key name, and the load failed with a message that read as a missing store entry. A block named after either built-in is now left off the labeled list, because the fixed list covers it whichever it configures. Test a_built_in_block_naming_the_other_built_in_resolves_its_passphrase_once fails without the change. --- crates/trusted-server-core/src/config.rs | 9 +-- .../trusted-server-core/src/config_payload.rs | 72 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index aceeb82795..89ede2a5eb 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -208,8 +208,9 @@ impl crate::secret_resolution::ConfiguredSecretFields for TrustedServerAppConfig /// The names of the `[ec.]` blocks in a serialized configuration that /// configure a module built into core under a label. /// -/// A block named after the implementation it configures is the fixed path -/// `secret_fields` already lists, and a block naming any other implementation +/// A block named after either built-in implementation is a fixed path +/// `secret_fields` already lists, whichever of the two it configures, so it is +/// left out rather than listed twice. A block naming any other implementation /// holds that implementation's settings, which core does not read. fn labeled_provider_block_names(data: &serde_json::Value) -> impl Iterator + '_ { data.get("ec") @@ -223,8 +224,8 @@ fn labeled_provider_block_names(data: &serde_json::Value) -> impl Iterator Result, Report> { + match key { + "ec_key" => Ok(CROSS_NAMED_PASSPHRASE.as_bytes().to_vec()), + CROSS_NAMED_PASSPHRASE => Err(Report::new(PlatformError::SecretStore)), + _ => UnifiedSecretStore.get_bytes(store_name, key), + } + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Ok(()) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Ok(()) + } + } + + /// A block named after one built-in implementation that configures the + /// other is on the fixed secret list already, so its passphrase is + /// resolved once and not read back as a key name. + #[test] + fn a_built_in_block_naming_the_other_built_in_resolves_its_passphrase_once() { + use crate::secret_resolution::ConfiguredSecretFields as _; + + for (name, implementation) in [("hmac", "host_signals"), ("host_signals", "hmac")] { + let mut data = + serde_json::to_value(test_settings()).expect("should serialize settings to JSON"); + data["ec"] = serde_json::json!({ + "module": name, + (name): { "implementation": implementation, "passphrase": "ec_key" }, + }); + + let listed = TrustedServerAppConfig::configured_secret_fields(&data); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); + let envelope_json = + serde_json::to_string(&envelope).expect("should serialize envelope"); + let reconstructed = settings_from_config_blob( + &envelope_json, + &RefusesAValueAsAKey, + &StoreName::from("ts_secrets"), + ) + .unwrap_or_else(|error| panic!("[ec.{name}] should load: {error:?}")); + + assert!( + listed.is_empty(), + "[ec.{name}] is on the fixed list, so it is not listed again" + ); + let written = + serde_json::to_value(&reconstructed).expect("should serialize the loaded settings"); + assert_eq!( + written["ec"][name]["passphrase"], CROSS_NAMED_PASSPHRASE, + "[ec.{name}] should carry the stored passphrase" + ); + } + } + #[test] fn omitted_s3_secret_references_resolve_default_store_keys() { let mut original = test_settings(); From 13cfa9b5efb12ecaafe34246453424fc0a363213 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 6 Oct 2026 20:14:50 +0100 Subject: [PATCH 13/16] The device and geo seams say module where they said provider What an operator selects to supply a capability is a module, and provider keeps the one meaning it has on main, an auction provider instance. This renames the words this branch adds on top of the Edge Cookie seam, which already says module, and only those words: every provider word was compared with upstream main, and a word that exists there is untouched. - DeviceProvider is DeviceModule, and BuiltinDeviceProvider, FastlyDeviceProvider and the test stand-ins are BuiltinDeviceModule, FastlyDeviceModule and so on. DeviceConfig's provider_key is module_key, and build_device_provider and build_geo_provider are build_device_module and build_geo_module. - HostSignalProvider is HostSignalModule, HostSignalsProviderConfig is HostSignalsModuleConfig, and HOST_SIGNALS_PROVIDER_KEY is HOST_SIGNALS_MODULE_KEY. - The [device] and [geo] selectors read module, as [ec] does, so the environment overrides read TRUSTED_SERVER__device__module and TRUSTED_SERVER__geo__module, and labeled_provider_block_names is labeled_module_block_names. - Test helpers and test names say module, and the plain word says module wherever it meant a device, geo or Edge Cookie module, in the core code, the adapters, the device and geo crates, the example settings, the fixtures and the guides. --- crates/device/README.md | 12 +- crates/device/fastly/Cargo.toml | 2 +- crates/device/fastly/src/lib.rs | 20 +-- crates/geo/README.md | 18 +-- crates/geo/fastly/Cargo.toml | 2 +- crates/geo/fastly/src/lib.rs | 18 +-- .../src/platform.rs | 4 +- .../src/platform.rs | 4 +- .../trusted-server-adapter-fastly/src/app.rs | 6 +- .../trusted-server-adapter-fastly/src/main.rs | 22 ++-- .../src/platform.rs | 4 +- .../src/platform.rs | 4 +- crates/trusted-server-core/src/config.rs | 16 +-- .../trusted-server-core/src/config_payload.rs | 10 +- crates/trusted-server-core/src/ec/device.rs | 96 +++++++------- crates/trusted-server-core/src/ec/module.rs | 56 ++++---- crates/trusted-server-core/src/evidence.rs | 4 +- .../trusted-server-core/src/platform/mod.rs | 36 +++--- .../src/platform/traits.rs | 2 +- crates/trusted-server-core/src/settings.rs | 120 +++++++++--------- .../trusted-server-core/src/test_support.rs | 18 ++- .../configs/trusted-server.integration.toml | 2 +- trusted-server.example.toml | 12 +- 23 files changed, 242 insertions(+), 246 deletions(-) diff --git a/crates/device/README.md b/crates/device/README.md index 3d2ca5f9f2..865cb9671f 100644 --- a/crates/device/README.md +++ b/crates/device/README.md @@ -1,10 +1,10 @@ -# Device providers +# Device modules -Device-detection provider crates live here, one per vendor. The Fastly provider +Device-detection module crates live here, one per vendor. The Fastly module (`trusted-server-device-fastly`) classifies a request with the host's TLS and -HTTP/2 signals. Future vendor providers (for example +HTTP/2 signals. Future vendor modules (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`. +The built-in default module (User-Agent only) ships in `trusted-server-core` +(`ec::device`). Adapters select and inject the vendor module via +`build_device_module`. diff --git a/crates/device/fastly/Cargo.toml b/crates/device/fastly/Cargo.toml index 8d4b61d489..c25b4ada19 100644 --- a/crates/device/fastly/Cargo.toml +++ b/crates/device/fastly/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trusted-server-device-fastly" -description = "Fastly host device provider exposing opt-in TLS and HTTP/2 signals." +description = "Fastly host device module exposing opt-in TLS and HTTP/2 signals." authors = { workspace = true } edition = { workspace = true } license = { workspace = true } diff --git a/crates/device/fastly/src/lib.rs b/crates/device/fastly/src/lib.rs index 9b36ea3697..8394059fff 100644 --- a/crates/device/fastly/src/lib.rs +++ b/crates/device/fastly/src/lib.rs @@ -1,8 +1,8 @@ -//! The Fastly device provider and host-signal capture. +//! The Fastly device module and host-signal capture. //! -//! [`FastlyDeviceProvider`] strengthens the built-in User-Agent classification +//! [`FastlyDeviceModule`] strengthens the built-in User-Agent classification //! with the host's TLS (JA4) and HTTP/2 signals, for deployments on Fastly -//! Compute. It is selected by `[device] provider = "fastly"` and wired in by the +//! Compute. It is selected by `[device] module = "fastly"` and wired in by the //! Fastly adapter, which injects the request info and the captured host signals. //! //! [`FastlyHostSignals`] captures those signals from a live Fastly request @@ -11,14 +11,14 @@ //! 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`] +//! platform-neutral [`HostSignals`], [`RequestInfo`], and [`DeviceModule`] //! 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::ec::device::{DeviceModule, DeviceSignals}; use trusted_server_core::evidence::{HostSignals, RequestInfo}; /// Host-computed client signals captured from a live Fastly request. @@ -64,26 +64,26 @@ impl HostSignals for FastlyHostSignals { } } -/// The Fastly device provider, opt-in via `[device] provider = "fastly"`. +/// The Fastly device module, opt-in via `[device] module = "fastly"`. /// /// Classifies a request with [`DeviceSignals::derive`], which strengthens the /// User-Agent classification with the host signals. It reads the User-Agent /// from its injected [`RequestInfo`] and the TLS and HTTP/2 signals from its /// injected [`HostSignals`], so the browser/bot gate is backed by the live /// request. -pub struct FastlyDeviceProvider { +pub struct FastlyDeviceModule { host_signals: Arc, } -impl FastlyDeviceProvider { - /// Creates the provider with its injected host signals. +impl FastlyDeviceModule { + /// Creates the module with its injected host signals. #[must_use] pub fn new(host_signals: Arc) -> Self { Self { host_signals } } } -impl DeviceProvider for FastlyDeviceProvider { +impl DeviceModule for FastlyDeviceModule { fn id(&self) -> &'static str { "fastly" } diff --git a/crates/geo/README.md b/crates/geo/README.md index f3c5d11fb5..b2077f6c88 100644 --- a/crates/geo/README.md +++ b/crates/geo/README.md @@ -1,20 +1,20 @@ -# Geo providers +# Geo modules -Geo and IP-intelligence provider crates live here, one per implementation, each +Geo and IP-intelligence module 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 + module for Fastly Compute, wrapping Fastly's `geo_lookup`. The Fastly adapter + injects it via `build_geo_module`. 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. +- Vendor geo modules (for example `crates/geo/`) will live alongside + it, one per vendor, selected by the `[geo] module` setting. -Whatever the source, a provider returns the same `GeoInfo` coding. The country +Whatever the source, a module 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 +code with no country prefix (`CA`), so the Fastly and other modules 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. +no location until a module is selected. diff --git a/crates/geo/fastly/Cargo.toml b/crates/geo/fastly/Cargo.toml index 79d3acf629..10c80cafca 100644 --- a/crates/geo/fastly/Cargo.toml +++ b/crates/geo/fastly/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trusted-server-geo-fastly" -description = "Fastly host geo provider backed by the Fastly geolocation API." +description = "Fastly host geo module backed by the Fastly geolocation API." authors = { workspace = true } edition = { workspace = true } license = { workspace = true } diff --git a/crates/geo/fastly/src/lib.rs b/crates/geo/fastly/src/lib.rs index 5128fa7c62..32ca0a6060 100644 --- a/crates/geo/fastly/src/lib.rs +++ b/crates/geo/fastly/src/lib.rs @@ -1,12 +1,12 @@ -//! The Fastly host geo provider. +//! The Fastly host geo module. //! //! [`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`. With no selector, -//! or `provider = "platform"`, this host lookup resolves the location, and -//! `provider = "none"` disables geo instead. +//! for deployments on Fastly Compute. It is the host platform's geo module, +//! injected by the Fastly adapter via `build_geo_module`. With no selector, +//! or `module = "platform"`, this host lookup resolves the location, and +//! `module = "none"` disables geo instead. //! -//! Like the Fastly device provider, this crate calls the Fastly SDK directly, +//! Like the Fastly device module, this crate calls the Fastly 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`. @@ -33,9 +33,9 @@ fn geo_from_fastly(geo: &Geo) -> GeoInfo { /// Fastly geo-lookup implementation of [`PlatformGeo`]. /// -/// The host platform geo provider for Fastly Compute. The adapter injects it via -/// `build_geo_provider`. With no selector, or `provider = "platform"`, it -/// resolves the location, and `provider = "none"` disables geo instead. +/// The host platform geo module for Fastly Compute. The adapter injects it via +/// `build_geo_module`. With no selector, or `module = "platform"`, it +/// resolves the location, and `module = "none"` disables geo instead. pub struct FastlyPlatformGeo; impl PlatformGeo for FastlyPlatformGeo { diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index f805b5bb79..369037fe53 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -572,9 +572,9 @@ pub fn build_runtime_services( // 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())) - // Route through the [geo] provider selector like the Fastly adapter, + // Route through the [geo] module selector like the Fastly adapter, // so the selector behaves the same on every adapter. - .geo(trusted_server_core::platform::build_geo_provider( + .geo(trusted_server_core::platform::build_geo_module( settings, Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo) as Arc)), )) diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index fc4adcf899..a7d5c70efc 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -632,9 +632,9 @@ pub fn build_runtime_services( // Geo: read Cloudflare-injected headers — no #[cfg] needed; headers are // simply absent on the native host target, producing Ok(None) from lookup(). - // Routed through the [geo] provider selector like the Fastly adapter, so + // Routed through the [geo] module 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))); + let geo = trusted_server_core::platform::build_geo_module(settings, Arc::new(build_geo(ctx))); RuntimeServices::builder() .config_store(config_store) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 7803ebe042..3be405a02c 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -127,7 +127,7 @@ use trusted_server_core::integrations::{ RequestFilterRegistryOutcome, }; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, StoreName, build_geo_provider, + ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, StoreName, build_geo_module, }; use trusted_server_device_fastly::FastlyHostSignals; @@ -308,7 +308,7 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime // The TLS JA4 and HTTP/2 signals 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 + // host-signal module reads. Fastly always supplies the capability, so the // service is always set even when a request carried no signal. let tls_ja4 = ctx .request() @@ -334,7 +334,7 @@ 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(build_geo_provider( + .geo(build_geo_module( &state.settings, Arc::new(FastlyPlatformGeo), )) diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index c3d25fcd22..f31cb0198a 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -16,7 +16,7 @@ use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::cache_policy::{EdgeCacheHeader, cache_control_headers_have_directive}; -use trusted_server_core::ec::device::{DeviceProvider, DeviceSignals, build_device_provider}; +use trusted_server_core::ec::device::{DeviceModule, DeviceSignals, build_device_module}; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::pull_sync::{ @@ -26,11 +26,11 @@ 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::build_geo_provider; +use trusted_server_core::platform::build_geo_module; 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}; +use trusted_server_device_fastly::{FastlyDeviceModule, FastlyHostSignals}; mod app; mod backend; @@ -615,10 +615,10 @@ fn apply_entry_point_finalize_headers( response: &mut HttpResponse, client_ip: Option, ) { - // Route through the [geo] provider selector, so a deployment that opts + // Route through the [geo] module 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 = build_geo_module(settings, Arc::new(FastlyPlatformGeo)); let geo_info = resolve_geo_for_response(response, client_ip, |client_ip| { geo.lookup(client_ip).unwrap_or_else(|e| { log::warn!("entry-point geo lookup failed: {e}"); @@ -866,15 +866,15 @@ pub(crate) fn extract_cookie_value(req: &HttpRequest, name: &str) -> Option DeviceSignals { let mut headers = HeaderMap::new(); if let Some(value) = req @@ -888,9 +888,9 @@ pub(crate) fn derive_device_signals(settings: &Settings, req: &FastlyRequest) -> .map(|ip| ip.to_string()) .unwrap_or_default(); let request_info = BorrowedRequestInfo::new(&client_ip, None).with_headers(&headers); - build_device_provider(settings, || { + build_device_module(settings, || { let host_signals: Arc = Arc::new(FastlyHostSignals::from_request(req)); - Box::new(FastlyDeviceProvider::new(host_signals)) as Box + Box::new(FastlyDeviceModule::new(host_signals)) as Box }) .detect(&request_info) } diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index c966b540a7..abad65c3fe 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -652,8 +652,8 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { // FastlyPlatformGeo // --------------------------------------------------------------------------- -/// The Fastly host geo provider now lives in its own crate, -/// `trusted-server-geo-fastly`, so every provider implementation sits under +/// The Fastly host geo module now lives in its own crate, +/// `trusted-server-geo-fastly`, so every module 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`. diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 4ccea6c995..cec9732c25 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -736,10 +736,10 @@ pub fn build_runtime_services( .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - // Routed through the [geo] provider selector like the Fastly adapter, + // Routed through the [geo] module 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( + .geo(trusted_server_core::platform::build_geo_module( settings, Arc::new(NullGeo), )) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 89ede2a5eb..be5f695b7b 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -13,7 +13,7 @@ use error_stack::Report; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKind}; -use crate::ec::module::{HMAC_MODULE_KEY, HOST_SIGNALS_PROVIDER_KEY}; +use crate::ec::module::{HMAC_MODULE_KEY, HOST_SIGNALS_MODULE_KEY}; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; use crate::integrations::{ @@ -160,7 +160,7 @@ fn remove_labeled_module_secret_errors(errors: &mut ValidationErrors, ec: &Ec) { ec.module_blocks .host_signals_blocks() .map(|(name, _)| name) - .filter(|name| *name != HOST_SIGNALS_PROVIDER_KEY), + .filter(|name| *name != HOST_SIGNALS_MODULE_KEY), ); for name in labeled { let Some(ValidationErrorsKind::Struct(block_errors)) = ec_errors.errors_mut().get_mut(name) @@ -191,7 +191,7 @@ impl crate::secret_resolution::ConfiguredSecretFields for TrustedServerAppConfig /// operator's choosing holds that secret at `ec.