diff --git a/crates/edgecookie/README.md b/crates/edgecookie/README.md new file mode 100644 index 000000000..6d15fd719 --- /dev/null +++ b/crates/edgecookie/README.md @@ -0,0 +1,10 @@ +# Edge Cookie providers + +Vendor Edge Cookie provider crates live here, one per vendor, for example +`crates/edgecookie/`. Each implements the `EdgeCookieProvider` trait +from `trusted-server-core` and is wired in by an adapter. + +The built-in HMAC provider (HMAC over the client IP) ships in +`trusted-server-core` (`ec::provider`), so no crate is needed for it. There is +no default provider; a deployment selects one explicitly with `[ec] provider`. +This directory is a placeholder until a vendor provider is added. diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 9a371f805..a42d86d8b 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -16,6 +16,7 @@ use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::provider::ensure_provider_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -69,11 +70,26 @@ fn build_state() -> Result, Report> { /// /// # Errors /// -/// Returns an error when the auction orchestrator or the integration -/// registry fail to initialise. +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. fn build_state_with_settings( settings: Settings, ) -> Result, Report> { + // Composition root: reject a provider selection this adapter can never + // supply, once, before any request is served. The Axum dev server injects + // no Edge Cookie provider into `RuntimeServices`, so `None` is exactly what + // `EcContext` sees per request; pass the injected provider here as well + // once this adapter supplies one. + // + // This adapter checks rather than keeps what the check resolved, unlike the + // Fastly, Cloudflare and Spin adapters, because it is a long-lived process + // 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)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -153,13 +169,26 @@ where /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Geo comes from the platform (a no-op on the local Axum dev server, so -/// jurisdiction stays Unknown there unless the request carries TCF consent). A -/// malformed consent string is logged and falls back to the default -/// (fail-closed) context rather than being silently swallowed. -fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) -> EcContext { +/// Geo comes from the platform (a no-op on the local Axum dev server, so +/// jurisdiction stays Unknown there unless the request carries TCF consent), and +/// a geo lookup failure is logged and treated as no location. +/// +/// Mirrors the Fastly entry point, which keeps the report and answers with an +/// error response: when the Edge Cookie context cannot be read the request +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + state: &AppState, + services: &RuntimeServices, + req: &Request, +) -> Result> { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -168,10 +197,6 @@ fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) None }); EcContext::read_from_request_with_geo(&state.settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) } // --------------------------------------------------------------------------- @@ -218,7 +243,7 @@ async fn dispatch_fallback( // Run the server-side auction with the configured creative-opportunity // slots; `handle_publisher_request` matches them against the request path. - let mut ec_context = build_ec_context(state, services, &req); + let mut ec_context = build_ec_context(state, services, &req)?; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -454,7 +479,7 @@ fn named_route_handler( // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. - let ec_context = build_ec_context(&state, &services, &req); + let ec_context = build_ec_context(&state, &services, &req)?; handle_auction( &state.settings, &state.orchestrator, @@ -473,7 +498,7 @@ fn named_route_handler( if req.method() == Method::OPTIONS { Ok(page_bids_preflight_denied()) } else { - let ec_context = build_ec_context(&state, &services, &req); + let ec_context = build_ec_context(&state, &services, &req)?; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -640,3 +665,76 @@ fn build_router(state: &Arc) -> RouterService { router.build() } + +#[cfg(test)] +mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + + use super::*; + + /// Settings selecting a vendor Edge Cookie provider this adapter does not + /// inject, with the `[ec.providers.]` block configuration validation + /// requires. `acme` is a fictional vendor key. + const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.providers.acme] + endpoint = "https://ec.acme.example.com" + "#; + + /// Builds application state directly, bypassing the composition root's + /// startup check, so the per-request behavior can be exercised with a + /// selection the adapter cannot supply. + fn state_with_uninjected_provider() -> AppState { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + let orchestrator = build_orchestrator(&settings).expect("should build orchestrator"); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + } + } + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// This adapter used to log the failure and continue with + /// `EcContext::default()`, so a deployment whose selected provider could not + /// be built served every request with no identity. The call sites propagate + /// the error to `http_error`, matching the Fastly adapter. + #[test] + fn build_ec_context_fails_when_the_selected_provider_is_unavailable() { + let state = state_with_uninjected_provider(); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .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 req = ctx.into_request(); + + let error = build_ec_context(&state, &services, &req) + .expect_err("an unavailable Edge Cookie provider must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected provider, got: {error}" + ); + } +} diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index fd11d7728..6a78e042d 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -193,6 +193,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index ed199e6bf..cd96dfdcf 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -33,6 +33,9 @@ fn test_router() -> edgezero_core::router::RouterService { proxy_secret = "integration-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -829,3 +832,53 @@ async fn first_party_proxy_rebuild_is_routed() { "/first-party/proxy-rebuild must be routed" ); } + +// --------------------------------------------------------------------------- +// Edge Cookie provider availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie provider this adapter does not +/// inject, with the `[ec.providers.]` block configuration validation +/// requires. `acme` is a fictional vendor key. +const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "integration-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.providers.acme] + endpoint = "https://ec.acme.example.com" +"#; + +/// A provider selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this pair (the `[ec.providers.acme]` block +/// is present), and the Axum dev server injects no vendor Edge Cookie provider, +/// so only the composition root can catch it. Without the startup check the +/// deployment would come up and answer every request. +#[test] +fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { + let settings = trusted_server_core::settings::Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = trusted_server_adapter_axum::app::TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected provider should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected provider, got: {error}" + ); +} diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 6ce0a5ee3..d517ce38b 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -18,6 +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::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -56,6 +57,16 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The Edge Cookie provider `[ec] provider` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request, so application state and + /// the request path used to resolve the same selection twice for every + /// request, once to check it could be satisfied and once to use it. + /// Resolving reads no request data, so the result is kept and handed to + /// every request through + /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). + /// `None` for a deployment that selects no provider. + ec_provider: Option>, } /// Build the application state, loading settings and constructing all per-application components. @@ -108,11 +119,19 @@ fn settings_from_cloudflare_config_json() -> Result Result, Report> { + // 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 + // 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)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -120,6 +139,7 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_provider, })) } @@ -127,19 +147,35 @@ fn build_state_with_settings( // Per-request RuntimeServices // --------------------------------------------------------------------------- -fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { - build_runtime_services(ctx) +/// 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. +fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { + build_runtime_services(ctx).with_resolved_ec_provider(state.ec_provider.clone()) } /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Geo comes from the Workers `cf` object when deployed. A malformed -/// consent string is logged and falls back to the default (fail-closed) context -/// rather than being silently swallowed. -fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { +/// Geo comes from the Workers `cf` object when deployed, and a geo lookup +/// failure is logged and treated as no location. +/// +/// Mirrors the Fastly entry point, which keeps the report and answers with an +/// error response: when the Edge Cookie context cannot be read the request +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -148,10 +184,6 @@ fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Reque None }); EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) } // --------------------------------------------------------------------------- @@ -178,7 +210,7 @@ where let s = Arc::clone(&state); let f = f.clone(); Box::pin(async move { - let services = build_per_request_services(&ctx); + let services = build_per_request_services(&s, &ctx); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &s.settings, @@ -376,7 +408,7 @@ fn build_router(state: &Arc) -> RouterService { state: Arc, ctx: RequestContext, ) -> Result { - let services = build_per_request_services(&ctx); + let services = build_per_request_services(&state, &ctx); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); @@ -418,7 +450,13 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie provider is unavailable). Answer with an error + // rather than serving the page with no identity. + let mut ec_context = match build_ec_context(&state.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -523,7 +561,7 @@ fn build_router(state: &Arc) -> RouterService { // Build the geo-aware EC context so the auction consent gate // sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. - let ec_context = build_ec_context(&s.settings, &services, &req); + let ec_context = build_ec_context(&s.settings, &services, &req)?; handle_auction( &s.settings, &s.orchestrator, @@ -587,7 +625,7 @@ fn build_router(state: &Arc) -> RouterService { // preflight fall through to a permissive origin would reopen exactly // the cross-site hole the canonical path closes. let page_bids = make_handler(Arc::clone(&state), |s, services, req| async move { - let ec_context = build_ec_context(&s.settings, &services, &req); + let ec_context = build_ec_context(&s.settings, &services, &req)?; let auction = AuctionDispatch { orchestrator: &s.orchestrator, slots: s.settings.creative_opportunity_slots(), @@ -625,3 +663,68 @@ fn build_router(state: &Arc) -> RouterService { router.build() } } + +#[cfg(test)] +mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + + use super::*; + + /// Settings selecting a vendor Edge Cookie provider this adapter does not + /// inject, with the `[ec.providers.]` block configuration validation + /// requires. `acme` is a fictional vendor key. + const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.providers.acme] + endpoint = "https://ec.acme.example.com" + "#; + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// This adapter used to log the failure and continue with + /// `EcContext::default()`, so a deployment whose selected provider could not + /// be built served every request with no identity. The call sites propagate + /// the error to `http_error`, matching the Fastly adapter. The settings are + /// parsed directly, bypassing the composition root's startup check, so the + /// per-request behavior can be exercised with a selection the adapter + /// cannot supply. + #[test] + fn build_ec_context_fails_when_the_selected_provider_is_unavailable() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + // 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 req = ctx.into_request(); + + let error = build_ec_context(&settings, &services, &req) + .expect_err("an unavailable Edge Cookie provider must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected provider, got: {error}" + ); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 14efed56a..8c1aa2894 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -209,6 +209,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index fb498ce4e..b300e5b37 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -36,6 +36,9 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -85,6 +88,9 @@ fn make_router() -> RouterService { proxy_secret = "integration-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -675,3 +681,53 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { "tsjs catch-all handler must not return 5xx: got {status}" ); } + +// --------------------------------------------------------------------------- +// Edge Cookie provider availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie provider this adapter does not +/// inject, with the `[ec.providers.]` block configuration validation +/// requires. `acme` is a fictional vendor key. +const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.providers.acme] + endpoint = "https://ec.acme.example.com" +"#; + +/// A provider selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this pair (the `[ec.providers.acme]` block +/// is present), and this adapter injects no vendor Edge Cookie provider, so only +/// the composition root can catch it. Without the startup check the deployment +/// would come up and answer every request. +#[test] +fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected provider should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected provider, got: {error}" + ); +} diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 06a0a155f..d70cfea1d 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -112,6 +112,8 @@ use trusted_server_core::ec::consent::ec_consent_withdrawn; 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::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::is_navigation_request; @@ -159,6 +161,16 @@ pub(crate) struct AppState { pub(crate) registry: Arc, pub(crate) default_kv_store: Arc, pub(crate) auction_telemetry_sink: Arc, + /// The Edge Cookie provider `[ec] provider` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request, so application state and + /// the request path used to resolve the same selection twice for every + /// request, once to check it could be satisfied and once to use it. + /// Resolving reads no request data, so the result is kept and handed to + /// every request through + /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). + /// `None` for a deployment that selects no provider. + pub(crate) ec_provider: Option>, } /// Build the application state, loading settings and constructing all per-application components. @@ -177,11 +189,26 @@ pub(crate) fn load_settings_from_config_store() -> Result Result, Report> { warn_if_certificate_check_disabled(&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 + // 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)?; + let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -194,6 +221,7 @@ pub(crate) fn build_state_from_settings( registry: Arc::new(registry), default_kv_store, auction_telemetry_sink, + ec_provider, })) } @@ -261,7 +289,7 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime ..ClientInfo::default() }); - RuntimeServices::builder() + let builder = RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) .kv_store(Arc::clone(&state.default_kv_store)) @@ -274,8 +302,16 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .http_client(Arc::new(FastlyPlatformHttpClient)) .geo(Arc::new(FastlyPlatformGeo)) .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) - .client_info(client_info) - .build() + .client_info(client_info); + + // 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. + match state.ec_provider.clone() { + Some(provider) => builder.resolved_ec_provider(provider).build(), + None => builder.build(), + } } fn publisher_fallback_methods() -> [Method; 7] { @@ -552,7 +588,12 @@ async fn execute_named( // copy is bot-gated, while operators use curl for this // authenticated diagnostic. let kv = crate::maybe_identity_graph(&state.settings); - handle_admin_ec_lookup(kv.as_ref(), ®istry, &req) + // The selected provider decides which identifiers this + // deployment recognizes, so build it here rather than + // assuming the built-in HMAC shape. The read-only + // diagnostic builds no EC request state to borrow it from. + let provider = request_provider(&state.settings.ec, &services)?; + handle_admin_ec_lookup(kv.as_ref(), ®istry, provider.as_deref(), &req) } NamedRouteHandler::AdminEidsLookup => handle_admin_eids_lookup(®istry, &req), _ => unreachable!("admin diagnostics should use early dispatch"), @@ -716,7 +757,11 @@ fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> let result = crate::require_identity_graph(&state.settings).and_then(|kv| { let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); - handle_batch_sync(&kv, &partner_registry, &limiter, req) + // A partner echoes back an identifier the deployment's own provider + // created, so validation and KV normalization are dispatched through + // that provider rather than the built-in HMAC grammar. + let provider = request_provider(&state.settings.ec, services)?; + handle_batch_sync(&kv, &partner_registry, &limiter, provider.as_deref(), req) }); let mut response = result.unwrap_or_else(|e| http_error(&e)); @@ -822,7 +867,7 @@ async fn dispatch_fallback( .ec_context .generate_if_needed(&state.settings, ec.kv_graph.as_ref()) { - log::warn!("EC generation failed for publisher proxy: {err:?}"); + log::error!("EC generation failed for publisher proxy: {err:?}"); } // Publisher pages read consent data, so the consent KV store must be @@ -1358,6 +1403,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" [request_signing] @@ -1427,6 +1475,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -1495,6 +1546,11 @@ mod tests { let registry = IntegrationRegistry::from_request_filters(filters); let default_kv_store = Arc::new(crate::platform::UnavailableKvStore) as Arc; + // 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) + .expect("should resolve the Edge Cookie provider selection"); let state = Arc::new(super::AppState { auction_telemetry_sink: Arc::new( trusted_server_core::auction::NoopAuctionTelemetrySink, @@ -1503,6 +1559,7 @@ mod tests { orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), default_kv_store, + ec_provider, }); TrustedServerApp::routes_for_state(&state) } @@ -1859,6 +1916,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -2512,6 +2572,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -2927,6 +2990,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 90879c3ba..9d9518c57 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -525,6 +525,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 283f16255..39c86ffdd 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -320,6 +320,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index f24b5b717..d11f73eff 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -16,6 +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::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -34,11 +35,14 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; +use trusted_server_core::settings_data::{ + default_config_key, default_config_store_name, get_settings_from_config_store, +}; use crate::middleware::{ AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware, SanitizeRequestMiddleware, }; -use crate::platform::build_runtime_services; +use crate::platform::{SpinPlatformConfigStore, build_runtime_services}; // --------------------------------------------------------------------------- // AppState @@ -49,16 +53,37 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The Edge Cookie provider `[ec] provider` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request, so application state and + /// the request path used to resolve the same selection twice for every + /// request, once to check it could be satisfied and once to use it. + /// Resolving reads no request data, so the result is kept and handed to + /// every request through + /// [`RuntimeServices::resolved_ec_provider`](trusted_server_core::platform::RuntimeServices::resolved_ec_provider). + /// `None` for a deployment that selects no provider. + ec_provider: Option>, } /// Build the application state, loading settings and constructing all per-application components. /// +/// Settings are read from the platform config store at run time, the same way +/// the Fastly and Axum adapters read them, so an operator publishes one with +/// `ts config push` and the deployed component picks it up. This +/// adapter previously compiled `trusted-server.example.toml` into the binary +/// and parsed it here, which could never succeed, because that template ships +/// placeholder secrets and the placeholder admin password fails validation. +/// /// # Errors /// -/// Returns an error when settings, the auction orchestrator, or the integration -/// registry fail to initialise. +/// Returns an error when the config store holds no readable app config, or when +/// settings, the auction orchestrator, or the integration registry fail to +/// initialize. fn build_state() -> Result, Report> { - let settings = Settings::from_toml(include_str!("../../../trusted-server.example.toml"))?; + let store_name = default_config_store_name(); + let config_key = default_config_key(); + let settings = + get_settings_from_config_store(&SpinPlatformConfigStore, &store_name, &config_key)?; build_state_with_settings(settings) } @@ -66,11 +91,19 @@ fn build_state() -> Result, Report> { /// /// # Errors /// -/// Returns an error when the auction orchestrator or the integration -/// registry fail to initialise. +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. fn build_state_with_settings( settings: Settings, ) -> Result, Report> { + // 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 + // 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)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -78,6 +111,7 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_provider, })) } @@ -337,13 +371,26 @@ fn health_response() -> Response { /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Spin's platform geo is a no-op, so jurisdiction stays Unknown unless -/// the request carries TCF consent. A malformed consent string is logged and -/// falls back to the default (fail-closed) context rather than being silently -/// swallowed. -fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { +/// Spin's platform geo is a no-op, so jurisdiction stays Unknown unless the +/// request carries TCF consent, and a geo lookup failure is logged and treated +/// as no location. +/// +/// Mirrors the Fastly entry point, which keeps the report and answers with an +/// error response: when the Edge Cookie context cannot be read the request +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie provider cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -352,10 +399,6 @@ fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Reque None }); EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) } fn admin_key_management_not_supported() -> Response { @@ -496,6 +539,13 @@ impl TrustedServerApp { } } +/// 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. +fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { + build_runtime_services(ctx).with_resolved_ec_provider(state.ec_provider.clone()) +} + fn build_router(state: &Arc) -> RouterService { { let state = Arc::clone(state); @@ -505,7 +555,7 @@ fn build_router(state: &Arc) -> RouterService { let discovery_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_trusted_server_discovery(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -517,7 +567,7 @@ fn build_router(state: &Arc) -> RouterService { let verify_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_verify_signature(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -550,7 +600,7 @@ fn build_router(state: &Arc) -> RouterService { let auction_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); // Request normalization (forwarded-header stripping, trusted // Host/scheme/client-IP derivation) is applied centrally by // `NormalizeMiddleware` before this handler runs, so the signed @@ -567,8 +617,13 @@ fn build_router(state: &Arc) -> RouterService { } // Build the geo-aware EC context so the auction consent gate sees // the caller's jurisdiction — `EcContext::default()` fails it - // closed for consented users. - let ec_context = build_ec_context(&s.settings, &services, &req); + // closed for consented users. When identity cannot be + // established at all, answer with an error rather than running + // the auction with no identity. + let ec_context = match build_ec_context(&s.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; Ok(handle_auction( &s.settings, &s.orchestrator, @@ -588,7 +643,7 @@ fn build_router(state: &Arc) -> RouterService { let page_bids_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( @@ -598,7 +653,13 @@ fn build_router(state: &Arc) -> RouterService { { return Ok(http_error(&error)); } - let ec_context = build_ec_context(&s.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie provider is unavailable). Answer with an error + // rather than re-running the auction with no identity. + let ec_context = match build_ec_context(&s.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &s.orchestrator, slots: s.settings.creative_opportunity_slots(), @@ -623,7 +684,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_proxy_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_first_party_proxy(&s.settings, &services, req) .await @@ -636,7 +697,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_click_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_first_party_click(&s.settings, &services, req) .await @@ -649,7 +710,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_sign_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok(handle_first_party_proxy_sign(&s.settings, &services, req) .await @@ -666,7 +727,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_rebuild_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); Ok( handle_first_party_proxy_rebuild(&s.settings, &services, req) @@ -682,7 +743,7 @@ fn build_router(state: &Arc) -> RouterService { state: Arc, ctx: RequestContext, ) -> Result { - let services = build_runtime_services(&ctx); + let services = build_per_request_services(&state, &ctx); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); @@ -721,7 +782,13 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie provider is unavailable). Answer with an error + // rather than serving the page with no identity. + let mut ec_context = match build_ec_context(&state.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -854,8 +921,98 @@ fn build_router(state: &Arc) -> RouterService { #[cfg(test)] mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + use super::*; + #[test] + fn build_state_takes_its_settings_from_the_platform_config_store() { + // This adapter used to compile the shipped example template into the + // binary and parse it here. That template carries placeholder secrets + // by design, and the placeholder admin password fails + // `validate_admin_handler_passwords`, so `build_state` could never + // return `Ok` and the router fell back to the start-up error handler + // that answers every request with 503. Nothing caught it because every + // other test enters through the `routes_with_settings` parity seam and + // never calls this function. + // + // There is no Spin runtime under `cargo test`, so there are no + // component variables to read and this cannot return `Ok` here. What it + // must never do again is fail because of a configuration baked into the + // binary, so the failure has to be the absence of a config store and + // nothing else. + let Err(error) = build_state() else { + return; + }; + let message = format!("{error:?}"); + assert!( + message.contains("config store"), + "build_state should fail only for want of a config store, got: {message}" + ); + assert!( + !message.to_lowercase().contains("password"), + "build_state must not fail on a configuration compiled into the binary, got: {message}" + ); + } + + /// Settings selecting a vendor Edge Cookie provider this adapter does not + /// inject, with the `[ec.providers.]` block configuration validation + /// requires. `acme` is a fictional vendor key. + const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.providers.acme] + endpoint = "https://ec.acme.example.com" + "#; + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// This adapter used to log the failure and continue with + /// `EcContext::default()`, so a deployment whose selected provider could not + /// be built served every request with no identity. The call sites propagate + /// the error to `http_error`, matching the Fastly adapter. The settings are + /// parsed directly, bypassing the composition root's startup check, so the + /// per-request behavior can be exercised with a selection the adapter + /// cannot supply. + #[test] + fn build_ec_context_fails_when_the_selected_provider_is_unavailable() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + // 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 req = ctx.into_request(); + + let error = build_ec_context(&settings, &services, &req) + .expect_err("an unavailable Edge Cookie provider must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected provider, got: {error}" + ); + } + #[test] fn scheme_host_from_spin_url_extracts_localhost_with_port() { assert_eq!( diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index d7a09987a..a9698350c 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -236,6 +236,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 492f1a518..6c6d1345d 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -149,6 +149,51 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { } } +/// Reads Trusted Server app config from Spin component variables, with no +/// request in hand. +/// +/// Application state is built before any request context exists, so the +/// per-request [`ConfigStoreHandleAdapter`] cannot serve it. Spin component +/// variables are ambient rather than request-scoped, which is how +/// `SpinSecretStoreAdapter` already reads secrets, so the same variables are +/// read directly here. Both paths map keys through [`spin_variable_name`], so +/// start-up and the request path read the same variable for the same key. +/// +/// Outside the Spin runtime, which includes every `cargo test` run on the host, +/// there are no component variables and every read reports that rather than +/// falling back to a configuration compiled into the binary. +pub struct SpinPlatformConfigStore; + +impl PlatformConfigStore for SpinPlatformConfigStore { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + { + let variable_name = spin_variable_name(key, PlatformError::ConfigStore)?; + futures::executor::block_on(spin_sdk::variables::get(&variable_name)).map_err(|error| { + Report::new(PlatformError::ConfigStore).attach(format!( + "config store lookup failed for key `{key}` as Spin variable `{variable_name}`: {error}" + )) + }) + } + #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] + { + Err(Report::new(PlatformError::ConfigStore).attach(format!( + "no config store is available for key `{key}` outside the Spin runtime, where component variables cannot be read" + ))) + } + } + + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore) + .attach("config store writes are not supported on Spin")) + } + + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore) + .attach("config store writes are not supported on Spin")) + } +} + fn spin_variable_name( key: &str, error_context: PlatformError, diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index f75ea687e..4b315ee14 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -35,6 +35,9 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -973,3 +976,53 @@ async fn admin_deactivate_key_auth_fail_returns_401() { "admin/keys/deactivate without credentials must return 401" ); } + +// --------------------------------------------------------------------------- +// Edge Cookie provider availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie provider this adapter does not +/// inject, with the `[ec.providers.]` block configuration validation +/// requires. `acme` is a fictional vendor key. +const UNINJECTED_PROVIDER_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + provider = "acme" + + [ec.providers.acme] + endpoint = "https://ec.acme.example.com" +"#; + +/// A provider selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this pair (the `[ec.providers.acme]` block +/// is present), and this adapter injects no vendor Edge Cookie provider, so only +/// the composition root can catch it. Without the startup check the deployment +/// would come up and answer every request. +#[test] +fn selecting_a_provider_this_adapter_cannot_supply_fails_at_startup() { + let settings = Settings::from_toml(UNINJECTED_PROVIDER_TOML) + .expect("should parse settings selecting an uninjected provider"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected provider should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected provider, got: {error}" + ); +} diff --git a/crates/trusted-server-core/README.md b/crates/trusted-server-core/README.md index 3049a1115..53fd75774 100644 --- a/crates/trusted-server-core/README.md +++ b/crates/trusted-server-core/README.md @@ -51,7 +51,7 @@ Behavior is covered by an extensive test suite in `crates/trusted-server-core/sr - The `ec/` module owns the EC identity subsystem: - `ec/generation.rs` — creates HMAC-based IDs using the client IP and publisher passphrase (format: `64hex.6alnum`). - - `ec/mod.rs` — `EcContext` struct with two-phase lifecycle (`read_from_request` + `generate_if_needed`), `get_ec_id` helper. + - `ec/mod.rs` — `EcContext` struct with two-phase lifecycle (`read_from_request` + `generate_if_needed`). - `ec/consent.rs` — EC-specific consent gating wrapper. - `ec/cookies.rs` — `Set-Cookie` header creation and expiration helpers. - `publisher.rs::handle_publisher_request` issues the `ts-ec` cookie when absent so the browser keeps the identifier on subsequent requests. diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index f878489d6..5f6acf77a 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -467,6 +467,9 @@ origin_url = "https://origin.example.com" proxy_secret = "change-me-proxy-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "production-secret-key-32-bytes-min" [[handlers]] diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 6ede36e9c..2525a528d 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -154,7 +154,9 @@ mod tests { fn strings_that_look_like_json_scalars_round_trip_as_strings() { let mut original = test_settings(); original.publisher.proxy_secret = Redacted::new("1234567890".to_string()); - original.ec.passphrase = Redacted::new("12345678901234567890123456789012".to_string()); + original.ec.providers.hmac = Some(crate::settings::HmacProviderConfig { + passphrase: Redacted::new("12345678901234567890123456789012".to_string()), + }); original.handlers[0].password = Redacted::new("true".to_string()); let reconstructed = settings_from_config_blob(&envelope_json(&original)) @@ -166,8 +168,22 @@ mod tests { "numeric-looking proxy secret should remain a string" ); assert_eq!( - reconstructed.ec.passphrase.expose(), - original.ec.passphrase.expose(), + reconstructed + .ec + .providers + .hmac + .as_ref() + .expect("should reconstruct the hmac provider") + .passphrase + .expose(), + original + .ec + .providers + .hmac + .as_ref() + .expect("should keep the hmac provider") + .passphrase + .expose(), "numeric-looking passphrase should remain a string" ); assert_eq!( diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index e1152b1e7..2e7df513b 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -42,19 +42,31 @@ pub const HEADER_ACCEPT_LANGUAGE: HeaderName = HeaderName::from_static("accept-l pub const HEADER_ACCEPT_ENCODING: HeaderName = HeaderName::from_static("accept-encoding"); pub const HEADER_REFERER: HeaderName = HeaderName::from_static("referer"); -/// TS-internal header names that must NOT be forwarded to downstream third-party services. +/// The fixed response headers that carry Edge Cookie identity output. /// -/// These headers are used internally by Trusted Server for identification, geo-enrichment, -/// debugging, and compression hints. Leaking them to external origins could expose -/// data and internal implementation details. +/// EC finalization strips these from a response the request was not permitted +/// to carry an identity on (see `clear_ec_headers_on_response` in +/// [`finalize`](crate::ec::finalize)), and they are also internal headers, so +/// [`INTERNAL_HEADERS`] is built from this list rather than repeating it. That +/// is the whole reason the list lives here alongside `INTERNAL_HEADERS` and not +/// beside its only reader, because two hand-written copies of one list drift as +/// soon as a header is added to one of them. /// -/// Uses `&str` slices because `HeaderName` has interior mutability and cannot appear -/// in `const` context. -pub const INTERNAL_HEADERS: &[&str] = &[ +/// Uses `&str` slices for the same reason [`INTERNAL_HEADERS`] does. +pub const EC_RESPONSE_HEADERS: &[&str] = &[ "x-ts-ec", "x-ts-eids", "x-ts-ec-consent", "x-ts-eids-truncated", +]; + +/// The internal headers that are not part of the Edge Cookie output surface. +/// +/// Kept apart from [`EC_RESPONSE_HEADERS`] only so [`INTERNAL_HEADERS`] can be +/// assembled from the two without repeating either. Add a header here unless it +/// is one EC finalization has to strip, in which case it belongs in +/// [`EC_RESPONSE_HEADERS`] and reaches [`INTERNAL_HEADERS`] from there. +const NON_EC_INTERNAL_HEADERS: &[&str] = &[ "x-pub-user-id", "x-subject-id", "x-consent-advertising", @@ -76,6 +88,43 @@ pub const INTERNAL_HEADERS: &[&str] = &[ "x-ts-tls-cipher", ]; +/// How many names [`INTERNAL_HEADERS`] holds. +const INTERNAL_HEADER_COUNT: usize = EC_RESPONSE_HEADERS.len() + NON_EC_INTERNAL_HEADERS.len(); + +/// Joins the two source lists into the array [`INTERNAL_HEADERS`] borrows. +/// +/// Written as a `const fn` because slice concatenation is not available in a +/// `const` initializer, and the join has to happen while the crate is compiled +/// so no caller pays for it. +const fn join_internal_headers() -> [&'static str; INTERNAL_HEADER_COUNT] { + let mut joined = [""; INTERNAL_HEADER_COUNT]; + let mut i = 0; + while i < EC_RESPONSE_HEADERS.len() { + joined[i] = EC_RESPONSE_HEADERS[i]; + i += 1; + } + let mut j = 0; + while j < NON_EC_INTERNAL_HEADERS.len() { + joined[i + j] = NON_EC_INTERNAL_HEADERS[j]; + j += 1; + } + joined +} + +/// TS-internal header names that must NOT be forwarded to downstream third-party services. +/// +/// These headers are used internally by Trusted Server for identification, geo-enrichment, +/// debugging, and compression hints. Leaking them to external origins could expose +/// data and internal implementation details. +/// +/// Built at compile time from [`EC_RESPONSE_HEADERS`] followed by +/// [`NON_EC_INTERNAL_HEADERS`], so an Edge Cookie response header cannot be +/// added to one list and missed in the other. +/// +/// Uses `&str` slices because `HeaderName` has interior mutability and cannot appear +/// in `const` context. +pub const INTERNAL_HEADERS: &[&str] = &join_internal_headers(); + // Consent-related cookie names pub const COOKIE_EUCONSENT_V2: &str = "euconsent-v2"; pub const COOKIE_GPP: &str = "__gpp"; @@ -84,3 +133,37 @@ pub const COOKIE_US_PRIVACY: &str = "us_privacy"; // Consent-related header names pub const HEADER_SEC_GPC: HeaderName = HeaderName::from_static("sec-gpc"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_edge_cookie_response_header_is_an_internal_header() { + // These two lists used to be written out by hand in two files, with + // nothing keeping them in step, so a new Edge Cookie response header + // could be stripped by EC finalization and still forwarded to a third + // party. `INTERNAL_HEADERS` is now assembled from + // `EC_RESPONSE_HEADERS`, and this is the assertion that fails if + // anyone goes back to writing them out separately. + for header in EC_RESPONSE_HEADERS { + assert!( + INTERNAL_HEADERS.contains(header), + "`{header}` carries Edge Cookie output, so it must never be forwarded" + ); + } + + assert_eq!( + INTERNAL_HEADERS.len(), + EC_RESPONSE_HEADERS.len() + NON_EC_INTERNAL_HEADERS.len(), + "every internal header should come from exactly one of the two source lists" + ); + + for (index, header) in INTERNAL_HEADERS.iter().enumerate() { + assert!( + !INTERNAL_HEADERS[index + 1..].contains(header), + "`{header}` is listed twice, so the two source lists overlap" + ); + } + } +} diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index 6219af7a9..dfd9434e5 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -32,7 +32,6 @@ use crate::error::TrustedServerError; use crate::openrtb::Eid; use super::eids::{resolve_partner_ids, to_eids}; -use super::generation::is_valid_ec_id; use super::kv::KvIdentityGraph; use super::kv_backend::EcKvLookup; use super::kv_types::{KvEntry, KvMetadata}; @@ -40,6 +39,7 @@ use super::log_id; use super::prebid_eids::{ analyze_prebid_eids_cookie, collect_sharedid_update, dedupe_partner_updates, is_valid_eid_uid, }; +use super::provider::{AcceptedProviders, EdgeCookieProvider}; use super::registry::PartnerRegistry; /// Route prefix shared by the cookie-based and explicit-ID lookup routes. @@ -276,13 +276,14 @@ struct SkippedPartnerId { pub fn handle_admin_ec_lookup( kv: Option<&KvIdentityGraph>, registry: &PartnerRegistry, + provider: Option<&dyn EdgeCookieProvider>, req: &Request, ) -> Result, Report> { let Some(kv) = kv else { return Ok(admin_ec_lookup_not_supported()); }; - let ec_id = match requested_ec_id(req) { + let ec_id = match requested_ec_id(req, &AcceptedProviders::active(provider)) { Ok(ec_id) => ec_id, Err(response) => return Ok(*response), }; @@ -342,9 +343,17 @@ fn cookie_ec_id(req: &Request) -> Result) -> Result>> { +fn requested_ec_id( + req: &Request, + accepted_providers: &AcceptedProviders<'_>, +) -> Result>> { let remainder = req .uri() .path() @@ -358,10 +367,12 @@ fn requested_ec_id(req: &Request) -> Result &'static str { + "opaque" + } + + fn code(&self) -> super::super::provider::ProviderCode { + crate::provider_code!("t0op") + } + + fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &super::super::provider::IdentityInput<'_>, + ) -> Result> + { + Ok(super::super::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[test] + fn requested_ec_id_accepts_the_hmac_envelope() { + let coded = format!("hmac~{}", test_ec_id()); + let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{coded}")); + + let ec_id = requested_ec_id(&request, &AcceptedProviders::active(None)) + .unwrap_or_else(|_| panic!("should accept a coded HMAC identifier in the path")); + + assert_eq!(ec_id, coded, "should look up the identifier as given"); + } + + #[test] + fn requested_ec_id_accepts_the_active_non_hmac_provider_and_rejects_others() { + // The diagnostic must be usable on a deployment whose provider is not + // the built-in HMAC one. Before the dispatch every non-`hmac` code was + // a 400, so an operator could not look up the identifier in the very + // cookie the browser was carrying. + let accepted = AcceptedProviders::active(Some(&OpaqueProvider)); + + let opaque = "t0op~Opaque_Value_MixedCase"; + let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{opaque}")); + let ec_id = requested_ec_id(&request, &accepted) + .unwrap_or_else(|_| panic!("should accept the active provider's identifier")); + assert_eq!(ec_id, opaque, "should look up the identifier as given"); + + // A code no configured provider reads stays a 400, even in the built-in + // HMAC shape, so one deployment cannot inspect another's identifiers. + let foreign = format!("t0zz~{}", test_ec_id()); + let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{foreign}")); + let response = requested_ec_id(&request, &accepted) + .expect_err("an unread provider code should be rejected"); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "an unread provider code should be a 400" + ); + } } diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs index 0e0f3b900..39d615f1d 100644 --- a/crates/trusted-server-core/src/ec/batch_sync.rs +++ b/crates/trusted-server-core/src/ec/batch_sync.rs @@ -20,9 +20,9 @@ use serde::{Deserialize, Serialize}; use crate::error::TrustedServerError; use super::auth::authenticate_bearer; -use super::generation::{is_valid_ec_id, normalize_ec_id_for_kv}; use super::kv::{KvIdentityGraph, UpsertResult}; use super::log_id; +use super::provider::{AcceptedProviders, EdgeCookieProvider}; use super::rate_limiter::RateLimiter; use super::registry::PartnerRegistry; @@ -101,15 +101,17 @@ pub fn handle_batch_sync( kv: &KvIdentityGraph, registry: &PartnerRegistry, rate_limiter: &dyn RateLimiter, + provider: Option<&dyn EdgeCookieProvider>, req: Request, ) -> Result, Report> { - handle_batch_sync_with_writer(kv, registry, rate_limiter, req) + handle_batch_sync_with_writer(kv, registry, rate_limiter, provider, req) } fn handle_batch_sync_with_writer( writer: &dyn BatchSyncWriter, registry: &PartnerRegistry, rate_limiter: &dyn RateLimiter, + provider: Option<&dyn EdgeCookieProvider>, req: Request, ) -> Result, Report> { // 1. Authenticate @@ -153,7 +155,12 @@ fn handle_batch_sync_with_writer( } // 4. Process mappings with per-item validation and rejection reasons. - let (accepted, errors) = process_mappings(writer, &partner.source_domain, &body.mappings); + let (accepted, errors) = process_mappings( + writer, + &partner.source_domain, + &body.mappings, + &AcceptedProviders::active(provider), + ); let rejected = errors.len(); let status = if rejected > 0 { @@ -183,19 +190,24 @@ fn process_mappings( writer: &dyn BatchSyncWriter, partner_id: &str, mappings: &[SyncMapping], + accepted_providers: &AcceptedProviders<'_>, ) -> (usize, Vec) { let mut accepted: usize = 0; let mut errors = Vec::new(); for (idx, mapping) in mappings.iter().enumerate() { - let ec_id = normalize_ec_id_for_kv(&mapping.ec_id); - if !is_valid_ec_id(&ec_id) { + // The global cookie bounds, then the provider that owns the + // identifier's code, which canonicalizes its own value part and decides + // whether the canonical form is one of its own. A partner echoing back + // an identifier a non-HMAC provider created is accepted here; an + // identifier under a code this deployment does not read is not. + let Some(ec_id) = accepted_providers.canonical_kv_key(&mapping.ec_id) else { errors.push(MappingError { index: idx, reason: REASON_INVALID_EC_ID, }); continue; - } + }; if mapping.partner_uid.trim().is_empty() || mapping.partner_uid.len() > MAX_UID_LENGTH { errors.push(MappingError { @@ -266,17 +278,47 @@ mod tests { use super::*; use std::collections::VecDeque; + use crate::ec::provider::{HmacProvider, IdentityInput, ProviderCode}; use crate::error::TrustedServerError; + use crate::evidence::RequestInfo; use crate::redacted::Redacted; use crate::settings::EcPartner; - // EC ID validation tests are in generation.rs (is_valid_ec_id). - // Verify the import works here with a basic smoke test. - #[test] - fn is_valid_ec_id_smoke_test() { - let valid = format!("{}.ABC123", "a".repeat(64)); - assert!(is_valid_ec_id(&valid)); - assert!(!is_valid_ec_id(&"a".repeat(64))); + /// The built-in provider, standing in for a deployment that selected it. + fn hmac_provider() -> HmacProvider { + HmacProvider::new(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())) + } + + /// A non-HMAC provider whose identifiers are opaque, modeling the + /// host-signal provider PR #1044 adds: valid identifiers that the built-in + /// HMAC grammar rejects outright. + #[derive(Debug)] + struct OpaqueProvider; + + impl crate::ec::provider::EdgeCookieProvider for OpaqueProvider { + fn id(&self) -> &'static str { + "opaque" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0op") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(crate::ec::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } } struct MockRateLimiter { @@ -421,7 +463,7 @@ mod tests { .body(EdgeBody::from("not-json")) .expect("should build test request"); - let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, req) + let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, None, req) .expect("should return oversized response"); assert_eq!( @@ -447,7 +489,7 @@ mod tests { .body(EdgeBody::from(oversized_body)) .expect("should build test request"); - let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, req) + let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, None, req) .expect("should return oversized response"); assert_eq!( @@ -466,7 +508,13 @@ mod tests { mapping(&format!("{}.ABC123", "a".repeat(64)), "u3", 1), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 1, "should count successful writes as accepted"); assert_eq!(errors.len(), 2, "should reject invalid mappings only"); @@ -493,7 +541,13 @@ mod tests { mapping(&format!("{}.ABC123", "c".repeat(64)), "u3", 1), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 1, "should keep accepted count before failure"); assert_eq!( @@ -521,7 +575,7 @@ mod tests { .expect("should build test request"); let response = - handle_batch_sync(&kv, ®istry, &limiter, req).expect("should return response"); + handle_batch_sync(&kv, ®istry, &limiter, None, req).expect("should return response"); assert_eq!( response.status(), StatusCode::UNAUTHORIZED, @@ -581,7 +635,13 @@ mod tests { let ec_id = format!("{}.ABC123", "a".repeat(64)); let mappings = vec![mapping(&ec_id, "uid-1", 100), mapping(&ec_id, "uid-2", 101)]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 0, "should not accept ineligible mappings"); assert_eq!(errors.len(), 2, "should report both errors"); @@ -591,13 +651,104 @@ mod tests { assert_eq!(errors[1].reason, REASON_INELIGIBLE); } + #[test] + fn process_mappings_accepts_an_identifier_from_the_active_non_hmac_provider() { + // A deployment whose active provider is not the built-in HMAC one still + // has to accept the identifiers that provider created. Before the + // dispatch these were rejected outright by the HMAC grammar, so a + // partner could never sync a mapping against them. + let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); + let mappings = vec![mapping("t0op~Opaque_Value_MixedCase", "uid-1", 100)]; + + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&OpaqueProvider)), + ); + + assert_eq!(accepted, 1, "the active provider's identifier is accepted"); + assert!( + errors.is_empty(), + "should report no errors, got: {errors:?}" + ); + } + + #[test] + fn process_mappings_rejects_a_code_no_configured_provider_reads() { + // The other side of the dispatch: a code belonging to a provider this + // deployment neither runs nor reads is not an identifier here, whatever + // its shape. + let writer = MockWriter::new(vec![]); + let hmac_shaped = format!("t0zz~{}.ABC123", "a".repeat(64)); + let mappings = vec![ + mapping("t0zz~Opaque_Value", "uid-1", 100), + mapping(&hmac_shaped, "uid-2", 100), + ]; + + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&OpaqueProvider)), + ); + + assert_eq!(accepted, 0, "an unknown provider code is not accepted"); + assert_eq!(errors.len(), 2, "both mappings should be rejected"); + assert!( + errors + .iter() + .all(|error| error.reason == REASON_INVALID_EC_ID), + "should reject as an invalid EC ID, got: {errors:?}" + ); + } + + #[test] + fn process_mappings_canonicalizes_through_the_owning_provider() { + // KV normalization is dispatched the same way as validation. The + // built-in provider lowercases its hash segment, so a partner echoing + // uppercase hex still writes the row created at generation time, while + // the opaque provider's own normalization leaves its value untouched. + let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); + let uppercase = format!("hmac~{}.ABC123", "A".repeat(64)); + let provider = hmac_provider(); + let accepted_providers = AcceptedProviders::active(Some(&provider)); + + assert_eq!( + accepted_providers.canonical_kv_key(&uppercase), + Some(format!("hmac~{}.ABC123", "a".repeat(64))), + "the built-in provider should lowercase only its hash segment" + ); + assert_eq!( + AcceptedProviders::active(Some(&OpaqueProvider)) + .canonical_kv_key("t0op~Opaque_Value_MixedCase"), + Some("t0op~Opaque_Value_MixedCase".to_owned()), + "an opaque provider's identifier should be keyed verbatim" + ); + + let mappings = vec![mapping(&uppercase, "uid-1", 100)]; + let (accepted, errors) = + process_mappings(&writer, "partner", &mappings, &accepted_providers); + assert_eq!(accepted, 1, "uppercase hex should still be accepted"); + assert!( + errors.is_empty(), + "should report no errors, got: {errors:?}" + ); + } + #[test] fn process_mappings_counts_unchanged_as_accepted() { let writer = MockWriter::new(vec![Ok(UpsertResult::Unchanged)]); let ec_id = format!("{}.ABC123", "a".repeat(64)); let mappings = vec![mapping(&ec_id, "uid-1", 100)]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 1, "should count unchanged mappings as accepted"); assert!( @@ -615,7 +766,13 @@ mod tests { mapping(&ec_id, "uid-old", 100), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!( accepted, 2, @@ -623,4 +780,27 @@ mod tests { ); assert!(errors.is_empty(), "should accept valid mappings"); } + + #[test] + fn process_mappings_accepts_a_minted_coded_ec_id() { + let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); + // Partners echo the identifier identify gave them, which carries the + // provider-code envelope since the creation path applies it. + let ec_id = format!("hmac~{}.ABC123", "a".repeat(64)); + let mappings = vec![mapping(&ec_id, "uid-1", 1)]; + + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); + + assert_eq!(accepted, 1, "should accept a coded HMAC identifier"); + assert!( + errors.is_empty(), + "should report no format error for a coded HMAC identifier" + ); + } } diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index ac0e0c05b..08b138389 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -13,8 +13,6 @@ //! endpoint (`/_ts/api/v1/identify`) exposes the EC ID in its response //! body for legitimate JS use cases. -use std::borrow::Cow; - use edgezero_core::body::Body as EdgeBody; use http::{HeaderValue, Response, header}; @@ -24,64 +22,27 @@ use crate::settings::Settings; /// Maximum age for the EC cookie (1 year in seconds). const COOKIE_MAX_AGE: i32 = 365 * 24 * 60 * 60; +/// Maximum length in bytes of an Edge Cookie identifier. +/// +/// A global bound enforced wherever an identifier enters the system (creation, +/// cookie read-back, cookie write), so no provider can emit a value the cookie +/// layer, logs, or the KV key space cannot carry. +pub(crate) const MAX_EC_ID_LEN: usize = 256; + fn is_allowed_ec_id_char(c: char) -> bool { - c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '~') } -// Outbound allowlist for cookie sanitization: permits [a-zA-Z0-9._-] as a -// defense-in-depth backstop when setting the Set-Cookie header. This is -// intentionally broader than the inbound format validator -// (`generation::is_valid_ec_id`), which enforces the exact -// `<64-hex>.<6-alphanumeric>` structure and is used to reject untrusted -// request values before they enter the system. +// Identifier allowlist: [A-Za-z0-9._~-], the cookie-safe alphabet every +// Edge Cookie identifier must fit regardless of which provider created it. +// This is intentionally broader than the built-in format validator +// (`generation::is_valid_ec_id`), which enforces the HMAC provider's +// `<64-hex>.<6-alphanumeric>` structure, either bare or under the `hmac~` +// envelope; an opaque vendor identifier only has to fit the alphabet and the +// length bound. #[must_use] pub(crate) fn ec_id_has_only_allowed_chars(ec_id: &str) -> bool { - ec_id.chars().all(is_allowed_ec_id_char) -} - -fn sanitize_ec_id_for_cookie(ec_id: &str) -> Cow<'_, str> { - if ec_id_has_only_allowed_chars(ec_id) { - return Cow::Borrowed(ec_id); - } - - let safe_id = ec_id - .chars() - .filter(|c| is_allowed_ec_id_char(*c)) - .collect::(); - - log::warn!( - "Stripped disallowed characters from EC ID before setting cookie (len {} -> {}); \ - callers should reject invalid request IDs before cookie creation", - ec_id.len(), - safe_id.len(), - ); - - Cow::Owned(safe_id) -} - -/// Returns `true` if every byte in `value` is a valid RFC 6265 `cookie-octet`. -/// An empty string is always rejected. -/// -/// RFC 6265 restricts cookie values to printable US-ASCII excluding whitespace, -/// double-quote, comma, semicolon, and backslash. Rejecting these characters -/// prevents header-injection attacks where a crafted value could append -/// spurious cookie attributes (e.g. `evil; Domain=.attacker.com`). -/// -/// Non-ASCII characters (multi-byte UTF-8) are always rejected because their -/// byte values exceed `0x7E`. -#[must_use] -fn is_safe_cookie_value(value: &str) -> bool { - // RFC 6265 §4.1.1 cookie-octet: - // 0x21 — '!' - // 0x23–0x2B — '#' through '+' (excludes 0x22 DQUOTE) - // 0x2D–0x3A — '-' through ':' (excludes 0x2C comma) - // 0x3C–0x5B — '<' through '[' (excludes 0x3B semicolon) - // 0x5D–0x7E — ']' through '~' (excludes 0x5C backslash, 0x7F DEL) - // All control characters (0x00–0x20) and non-ASCII (0x80+) are also excluded. - !value.is_empty() - && value - .bytes() - .all(|b| matches!(b, 0x21 | 0x23..=0x2B | 0x2D..=0x3A | 0x3C..=0x5B | 0x5D..=0x7E)) + !ec_id.is_empty() && ec_id.len() <= MAX_EC_ID_LEN && ec_id.chars().all(is_allowed_ec_id_char) } /// Formats a `Set-Cookie` header value for the EC cookie. @@ -98,56 +59,48 @@ fn format_set_cookie(domain: &str, value: &str, max_age: i32) -> String { /// /// Per spec §5.2, the EC cookie domain is computed from /// `settings.publisher.domain` (not `cookie_domain`) to ensure the EC -/// cookie is always scoped to the publisher's apex domain. The EC ID is -/// sanitized through a narrow outbound allowlist as a defense-in-depth -/// backstop against header injection. +/// cookie is always scoped to the publisher's apex domain. Callers validate +/// the identifier with [`ec_id_has_only_allowed_chars`] before this point; +/// an identifier is rejected outright rather than rewritten, so the cookie +/// value and the identity-graph key can never silently diverge. #[must_use] pub(crate) fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String { - let safe_id = sanitize_ec_id_for_cookie(ec_id); - format_set_cookie( &settings.publisher.ec_cookie_domain(), - safe_id.as_ref(), + ec_id, COOKIE_MAX_AGE, ) } /// Sets the EC ID cookie on the given response. /// -/// Validates `ec_id` against RFC 6265 `cookie-octet` rules before -/// interpolation. If the value contains unsafe characters (e.g. semicolons), -/// the cookie is not set and a warning is logged. This prevents an attacker -/// from injecting spurious cookie attributes via a controlled ID value. +/// Validates `ec_id` against the identifier alphabet and length bound before +/// interpolation. An identifier that fails validation is rejected and the +/// cookie is not set, with an error logged; the value is never rewritten, so +/// a provider identifier survives byte for byte or not at all. This also +/// prevents an attacker from injecting spurious cookie attributes via a +/// controlled ID value. /// /// `cookie_domain` comes from operator configuration and is considered trusted. -/// -/// # Panics (debug only) -/// -/// Debug-asserts that `ec_id` passes [`super::generation::is_valid_ec_id`] -/// as a defense-in-depth check against cookie injection. pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_id: &str) { - if !is_safe_cookie_value(ec_id) { - log::warn!( - "Rejecting EC ID for Set-Cookie: value of {} bytes contains characters illegal in a cookie value", - ec_id.len() + if !ec_id_has_only_allowed_chars(ec_id) { + log::error!( + "Rejecting EC ID for Set-Cookie: value of {} bytes is empty, over {} bytes, or \ + contains characters outside the identifier alphabet", + ec_id.len(), + MAX_EC_ID_LEN, ); return; } - debug_assert!( - super::generation::is_valid_ec_id(ec_id), - "EC ID must be validated before cookie creation: got '{ec_id}'" - ); - match HeaderValue::from_str(&create_ec_cookie(settings, ec_id)) { Ok(val) => { response.headers_mut().append(header::SET_COOKIE, val); } Err(e) => { - // Unreachable in practice — is_safe_cookie_value and the debug - // assertion above gate the value, and format_set_cookie emits - // only controlled bytes. Logged for defense-in-depth symmetry - // with the rejection logging above. + // Unreachable in practice: the identifier allowlist above gates + // the value, and format_set_cookie emits only controlled bytes. + // Logged for defense-in-depth symmetry with the rejection above. log::warn!("Skipping EC Set-Cookie: invalid header value: {e}"); } } @@ -177,6 +130,41 @@ pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) #[cfg(test)] mod tests { use super::*; + + #[test] + fn the_ec_cookie_lifetime_is_one_year() { + // The legacy bare-identifier reader's retirement condition (see + // `provider_owns_id`) is written in terms of this lifetime and the + // identity-graph `ENTRY_TTL`, which `kv::tests::constants_have_expected_values` + // pins to the same figure. Changing either moves the earliest safe + // retirement, so neither may drift unnoticed. + assert_eq!( + COOKIE_MAX_AGE, 31_536_000, + "the EC cookie should live one year" + ); + } + + #[test] + fn identifier_bounds_reject_oversize_and_accept_tilde() { + assert!( + ec_id_has_only_allowed_chars("a.~-_Z9"), + "the cookie-safe alphabet includes the tilde" + ); + assert!( + !ec_id_has_only_allowed_chars(""), + "an empty identifier is rejected" + ); + let oversize = "a".repeat(MAX_EC_ID_LEN + 1); + assert!( + !ec_id_has_only_allowed_chars(&oversize), + "an identifier over the length cap is rejected" + ); + let at_cap = "a".repeat(MAX_EC_ID_LEN); + assert!( + ec_id_has_only_allowed_chars(&at_cap), + "an identifier at the length cap is accepted" + ); + } use crate::test_support::tests::create_test_settings; use http::header; @@ -226,17 +214,21 @@ mod tests { } #[test] - fn create_ec_cookie_sanitizes_disallowed_chars_in_id() { + fn set_ec_cookie_rejects_disallowed_chars_outright() { + // Rejection, never rewriting: an identifier outside the alphabet must + // not produce a cookie at all, so the cookie value and the identity + // graph key can never silently diverge. let settings = create_test_settings(); - let result = create_ec_cookie(&settings, "evil;injected\r\nfoo=bar\0baz"); - let value = result - .strip_prefix(&format!("{COOKIE_TS_EC}=")) - .and_then(|s| s.split_once(';').map(|(v, _)| v)) - .expect("should have cookie value portion"); - - assert_eq!( - value, "evilinjectedfoobarbaz", - "should strip disallowed characters and preserve safe chars" + let mut response = Response::new(EdgeBody::empty()); + set_ec_cookie( + &settings, + &mut response, + "evil;injected +foo=bar", + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "an identifier outside the alphabet should set no cookie" ); } @@ -289,47 +281,6 @@ mod tests { ); } - #[test] - fn is_safe_cookie_value_rejects_empty_string() { - assert!(!is_safe_cookie_value(""), "should reject empty string"); - } - - #[test] - fn is_safe_cookie_value_accepts_valid_ec_id_characters() { - assert!( - is_safe_cookie_value("abcdef0123456789.ABCDEFabcdef"), - "should accept hex digits, dots, and alphanumeric characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_non_ascii() { - assert!( - !is_safe_cookie_value("val\u{fc}e"), - "should reject non-ASCII UTF-8 characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_illegal_characters() { - assert!(!is_safe_cookie_value("val;ue"), "should reject semicolon"); - assert!(!is_safe_cookie_value("val,ue"), "should reject comma"); - assert!( - !is_safe_cookie_value("val\"ue"), - "should reject double-quote" - ); - assert!(!is_safe_cookie_value("val\\ue"), "should reject backslash"); - assert!(!is_safe_cookie_value("val ue"), "should reject space"); - assert!( - !is_safe_cookie_value("val\x00ue"), - "should reject null byte" - ); - assert!( - !is_safe_cookie_value("val\x7fue"), - "should reject DEL character" - ); - } - #[test] fn expire_ec_cookie_sets_max_age_zero() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index a553bb7a7..43707ba8b 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -8,33 +8,30 @@ use std::collections::HashSet; use edgezero_core::body::Body as EdgeBody; use http::Response; -use super::consent::{ec_consent_granted, ec_consent_withdrawn}; +use crate::constants::EC_RESPONSE_HEADERS; use crate::settings::Settings; use super::EcContext; +use super::consent::ec_consent_withdrawn; use super::cookies::{expire_ec_cookie, set_ec_cookie}; -use super::generation::is_valid_ec_id; use super::kv::KvIdentityGraph; use super::log_id; use super::prebid_eids::ingest_eid_cookies; +use super::provider::apply_provider_response_headers; use super::registry::PartnerRegistry; -/// TS-managed response headers tied to EC identity output. -const EC_RESPONSE_HEADERS: &[&str] = &[ - "x-ts-ec", - "x-ts-eids", - "x-ts-ec-consent", - "x-ts-eids-truncated", -]; - /// Finalizes EC response behavior for all routes. /// -/// Applies withdrawal handling, last-seen updates, cookie reconciliation, -/// Prebid EID ingestion, and cookie writes for new EC generation. +/// Applies the resolved consent gate, cookie reconciliation, Prebid EID +/// ingestion, and cookie writes for new EC generation. /// -/// On consent withdrawal, the browser response clears the EC cookie -/// immediately and the EC identity-graph KV tombstone is the authoritative -/// revocation marker. There is no separate consent KV store to clean up. +/// When the request carries an explicit withdrawal signal (a storage opt-out or +/// a TCF record refusing storage) and the client presented a cookie, the browser +/// response clears the EC cookie immediately and the EC identity-graph KV +/// tombstone is the authoritative revocation marker. A request that is merely +/// not permitted (pre-consent or fail-closed) strips EC response headers but +/// leaves an already-issued cookie intact. There is no separate consent KV +/// store to clean up. /// /// `eids_cookie` should be the raw value of the `ts-eids` cookie extracted /// from the request *before* routing consumes it. @@ -47,32 +44,47 @@ pub fn ec_finalize_response( sharedid_cookie: Option<&str>, response: &mut Response, ) { - let consent_allows_ec = ec_consent_granted(ec_context.consent()); - let consent_withdrawn = ec_consent_withdrawn(ec_context.consent()); - - if !consent_allows_ec { - // Always strip EC-specific response headers when consent is not - // currently usable for this request. This covers both explicit - // revocation and fail-closed cases such as missing geo or undecodable - // consent input. + // Apply any response headers the active provider asked for during + // generation (for example to request more client evidence). This is empty + // unless a provider produced headers, so it is safe on every path. Each + // one was checked against core's reserved response surface at capture + // time in `EcContext::generate_with_provider`, so nothing here can set a + // managed `ts-` cookie, an `x-ts-` header, or a framing or hop-by-hop + // header. They accumulate with whatever the origin returned rather than + // replacing it, for the reasons on + // `provider::apply_provider_response_headers`. + apply_provider_response_headers( + response.headers_mut(), + ec_context.response_headers().iter().cloned(), + ); + + let ec_permitted = ec_context.ec_allowed(); + + if !ec_permitted { + // Always strip EC-specific response headers when EC is not permitted for + // this request, covering both an explicit withdrawal and fail-closed + // cases such as missing geo or undecodable consent input. clear_ec_headers_on_response(response, Some(registry)); // Only expire the browser cookie and tombstone the identity-graph row - // when the request carries an explicit withdrawal signal. - if consent_withdrawn && ec_context.cookie_was_present() { + // when the request carries an explicit withdrawal signal. A pre-consent + // or fail-closed state (consent is simply not granted) strips headers + // but must not destroy an already-issued identifier, or a returning user + // would be permanently withdrawn before they ever get to consent. + if ec_consent_withdrawn(ec_context.consent()) && ec_context.cookie_was_present() { expire_ec_cookie(settings, response); // Compute once for the authoritative identity-graph tombstones. - let ids_to_withdraw = withdrawal_ec_ids(ec_context); + let keys_to_withdraw = withdrawal_kv_keys(ec_context); // The identity-graph tombstone is the authoritative withdrawal marker // for subsequent EC behavior. if let Some(graph) = kv { - apply_withdrawal_tombstones(&ids_to_withdraw, |ec_id| { - if let Err(err) = graph.write_withdrawal_tombstone(ec_id) { + apply_withdrawal_tombstones(&keys_to_withdraw, |kv_key| { + if let Err(err) = graph.write_withdrawal_tombstone(kv_key) { log::error!( "Failed to write withdrawal tombstone for EC ID '{}': {err:?}", - log_id(ec_id), + log_id(kv_key), ); } }); @@ -82,10 +94,14 @@ pub fn ec_finalize_response( return; } - // Returning user: consent is granted and EC came from request. - if ec_context.ec_was_present() && !ec_context.ec_generated() && consent_allows_ec { - if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) { - ingest_eid_cookies(eids_cookie, sharedid_cookie, ec_id, graph, registry); + // Returning user: EC is permitted and came from the request. + if ec_context.ec_was_present() && !ec_context.ec_generated() && ec_permitted { + // Key EID ingestion by the provider's canonical form of the identifier, + // the key the identity-graph row is stored under, so an ingested EID + // lands on the live row rather than creating a second one keyed by the + // value the browser carries. + if let (Some(graph), Some(kv_key)) = (kv, ec_context.ec_kv_key()) { + ingest_eid_cookies(eids_cookie, sharedid_cookie, &kv_key, graph, registry); } // Ordinary returning-user page views no longer refresh the browser @@ -97,12 +113,15 @@ pub fn ec_finalize_response( // there is no KV graph: that would mint a browser cookie with no backing // identity-graph row, producing a phantom ID on later requests. if ec_context.ec_generated() { - let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) else { - log::info!("Skipping generated EC response write because KV graph is unavailable"); + let (Some(graph), Some(kv_key)) = (kv, ec_context.ec_kv_key()) else { + log::info!( + "Skipping generated EC response write because the KV graph or the \ + identity-graph key is unavailable" + ); return; }; - ingest_eid_cookies(eids_cookie, sharedid_cookie, ec_id, graph, registry); + ingest_eid_cookies(eids_cookie, sharedid_cookie, &kv_key, graph, registry); set_ec_cookie_on_response(settings, ec_context, response); } } @@ -152,30 +171,35 @@ pub fn clear_ec_on_response(settings: &Settings, response: &mut Response HashSet { - let mut hashes = HashSet::new(); - - if let Some(cookie_ec_id) = ec_context.existing_cookie_ec_id() - && is_valid_ec_id(cookie_ec_id) - { - hashes.insert(cookie_ec_id.to_owned()); +/// The identity-graph keys a withdrawal must tombstone. +/// +/// Both the `ts-ec` cookie the request carried and the active identifier are +/// turned into keys by the provider that owns them, so the tombstone lands on +/// the row the live identifier is stored under rather than on the raw cookie +/// value. An identifier no provider this deployment reads owns produces no key +/// and is dropped, which is the same filtering the previous shape check did. +/// The two collapse to one key when they are the same identity written two +/// ways. +fn withdrawal_kv_keys(ec_context: &EcContext) -> HashSet { + let mut keys = HashSet::new(); + + if let Some(cookie_kv_key) = ec_context.cookie_ec_kv_key() { + keys.insert(cookie_kv_key); } - if let Some(active_ec_id) = ec_context.ec_value() - && is_valid_ec_id(active_ec_id) - { - hashes.insert(active_ec_id.to_owned()); + if let Some(active_kv_key) = ec_context.ec_kv_key() { + keys.insert(active_kv_key); } - hashes + keys } -fn apply_withdrawal_tombstones(ec_ids: &HashSet, mut write_tombstone: F) +fn apply_withdrawal_tombstones(kv_keys: &HashSet, mut write_tombstone: F) where F: FnMut(&str), { - for ec_id in ec_ids { - write_tombstone(ec_id); + for kv_key in kv_keys { + write_tombstone(kv_key); } } @@ -219,6 +243,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, jurisdiction: Jurisdiction, + ec_allowed: bool, ) -> EcContext { let consent = ConsentContext { jurisdiction, @@ -232,6 +257,7 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, ) } @@ -241,6 +267,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> EcContext { EcContext::new_for_test_with_cookie( ec_value.map(str::to_owned), @@ -248,9 +275,54 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, ) } + /// The identifier [`CanonicalizingProvider`] creates, as the browser carries + /// it in the `ts-ec` cookie. + const CANONICAL_COOKIE_VALUE: &str = "t0ca~MiXeD.CaseId"; + + /// The identity-graph key generation writes that identifier's row under. + /// Pinned to the creation path by + /// `generate_keys_the_identity_graph_by_the_normalized_identifier` in the + /// `ec` module tests. + const CANONICAL_KV_KEY: &str = "t0ca~mixed.caseid"; + + fn canonicalizing_context( + ec_was_present: bool, + ec_generated: bool, + consent: ConsentContext, + ec_allowed: bool, + ) -> EcContext { + make_context_with_consent( + Some(CANONICAL_COOKIE_VALUE), + Some(CANONICAL_COOKIE_VALUE), + ec_was_present, + ec_generated, + consent, + ec_allowed, + ) + .with_provider_for_test(std::sync::Arc::new( + crate::ec::tests::CanonicalizingProvider, + )) + } + + fn graph_with_live_canonical_row() -> KvIdentityGraph { + let graph = KvIdentityGraph::in_memory("finalize-canonical-store"); + graph + .create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should write the row generation keys by the canonical form"); + graph + } + fn sample_ec_id(suffix: &str) -> String { format!("{}.{suffix}", "a".repeat(64)) } @@ -273,11 +345,18 @@ mod tests { } #[test] - fn withdrawal_ec_ids_returns_cookie_ec_only_when_active_missing() { + fn withdrawal_kv_keys_returns_cookie_ec_only_when_active_missing() { let cookie_ec = sample_ec_id("cook1e"); - let ec_context = make_context(None, Some(&cookie_ec), true, false, Jurisdiction::Unknown); + let ec_context = make_context( + None, + Some(&cookie_ec), + true, + false, + Jurisdiction::Unknown, + false, + ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 1, "should include exactly one EC ID"); assert!( @@ -287,7 +366,7 @@ mod tests { } #[test] - fn withdrawal_ec_ids_deduplicates_matching_cookie_and_active_ec() { + fn withdrawal_kv_keys_deduplicates_matching_cookie_and_active_ec() { let ec_id = sample_ec_id("same01"); let ec_context = make_context( Some(&ec_id), @@ -295,16 +374,17 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 1, "should deduplicate identical EC IDs"); assert!(ids.contains(&ec_id), "should retain the shared EC ID"); } #[test] - fn withdrawal_ec_ids_includes_both_cookie_and_active_when_different() { + fn withdrawal_kv_keys_includes_both_cookie_and_active_when_different() { let active_ec = sample_ec_id("activ1"); let cookie_ec = sample_ec_id("cook1e"); let ec_context = make_context( @@ -313,9 +393,10 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 2, "should include both distinct EC IDs"); assert!(ids.contains(&active_ec), "should include active EC ID"); @@ -323,7 +404,7 @@ mod tests { } #[test] - fn withdrawal_ec_ids_filters_invalid_values() { + fn withdrawal_kv_keys_filters_invalid_values() { let valid_ec = sample_ec_id("valid1"); let ec_context = make_context( Some(&valid_ec), @@ -331,9 +412,10 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 1, "should ignore malformed EC values"); assert!(ids.contains(&valid_ec), "should keep the valid EC ID"); @@ -402,7 +484,7 @@ mod tests { ..Default::default() }; let ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", "stale"); set_header(&mut response, "x-ts-eids", "[]"); @@ -459,6 +541,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -493,6 +576,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -527,6 +611,7 @@ mod tests { false, true, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -554,7 +639,7 @@ mod tests { #[test] fn finalize_denied_without_cookie_is_noop() { let settings = create_test_settings(); - let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown); + let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown, false); let mut response = empty_response(); let test_registry = PartnerRegistry::empty(); @@ -579,7 +664,12 @@ mod tests { } #[test] - fn finalize_unknown_jurisdiction_strips_headers_without_expiring_cookie() { + fn finalize_not_permitted_without_withdrawal_keeps_cookie() { + // When EC is not permitted (here a fail-closed unknown jurisdiction with + // no geo) but the request carries no explicit withdrawal signal, the + // response strips EC headers yet must leave an already-issued cookie + // intact. A pre-consent or transient fail-closed request must not + // permanently withdraw a returning user before they get to consent. let settings = create_test_settings(); let ec_id = sample_ec_id("unk001"); let ec_context = make_context( @@ -588,6 +678,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", &ec_id); @@ -606,15 +697,415 @@ mod tests { assert!( get_header(&response, "x-ts-ec").is_none(), - "should strip EC header when consent cannot be verified" + "should strip EC header when EC is not permitted" ); assert!( get_header(&response, "x-ts-eids").is_none(), - "should strip EID header when consent cannot be verified" + "should strip EID header when EC is not permitted" ); assert!( get_header(&response, "set-cookie").is_none(), - "should not expire the cookie without an explicit withdrawal signal" + "a not-permitted request without a withdrawal signal should keep the cookie" + ); + } + + #[test] + fn set_ec_cookie_on_response_writes_the_ts_ec_cookie() { + // The positive case: when an EC value is present, the finalize path + // writes the ts-ec cookie to the browser, carrying the EC id. + let settings = create_test_settings(); + let ec_id = sample_ec_id("setck1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + true, + ); + let mut response = empty_response(); + + set_ec_cookie_on_response(&settings, &ec_context, &mut response); + + let set_cookie = + get_header_str(&response, "set-cookie").expect("an EC value should write a Set-Cookie"); + assert!( + set_cookie.contains("ts-ec=") && set_cookie.contains(&ec_id), + "should write the ts-ec cookie carrying the EC id, got: {set_cookie}" + ); + } + + #[test] + fn closed_consent_gate_writes_no_ec_cookie() { + // The gate: with the consent gate closed (ec_allowed = false), no + // ts-ec cookie is written, even when an EC value and a generated flag are + // present. The consent gate is what suppresses the cookie. + let settings = create_test_settings(); + let ec_id = sample_ec_id("gated1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + false, + ); + let mut response = empty_response(); + + // Pass a KV graph so the missing-graph guard cannot be the reason the + // cookie is suppressed; the closed gate must be doing the work. + let kv = KvIdentityGraph::failing("test_store"); + let test_registry = PartnerRegistry::empty(); + ec_finalize_response( + &settings, + &ec_context, + Some(&kv), + &test_registry, + None, + None, + &mut response, + ); + + assert!( + get_header(&response, "set-cookie").is_none(), + "a closed consent gate must not write a ts-ec cookie" + ); + } + + #[test] + fn withdrawal_tombstones_the_canonical_row_not_the_cookie_value() { + // The tombstone is the authoritative revocation marker, so it has to + // land on the key the live row uses. Written under the raw cookie + // value it creates a second row nothing reads, and the revocation + // never takes effect for a provider whose canonical form differs. + let settings = create_test_settings(); + let graph = graph_with_live_canonical_row(); + let consent = ConsentContext { + jurisdiction: Jurisdiction::UsState("CA".to_owned()), + gpc: true, + source: ConsentSource::Cookie, + ..Default::default() + }; + let ec_context = canonicalizing_context(true, false, consent, false); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let (live_row, _) = graph + .get(CANONICAL_KV_KEY) + .expect("should read the canonical row") + .expect("the canonical row should still exist"); + assert!( + !live_row.consent.ok, + "withdrawal should tombstone the row the live identifier is keyed by" + ); + assert!( + graph + .get(CANONICAL_COOKIE_VALUE) + .expect("should read the graph") + .is_none(), + "withdrawal should not write a tombstone under the raw cookie value" + ); + } + + #[test] + fn eid_ingestion_keys_by_the_providers_canonical_form() { + // An ingested EID must join the row the identifier already has. Keyed + // by the raw cookie value the upsert finds no row and the partner ID + // is dropped. + let settings = create_test_settings(); + let graph = graph_with_live_canonical_row(); + let partners = vec![make_partner("sharedid.org")]; + let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + source: ConsentSource::Cookie, + ..Default::default() + }; + let ec_context = canonicalizing_context(true, false, consent, true); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + ®istry, + None, + Some("shared-cookie-id"), + &mut response, + ); + + let (row, _) = graph + .get(CANONICAL_KV_KEY) + .expect("should read the canonical row") + .expect("the canonical row should still exist"); + assert_eq!( + row.ids.get("sharedid.org").map(|id| id.uid.as_str()), + Some("shared-cookie-id"), + "the ingested EID should land on the row keyed by the canonical form" + ); + assert!( + graph + .get(CANONICAL_COOKIE_VALUE) + .expect("should read the graph") + .is_none(), + "EID ingestion should not create a row under the raw cookie value" + ); + } + + /// A provider that sets one cookie of its own and one `Vary` entry, the + /// two response effects a provider realistically asks for, so a test can + /// watch both land on a response the origin already wrote headers to. + #[derive(Debug)] + struct EvidenceHeaderProvider; + + impl crate::ec::provider::EdgeCookieProvider for EvidenceHeaderProvider { + fn id(&self) -> &'static str { + "evidence-header" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::provider_code!("t0eh") + } + + fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &crate::ec::provider::IdentityInput<'_>, + ) -> Result< + crate::ec::provider::GeneratedEdgeCookie, + error_stack::Report, + > { + Ok(crate::ec::provider::GeneratedEdgeCookie { + id: Some("evidence-id".to_owned()), + response_headers: vec![ + ( + http::header::SET_COOKIE, + HeaderValue::from_static("vendor-ev=abc; Path=/"), + ), + (http::header::VARY, HeaderValue::from_static("sec-ch-ua")), + ], + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + #[test] + fn provider_response_headers_reach_the_response_without_dropping_the_origins() { + // The response finalization runs on is the finished one, so it already + // carries the publisher origin's own headers. A provider effect must + // add to those, never replace them: replacing `Set-Cookie` would drop + // the publisher's session and sign-in cookies, and replacing `Vary` + // would break the caching the origin asked for. + let settings = create_test_settings(); + let graph = KvIdentityGraph::in_memory("finalize-provider-headers-store"); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + source: ConsentSource::Cookie, + ..Default::default() + }; + let mut ec_context = make_context_with_consent(None, None, false, false, consent, true) + .with_provider_for_test(std::sync::Arc::new(EvidenceHeaderProvider)); + ec_context + .generate_if_needed(&settings, Some(&graph)) + .expect("should create the identifier through the provider"); + + // What the publisher's origin returned, before EC finalization runs. + let mut response = empty_response(); + response.headers_mut().append( + http::header::SET_COOKIE, + HeaderValue::from_static("publisher_session=origin-value; Path=/; HttpOnly"), + ); + response.headers_mut().append( + http::header::VARY, + HeaderValue::from_static("accept-encoding"), + ); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should render set-cookie as utf-8")) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("publisher_session=origin-value")), + "the origin's own cookie must survive a provider effect, got {cookies:?}" + ); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("vendor-ev=abc")), + "the provider's cookie must reach the response, got {cookies:?}" + ); + assert!( + cookies.iter().any(|cookie| cookie.starts_with("ts-ec=")), + "core's own managed cookie must still be written, got {cookies:?}" + ); + + let vary: Vec<&str> = response + .headers() + .get_all(http::header::VARY) + .iter() + .map(|value| value.to_str().expect("should render vary as utf-8")) + .collect(); + assert!( + vary.contains(&"accept-encoding"), + "the origin's Vary must survive a provider effect, got {vary:?}" + ); + assert!( + vary.contains(&"sec-ch-ua"), + "the provider's Vary must reach the response, got {vary:?}" + ); + } + + /// A provider standing in for the one a deployment switched *to*, with a + /// different registered code from the provider that created the live row. + #[derive(Debug)] + struct SwitchedProvider; + + impl crate::ec::provider::EdgeCookieProvider for SwitchedProvider { + fn id(&self) -> &'static str { + "switched" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::provider_code!("t0sw") + } + + fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &crate::ec::provider::IdentityInput<'_>, + ) -> Result< + crate::ec::provider::GeneratedEdgeCookie, + error_stack::Report, + > { + Ok(crate::ec::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_ascii_lowercase() + } + } + + #[test] + fn switching_provider_leaves_the_previous_providers_row_beyond_withdrawal() { + // Pins what a provider switch really does, which the switching + // section of the pluggable-providers spec now states plainly. The + // retired provider's identifier is owned by nobody this deployment + // reads, so a later withdrawal expires the browser cookie but cannot + // tombstone the row, and the identifier is never adopted either. If + // the deferred `legacy_providers` reader list ever lands, this test + // is meant to fail, so that the spec sentence a deployer acts on is + // revisited in the same change. + let settings = create_test_settings(); + let graph = graph_with_live_canonical_row(); + // A TCF record consenting to nothing, under GDPR, so the request + // carries an explicit refusal of storage. That is the narrow, + // destructive kind of withdrawal, the one that tombstones rather than + // merely suppressing, which is the behavior under test. + let consent = ConsentContext { + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(crate::consent::TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents: vec![false; 24], + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + }), + source: ConsentSource::Cookie, + ..Default::default() + }; + // The browser still carries the identifier the previous provider + // created, but the deployment now runs a provider with a different + // code, so read-back treats the cookie as absent and the active + // identifier is empty. + let ec_context = make_context_with_consent( + None, + Some(CANONICAL_COOKIE_VALUE), + false, + false, + consent, + false, + ) + .with_provider_for_test(std::sync::Arc::new(SwitchedProvider)); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + assert!( + ec_context.ec_value().is_none(), + "the retired provider's identifier must never be adopted by the new one" + ); + + let (row, _) = graph + .get(CANONICAL_KV_KEY) + .expect("should read the previous provider's row") + .expect("the previous provider's row should still exist"); + assert!( + row.consent.ok, + "withdrawal cannot reach a retired provider's row without the provider that owns the code" + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should render set-cookie as utf-8")) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("ts-ec=") && cookie.contains("Max-Age=0")), + "withdrawal should still expire the browser cookie after a switch, got {cookies:?}" ); } } diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs index 2924b7692..90530687c 100644 --- a/crates/trusted-server-core/src/ec/generation.rs +++ b/crates/trusted-server-core/src/ec/generation.rs @@ -10,8 +10,8 @@ use hmac::{Hmac, Mac}; use rand::Rng; use sha2::Sha256; +use crate::ec::provider::{HMAC_PROVIDER_CODE, PROVIDER_CODE_SEPARATOR, split_provider_code}; use crate::error::TrustedServerError; -use crate::settings::Settings; type HmacSha256 = Hmac; @@ -81,19 +81,39 @@ fn generate_random_suffix(length: usize) -> String { /// /// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails pub fn generate_ec_id( - settings: &Settings, + passphrase: &str, client_ip: &str, ) -> Result> { - let mut mac = HmacSha256::new_from_slice(settings.ec.passphrase.expose().as_bytes()) - .change_context(TrustedServerError::EdgeCookie { + generate_hmac_ec_id(passphrase, &[client_ip]) +} + +/// Creates an Edge Cookie identifier as HMAC-SHA256 over the given parts plus a +/// random suffix, in the `{64hex}.{6alnum}` format. +/// +/// The parts are joined with a unit separator (`\u{1f}`), which cannot appear in +/// a client IP, User-Agent, or TLS and HTTP/2 signal, so distinct part lists +/// cannot collide. A provider that derives identity from multiple request +/// signals (for example a Fastly provider over JA4, H2, IP, and UA) passes them +/// as separate parts. Each part must be pre-normalized by the caller. +/// +/// # Errors +/// +/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +pub fn generate_hmac_ec_id( + passphrase: &str, + parts: &[&str], +) -> Result> { + let mut mac = HmacSha256::new_from_slice(passphrase.as_bytes()).change_context( + TrustedServerError::EdgeCookie { message: "Failed to create HMAC instance".to_string(), - })?; - mac.update(client_ip.as_bytes()); + }, + )?; + // A unit separator cannot occur in any part, so distinct lists never collide. + mac.update(parts.join("\u{1f}").as_bytes()); let hmac_hash = hex::encode(mac.finalize().into_bytes()); - // Append random 6-character alphanumeric suffix for additional uniqueness. - let random_suffix = generate_random_suffix(6); - let ec_id = format!("{hmac_hash}.{random_suffix}"); + // Append a random 6-character alphanumeric suffix for additional uniqueness. + let ec_id = format!("{hmac_hash}.{}", generate_random_suffix(6)); log::trace!("Generated fresh EC ID: {}", super::log_id(&ec_id)); @@ -120,12 +140,33 @@ pub fn ec_hash(ec_id: &str) -> &str { /// so internal EC IDs are already lowercase. This normalization is a /// defense-in-depth measure for EC IDs submitted by external partners /// (via batch sync) that may use uppercase hex. +/// +/// An identifier this function creates carries the built-in provider's code +/// envelope (`hmac~` before the value, see +/// [`PROVIDER_CODE_SEPARATOR`](super::provider::PROVIDER_CODE_SEPARATOR)), +/// and partners echo that form back, so the envelope is kept and only the +/// value inside it is lowercased. That keeps the key identical to the one +/// written at creation. An identifier under any other provider's code is not +/// HMAC-shaped and is returned unchanged, because only that provider knows +/// how to normalize it. #[must_use] pub fn normalize_ec_id_for_kv(ec_id: &str) -> String { - let mut parts = ec_id.splitn(2, '.'); + let (code, bare) = match split_provider_code(ec_id) { + (Some(code), bare) if code == HMAC_PROVIDER_CODE.as_str() => (Some(code), bare), + (Some(_), _) => return ec_id.to_owned(), + (None, bare) => (None, bare), + }; + let mut parts = bare.splitn(2, '.'); let hash = parts.next().unwrap_or_default(); let suffix = parts.next().unwrap_or_default(); - format!("{}.{}", hash.to_ascii_lowercase(), suffix) + match code { + Some(code) => format!( + "{code}{PROVIDER_CODE_SEPARATOR}{}.{}", + hash.to_ascii_lowercase(), + suffix + ), + None => format!("{}.{}", hash.to_ascii_lowercase(), suffix), + } } /// Checks whether a string is a valid 64-character hex EC hash prefix. @@ -139,17 +180,35 @@ pub fn is_valid_ec_hash(value: &str) -> bool { value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) } -/// Checks whether a string matches the expected EC ID format. +/// Checks whether a string is a built-in HMAC identifier, bare or enveloped. /// -/// The format is `{64hex}.{6alnum}` where the first part is a 64-character -/// **lowercase** hex string and the second part is a 6-character alphanumeric -/// string. Only lowercase hex is accepted; callers must normalize before -/// validation to prevent duplicate KV keys from case-variant EC IDs. The HMAC -/// prefix is lowercase because it comes from `hex::encode`; the random suffix -/// allows mixed-case alphanumeric characters by construction. +/// The bare format is `{64hex}.{6alnum}` where the first part is a +/// 64-character **lowercase** hex string and the second part is a 6-character +/// alphanumeric string. Only lowercase hex is accepted; callers must +/// normalize before validation to prevent duplicate KV keys from case-variant +/// EC IDs. The HMAC prefix is lowercase because it comes from `hex::encode`; +/// the random suffix allows mixed-case alphanumeric characters by +/// construction. +/// +/// An identifier this provider creates carries the provider-code envelope, +/// `hmac~` before the bare value, so both the enveloped and the legacy bare +/// form are accepted here. An identifier under any other provider's code is +/// not an HMAC identifier and is rejected. +/// +/// This is the built-in provider's grammar, not the deployment's. The +/// partner-facing paths (pull sync, batch sync, the admin lookup) dispatch by +/// provider code through +/// [`AcceptedProviders`](super::provider::AcceptedProviders), which reaches +/// this only for an identifier the built-in provider owns, or as the fallback +/// for a stateless deployment that has selected no provider at all. #[must_use] pub fn is_valid_ec_id(value: &str) -> bool { - let mut parts = value.split('.'); + let bare = match split_provider_code(value) { + (Some(code), bare) if code == HMAC_PROVIDER_CODE.as_str() => bare, + (Some(_), _) => return false, + (None, bare) => bare, + }; + let mut parts = bare.split('.'); let Some(hmac_part) = parts.next() else { return false; }; @@ -175,7 +234,39 @@ mod tests { use super::*; use std::net::{Ipv4Addr, Ipv6Addr}; - use crate::test_support::tests::create_test_settings; + const TEST_PASSPHRASE: &str = "test-secret-key-32-bytes-minimum"; + + #[test] + fn generate_hmac_ec_id_is_stable_per_parts_and_collision_resistant() { + // The 64-char hex prefix is HMAC over the parts and is stable for the + // same parts; the random suffix varies, so compare prefixes only. + let prefix = |parts: &[&str]| { + generate_hmac_ec_id(TEST_PASSPHRASE, parts) + .expect("should generate") + .split('.') + .next() + .expect("should have a prefix") + .to_owned() + }; + + assert_eq!( + prefix(&["a", "b"]), + prefix(&["a", "b"]), + "the same parts should yield the same stable prefix" + ); + assert_ne!( + prefix(&["a", "b"]), + prefix(&["a", "c"]), + "different parts should yield a different prefix" + ); + // The unit separator prevents a join collision: ["a", "b"] must not hash + // the same as ["ab"]. + assert_ne!( + prefix(&["a", "b"]), + prefix(&["ab"]), + "the separator should prevent ['a','b'] colliding with ['ab']" + ); + } #[test] fn normalize_ipv4_unchanged() { @@ -215,8 +306,7 @@ mod tests { #[test] fn generate_produces_valid_format() { - let settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, "192.168.1.1").expect("should generate EC ID"); + let ec_id = generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate EC ID"); assert!( is_valid_ec_id(&ec_id), "should match EC ID format: {{64hex}}.{{6alnum}}, got: {ec_id}" @@ -225,10 +315,10 @@ mod tests { #[test] fn generate_same_ip_produces_consistent_hash_prefix() { - let settings = create_test_settings(); - let first = generate_ec_id(&settings, "192.168.1.1").expect("should generate first EC ID"); + let first = + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate first EC ID"); let second = - generate_ec_id(&settings, "192.168.1.1").expect("should generate second EC ID"); + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate second EC ID"); assert_eq!( ec_hash(&first), @@ -321,4 +411,37 @@ mod tests { "should reject extra segments" ); } + + #[test] + fn is_valid_ec_id_accepts_the_hmac_envelope() { + let coded = format!("hmac~{}.ABC123", "a".repeat(64)); + assert!( + is_valid_ec_id(&coded), + "should accept a created identifier carrying the hmac code" + ); + } + + #[test] + fn is_valid_ec_id_rejects_other_provider_codes() { + let coded = format!("t0op~{}.ABC123", "a".repeat(64)); + assert!( + !is_valid_ec_id(&coded), + "should reject an identifier carrying another provider's code" + ); + } + + #[test] + fn normalize_ec_id_for_kv_keeps_the_hmac_envelope() { + let coded = format!("hmac~{}.ABC123", "A".repeat(64)); + assert_eq!( + normalize_ec_id_for_kv(&coded), + format!("hmac~{}.ABC123", "a".repeat(64)), + "should lowercase the hash and keep the code prefix" + ); + assert_eq!( + normalize_ec_id_for_kv("t0op~MixedCase"), + "t0op~MixedCase", + "should leave another provider's identifier unchanged" + ); + } } diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 6ca251905..2df83d37e 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -10,7 +10,6 @@ use http::{Request, Response, StatusCode}; use url::Url; use super::auth::authenticate_bearer; -use super::consent::ec_consent_granted; use crate::error::TrustedServerError; use crate::openrtb::{Eid, Uid}; use crate::settings::Settings; @@ -62,7 +61,7 @@ pub fn handle_identify( ); }; - if !ec_consent_granted(ec_context.consent()) { + if !ec_context.ec_allowed() { return json_response_with_origin( StatusCode::FORBIDDEN, &serde_json::json!({ "consent": "denied" }), @@ -87,40 +86,53 @@ pub fn handle_identify( let mut uid: Option = None; let mut cluster_size: Option = None; - match kv.get(ec_id) { - Ok(Some((entry, generation))) => { - if !entry.consent.ok { - // Tombstone entries preserve the withdrawal signal for 24 hours. - // Do not extract IDs or evaluate cluster size because that would - // write back with the live-entry TTL. - log::trace!("Identify found tombstone for '{}'", log_id(ec_id)); - } else { - // Extract only this partner's UID. - if let Some(partner_uid) = entry.ids.get(&partner.source_domain) - && !partner_uid.uid.is_empty() - { - uid = Some(partner_uid.uid.clone()); - } - - // Evaluate cluster size lazily for identify responses. Existing - // stored cluster_size values are reused without a prefix-list call. - match kv.evaluate_cluster(ec_id, &entry, generation) { - Ok(size) => { - cluster_size = size; + // Read the identity-graph row under the provider's canonical form of the + // identifier, the same key generation wrote, rather than under the value the + // browser carries. The two are the same string for the built-in HMAC + // provider and differ for any provider whose canonical form is not the + // cookie value. `None` means no provider this deployment reads owns the + // identifier, so there is no row to look for and the response is not + // degraded. + if let Some(kv_key) = ec_context.ec_kv_key() { + match kv.get(&kv_key) { + Ok(Some((entry, generation))) => { + if !entry.consent.ok { + // Tombstone entries preserve the withdrawal signal for 24 + // hours. Do not extract IDs or evaluate cluster size because + // that would write back with the live-entry TTL. + log::trace!("Identify found tombstone for '{}'", log_id(&kv_key)); + } else { + // Extract only this partner's UID. + if let Some(partner_uid) = entry.ids.get(&partner.source_domain) + && !partner_uid.uid.is_empty() + { + uid = Some(partner_uid.uid.clone()); } - Err(err) => { - log::warn!("Cluster evaluation failed for '{}': {err:?}", log_id(ec_id)); + + // Evaluate cluster size lazily for identify responses. + // Existing stored cluster_size values are reused without a + // prefix-list call. + match kv.evaluate_cluster(&kv_key, &entry, generation) { + Ok(size) => { + cluster_size = size; + } + Err(err) => { + log::warn!( + "Cluster evaluation failed for '{}': {err:?}", + log_id(&kv_key) + ); + } } } } - } - Ok(None) => {} - Err(err) => { - log::warn!( - "Identify KV read failed for EC ID '{}': {err:?}", - log_id(ec_id) - ); - degraded = true; + Ok(None) => {} + Err(err) => { + log::warn!( + "Identify KV read failed for EC ID '{}': {err:?}", + log_id(&kv_key) + ); + degraded = true; + } } } @@ -332,7 +344,6 @@ fn apply_cors_headers(response: &mut Response, origin: &str) { #[cfg(test)] mod tests { use super::*; - use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; use crate::ec::registry::PartnerRegistry; use crate::redacted::Redacted; @@ -352,13 +363,23 @@ mod tests { ); } - fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { + /// The identifier [`CanonicalizingProvider`] creates, as the browser + /// carries it in the `ts-ec` cookie. + const CANONICAL_COOKIE_VALUE: &str = "t0ca~MiXeD.CaseId"; + + /// The identity-graph key generation writes that identifier's row under. + /// Pinned to the creation path by + /// `generate_keys_the_identity_graph_by_the_normalized_identifier` in the + /// `ec` module tests, which asserts both the key it writes and the key + /// [`EcContext::ec_kv_key`] derives. + const CANONICAL_KV_KEY: &str = "t0ca~mixed.caseid"; + + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { let consent = ConsentContext { - jurisdiction, source: ConsentSource::Cookie, ..ConsentContext::default() }; - EcContext::new_for_test(ec_value.map(str::to_owned), consent) + EcContext::new_for_test_gated(ec_value.map(str::to_owned), consent, ec_allowed) } fn make_test_partner(source_domain: &str, api_token: &str) -> EcPartner { @@ -472,7 +493,7 @@ mod tests { .uri("https://edge.test-publisher.com/identify") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -514,7 +535,7 @@ mod tests { .header("authorization", "Bearer wrong-token") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -539,7 +560,7 @@ mod tests { .header("authorization", format!("Bearer {VALID_API_TOKEN}")) .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::Unknown, None); + let ec_context = make_ec_context(false, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct denied response"); @@ -573,7 +594,7 @@ mod tests { .header("authorization", format!("Bearer {VALID_API_TOKEN}")) .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response"); @@ -599,7 +620,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build test request"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct degraded identify response"); @@ -652,7 +673,7 @@ mod tests { .header("origin", "https://evil.example") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct forbidden response"); @@ -678,7 +699,7 @@ mod tests { .header("origin", "https://www.test-publisher.com") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response with CORS headers"); @@ -756,4 +777,57 @@ mod tests { "should vary on identity request inputs for preflight" ); } + + #[test] + fn handle_identify_reads_the_row_under_the_providers_canonical_key() { + // A provider whose canonical form is not the cookie value keys its row + // under the canonical form at generation. Identify has to look there, + // or every such deployment reads a miss for every request and reports + // no partner UID at all. + let settings = create_test_settings(); + let kv = KvIdentityGraph::in_memory("identify-canonical-store"); + kv.create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should write the row generation keys by the canonical form"); + let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; + let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); + let req = Request::builder() + .method("GET") + .uri("https://edge.test-publisher.com/identify") + .header("authorization", format!("Bearer {VALID_API_TOKEN}")) + .body(EdgeBody::empty()) + .expect("should build test request"); + let ec_context = make_ec_context(true, Some(CANONICAL_COOKIE_VALUE)) + .with_provider_for_test(std::sync::Arc::new( + crate::ec::tests::CanonicalizingProvider, + )); + + let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + .expect("should build identify response"); + + assert_eq!(response.status(), StatusCode::OK, "should return 200"); + let body = serde_json::from_slice::( + &response.into_body().into_bytes().unwrap_or_default(), + ) + .expect("should decode identify response JSON"); + assert_eq!( + body["ec"], CANONICAL_COOKIE_VALUE, + "should echo the identifier the browser carries, not the graph key" + ); + assert_eq!( + body["uid"], "partner-uid-123", + "should find the row generation keyed by the provider's canonical form" + ); + assert_eq!( + body["degraded"], + serde_json::Value::Bool(false), + "a hit under the canonical key is not a degraded read" + ); + } } diff --git a/crates/trusted-server-core/src/ec/kv.rs b/crates/trusted-server-core/src/ec/kv.rs index 3572581ce..14f6f3487 100644 --- a/crates/trusted-server-core/src/ec/kv.rs +++ b/crates/trusted-server-core/src/ec/kv.rs @@ -712,6 +712,19 @@ impl KvIdentityGraph { } // Compute cluster size via prefix list. + // + // `ec_hash` takes everything before the first `.`, so a coded + // identifier yields `hmac~` and a legacy bare one yields + // ``. Prefix matching is anchored at the start of the key, so + // the two never see each other: while pre-epic bare cookies are still + // being read back, two rows for the same client IP that straddle the + // envelope each count only their own half and `cluster_size` + // under-reports. That is accepted, not a defect to work around here. + // The count is reported in identify responses and gates nothing, and + // bridging it would mean a second prefix scan on every request for the + // whole migration window. See section 3 of the pluggable-providers + // design. Anyone making this count gate a decision has to fix the + // bridge first. let hash_prefix = ec_hash(ec_id); let cluster_size = self.count_hash_prefix_keys(hash_prefix)?; diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 840ce90d3..5ef301da2 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -45,6 +45,7 @@ pub mod kv_backend; pub mod kv_types; pub mod partner; pub mod prebid_eids; +pub mod provider; pub mod pull_sync; pub mod rate_limiter; pub mod registry; @@ -60,6 +61,8 @@ pub fn log_id(ec_id: &str) -> String { format!("{prefix}\u{2026}") } +use std::sync::Arc; + use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; use error_stack::Report; @@ -70,10 +73,12 @@ use crate::constants::COOKIE_TS_EC; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; +use crate::evidence::BorrowedRequestInfo; use crate::geo::GeoInfo; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; +use provider::{EdgeCookieProvider, GeneratedEdgeCookie, IdentityInput}; use self::kv::KvIdentityGraph; use self::kv_types::KvEntry; @@ -115,24 +120,6 @@ fn request_ec_id_if_allowed(value: &str, source: &str) -> Option { None } -/// Gets an existing EC ID from the request. -/// -/// Attempts to retrieve an existing EC ID from the `ts-ec` cookie. -/// -/// Returns `None` if the cookie does not contain a valid EC ID. -/// -/// # Errors -/// -/// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails -pub fn get_ec_id(req: &Request) -> Result, Report> { - let parsed = parse_ec_from_request(req)?; - let ec_id = parsed.cookie_ec.filter(|v| is_valid_ec_id(v)); - if let Some(ref id) = ec_id { - log::trace!("Existing EC ID found: {}", log_id(id)); - } - Ok(ec_id) -} - /// Captures the EC state for a single request lifecycle. /// /// Created via [`read_from_request`](Self::read_from_request) during @@ -152,6 +139,10 @@ pub struct EcContext { ec_generated: bool, /// The consent context for this request. consent: ConsentContext, + /// Whether Edge Cookie creation is allowed for this request. Resolved once + /// at construction: closed when no provider is selected, otherwise from the + /// request's consent context. Read via [`ec_allowed`](Self::ec_allowed). + ec_allowed: bool, /// The normalized client IP, captured early before the request body /// is consumed. `None` when the platform cannot determine client IP. client_ip: Option, @@ -161,6 +152,27 @@ pub struct EcContext { /// Set via [`EcContext::set_device_signals`] before /// [`EcContext::generate_if_needed`] is called. device_signals: Option, + /// The selected Edge Cookie provider (built-in or injected), built once at + /// construction. Core asks it whether an identifier is well formed + /// ([`accepts_id`](crate::ec::provider::EdgeCookieProvider::accepts_id)) so + /// an opaque vendor identifier round-trips through read-back and withdrawal + /// instead of being dropped by the built-in shape check. `None` when no + /// provider is configured. + selected_provider: Option>, + /// A snapshot of the request evidence a provider reads at generation time: + /// the request headers (so a provider can read cookies and client hints), and + /// the URL path and query string (so it can read request parameters). + /// Captured once at construction, and only when a provider is configured and + /// the request carries no usable identifier, so a no-provider deployment and + /// a returning visitor both clone nothing. A provider reads these through + /// [`RequestInfo`](crate::evidence::RequestInfo) at generate time. + request_headers: http::HeaderMap, + request_path: String, + request_query: String, + /// Response headers a provider asked to set, captured during + /// [`EcContext::generate_if_needed`] and applied to the response by EC + /// finalization. Empty for providers that set no headers. + response_headers: Vec<(http::HeaderName, http::HeaderValue)>, } impl EcContext { @@ -200,13 +212,50 @@ impl EcContext { ) -> Result> { let parsed = parse_ec_from_request(req)?; - let ec_value = parsed.cookie_ec.clone().filter(|v| is_valid_ec_id(v)); + // 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. + let selected_provider: Option> = + provider::request_provider(&settings.ec, services)?; + + // Read back an existing identifier only when the selected provider + // accepts its shape, so an opaque vendor identifier (for example a signed + // envelope) round-trips instead of being silently dropped by the built-in + // shape check. With no provider configured, Trusted Server is stateless: + // an existing identifier is treated as absent so it is never used or + // egressed, while the raw cookie value stays available to withdrawal + // handling below. + let ec_value = parsed.cookie_ec.clone().filter(|v| { + selected_provider + .as_ref() + .is_some_and(|selected| provider::provider_owns_id(selected.as_ref(), v)) + }); let ec_was_present = ec_value.is_some(); if let Some(ref id) = ec_value { log::trace!("Existing EC ID found: {}", log_id(id)); } + // Snapshot the request evidence a provider reads at generation time (the + // headers, so it can read cookies and client hints, and the URL path and + // query, so it can read request parameters). Capture only when a provider + // is configured and no identifier already exists, so a no-provider + // deployment and a returning visitor clone nothing. Generation runs after + // the request body may be consumed, so the snapshot is owned. + let (request_headers, request_path, request_query) = + if selected_provider.is_some() && ec_value.is_none() { + ( + req.headers().clone(), + req.uri().path().to_owned(), + req.uri().query().unwrap_or_default().to_owned(), + ) + } else { + (http::HeaderMap::new(), String::new(), String::new()) + }; + // Capture the client IP from platform services (normalized). let client_ip = services .client_info() @@ -223,11 +272,20 @@ impl EcContext { kv_store: None, }); + // Gate Edge Cookie creation and use on the request's consent context + // (jurisdiction and consent signals). With no provider selected nothing + // may create or use an identifier, so the gate is closed rather than + // open by default. Downstream consumers read the stored result via + // [`EcContext::ec_allowed`] rather than re-deriving it. + let ec_allowed = selected_provider + .as_ref() + .is_some_and(|_| consent::ec_consent_granted(&consent)); + log::info!( - "EC context: present={}, cookie_present={}, consent_allowed={}, jurisdiction={}", + "EC context: present={}, cookie_present={}, ec_allowed={}, jurisdiction={}", ec_was_present, parsed.cookie_ec.is_some(), - consent::ec_consent_granted(&consent), + ec_allowed, consent.jurisdiction, ); @@ -237,9 +295,15 @@ impl EcContext { ec_was_present, ec_generated: false, consent, + ec_allowed, client_ip, geo_info: geo_info.cloned(), device_signals: None, + selected_provider, + request_headers, + request_path, + request_query, + response_headers: Vec::new(), }) } @@ -254,8 +318,13 @@ impl EcContext { /// /// # Errors /// - /// Returns an error if the client IP is unavailable and generation is - /// needed, or if HMAC generation fails. + /// Forwards every error from `generate_with_provider`: the selected provider + /// failing to derive an identifier (which includes a provider that needs the + /// client IP being run on a host that cannot supply one), the provider + /// producing an identifier outside the cookie-safe alphabet or over the + /// length cap, the provider asking for a response header inside core's + /// reserved surface, or persisting the identifier to the KV identity graph + /// failing. pub fn generate_if_needed( &mut self, settings: &Settings, @@ -265,22 +334,126 @@ impl EcContext { return Ok(()); } - if !consent::ec_consent_granted(&self.consent) { + // A deployment with no provider selected is stateless: nothing to + // generate, and not an error. Reuse the provider built at read time + // rather than building it again. + let Some(ec_provider) = self.selected_provider.clone() else { + log::trace!("EC generation skipped: no Edge Cookie provider configured"); + return Ok(()); + }; + + if !self.ec_allowed { log::info!( - "EC generation skipped: consent not granted (jurisdiction={})", + "EC generation skipped: EC creation not permitted (jurisdiction={})", self.consent.jurisdiction, ); return Ok(()); } - let client_ip = self.client_ip.as_deref().ok_or_else(|| { - Report::new(TrustedServerError::EdgeCookie { - message: "Client IP required for EC generation but unavailable".to_owned(), - }) - })?; + // Whether the client IP is needed is the selected provider's decision, + // not core's. A provider that derives identity from headers, cookies, + // query parameters, or the client reads no IP and must still run on a + // host that cannot supply one. The IP is passed as the documented + // unavailable value, the empty string (see + // [`RequestInfo::client_ip`](crate::evidence::RequestInfo::client_ip)), + // and a provider that needs it refuses there, returning the error to + // the caller. The publisher proxy and integration proxy log it and + // serve the response without an Edge Cookie. + self.generate_with_provider(ec_provider.as_ref(), settings, kv) + } - let ec_id = generation::generate_ec_id(settings, client_ip)?; - log::info!("Generated new EC ID: {}", log_id(&ec_id)); + /// Derives and commits an EC identifier using a specific provider. + /// + /// Split out of [`generate_if_needed`](Self::generate_if_needed) so the + /// provider is supplied explicitly, resolved once at read time and threaded + /// here rather than rebuilt. The request evidence captured at read time + /// (client IP, headers, and the URL path and query) is passed borrowed + /// through [`RequestInfo`](crate::evidence::RequestInfo), so a provider can + /// read cookies and request parameters at generate time; the built-in HMAC + /// provider reads only the client IP. The skip guards (existing EC, consent + /// gate) stay in [`generate_if_needed`](Self::generate_if_needed). + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when the provider fails to + /// derive an identifier (which for [`HmacProvider`] includes an + /// unavailable client IP), the provider + /// asks for a response header inside core's reserved surface (see + /// [`reserved_response_effect`](crate::ec::provider::reserved_response_effect)), + /// or persisting a generated identifier to the KV identity graph fails. + fn generate_with_provider( + &mut self, + ec_provider: &dyn EdgeCookieProvider, + settings: &Settings, + kv: Option<&KvIdentityGraph>, + ) -> Result<(), Report> { + let input = IdentityInput { + consent: Some(&self.consent), + }; + // Pass the request evidence captured at read time, borrowed: the client + // IP, the request headers (so a provider reads cookies and client hints), + // and the URL path and query (so it reads request parameters). A built-in + // provider reads only the client IP; a vendor provider reads what it + // needs through [`RequestInfo`]. + let request_info = BorrowedRequestInfo::new( + self.client_ip.as_deref().unwrap_or_default(), + Some(&self.request_headers), + ) + .with_request_target(&self.request_path, &self.request_query); + let generated: GeneratedEdgeCookie = ec_provider.generate(&request_info, &input)?; + // Check every response header the provider asked for against core's + // reserved surface before any of them are kept. A provider may set its + // own cookies and headers, but not a managed `ts-` cookie, a header in + // the `x-ts-` namespace, or a framing or hop-by-hop header. Rejection + // fails the request, matching the identifier-bounds rejection below: + // without it a provider could write `ts-ec` itself and bypass the + // identifier validation and identity-graph row this function enforces. + // Checked before the identifier is read, because a provider can return + // headers with no identifier at all. + for (name, value) in &generated.response_headers { + if let Some(effect) = provider::reserved_response_effect(name, value) { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Provider `{}` returned a response header `{name}` that {effect}", + ec_provider.id(), + ), + })); + } + } + // Capture any response headers the provider asked for, even when it + // produced no identifier (for example while it still needs more client + // evidence). EC finalization applies them to the response. + self.response_headers = generated.response_headers; + let generated_id = generated + .id + .map(|value| crate::ec::provider::apply_provider_code(ec_provider, &value)); + let Some(ec_id) = generated_id else { + log::info!( + "EC generation produced no identifier (provider={}); proceeding without an EC", + ec_provider.id(), + ); + return Ok(()); + }; + // Enforce the global identifier bounds at creation. The cookie-safe + // alphabet and the length cap apply to every provider, so no + // implementation can emit a value the cookie layer or the identity + // graph cannot carry. Rejection is loud and total; the identifier is + // never rewritten. + if !ec_id_has_only_allowed_chars(&ec_id) { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Provider `{}` produced an identifier that is empty, over {} bytes, or \ + outside the cookie-safe alphabet", + ec_provider.id(), + cookies::MAX_EC_ID_LEN, + ), + })); + } + log::info!( + "Generated new EC ID (provider={}): {}", + ec_provider.id(), + log_id(&ec_id), + ); self.ec_value = Some(ec_id); self.ec_generated = true; @@ -297,7 +470,13 @@ impl EcContext { .as_ref() .map(DeviceSignals::to_kv_device); - if let Err(err) = graph.create_or_revive(ec_value, &entry) { + // Key the identity graph by the provider's canonical form of the + // identifier, so equivalent representations of one identity share + // one row. The built-in normalization lowercases only the HMAC + // hash segment; an opaque vendor provider overrides it to the + // identity function. + let kv_key = crate::ec::provider::provider_kv_key(ec_provider, ec_value); + if let Err(err) = graph.create_or_revive(&kv_key, &entry) { log::error!( "Failed to create or revive EC entry for id '{}' after generation: {err:?}", log_id(ec_value), @@ -319,6 +498,84 @@ impl EcContext { self.ec_value.as_deref() } + /// The providers whose identifiers this request's paths accept. + /// + /// Today that is the selected provider alone (see + /// [`AcceptedProviders`](provider::AcceptedProviders) for the + /// `legacy_providers` seam). + #[must_use] + pub(crate) fn accepted_providers(&self) -> provider::AcceptedProviders<'_> { + provider::AcceptedProviders::active(self.selected_provider.as_deref()) + } + + /// Returns whether `value` is a well-formed identifier for the selected + /// provider. + /// + /// Lets core validate a cookie or active identifier (for example before + /// withdrawing it) through the provider that issued it, rather than assuming + /// the built-in shape. The global cookie bounds are checked first, then the + /// provider-specific part is dispatched by the identifier's code. Falls back + /// to the built-in shape when no provider is configured. + #[must_use] + pub(crate) fn accepts_id(&self, value: &str) -> bool { + self.accepted_providers().accepts(value) + } + + /// The identity-graph key for `value` under the providers this deployment + /// reads. + /// + /// The canonical route from an identifier to a row key. The organic + /// generate, identify and finalize paths turn an identifier into a row key + /// through this (or through [`ec_kv_key`](Self::ec_kv_key), which wraps it), + /// so a provider whose canonical form differs from the cookie value still + /// finds the row it created. The owning provider is picked by the + /// identifier's `{code}~` prefix and supplies the canonical form of its own + /// value part, matching what + /// [`generate_if_needed`](Self::generate_if_needed) wrote at creation. + /// + /// Known gap: pull sync (`ec::pull_sync`) and the admin lookup + /// (`ec::admin`) still key rows by the raw active identifier rather than + /// this canonical form, so for a provider whose canonical form differs from + /// the cookie value they can read or write under the wrong key. The three + /// organic paths were routed through the canonical form (commit + /// `343ac3e`); these two were left keying raw and are tracked as a known + /// issue for a later change. + /// + /// `None` when no provider this deployment reads owns `value`, in which + /// case there is no row to read or write. + #[must_use] + pub(crate) fn kv_key_for(&self, value: &str) -> Option { + self.accepted_providers().canonical_kv_key(value) + } + + /// The identity-graph key for this request's active identifier. + #[must_use] + pub(crate) fn ec_kv_key(&self) -> Option { + self.ec_value().and_then(|value| self.kv_key_for(value)) + } + + /// The identity-graph key for the `ts-ec` cookie the request carried. + /// + /// Withdrawal tombstones the cookie's row as well as the active one, + /// because a stateless deployment leaves [`ec_kv_key`](Self::ec_kv_key) + /// empty while a live row still exists, and the cookie is the only way + /// back to it. + /// + /// This does not reach across a provider switch. An identifier created + /// under a retired provider's `{code}~` prefix is owned by no provider + /// this deployment reads, so [`kv_key_for`](Self::kv_key_for) yields + /// `None` and its row is never tombstoned. Core cannot derive that key, + /// because the canonical form is the owning provider's own normalization. + /// The browser cookie is still expired, since that path keys off the raw + /// cookie rather than off ownership. See the provider-switching section + /// of the pluggable providers design spec for what an operator has to do + /// about it. + #[must_use] + pub(crate) fn cookie_ec_kv_key(&self) -> Option { + self.existing_cookie_ec_id() + .and_then(|value| self.kv_key_for(value)) + } + /// Returns whether the `ts-ec` cookie was present on the incoming request. #[must_use] pub fn cookie_was_present(&self) -> bool { @@ -348,7 +605,8 @@ impl EcContext { /// /// Allows handlers to apply query-param fallback consent for the current /// request only when pre-routing consent extraction produced an empty - /// context. + /// context. Mutations do not re-derive [`ec_allowed`](Self::ec_allowed), + /// which is resolved once at construction. pub fn consent_mut(&mut self) -> &mut ConsentContext { &mut self.consent } @@ -365,6 +623,14 @@ impl EcContext { self.device_signals = Some(signals); } + /// Returns the response headers a provider asked to set during + /// [`generate_if_needed`](Self::generate_if_needed). Empty unless a provider + /// produced any. + #[must_use] + pub fn response_headers(&self) -> &[(http::HeaderName, http::HeaderValue)] { + &self.response_headers + } + /// Returns the device signals, if set. #[must_use] pub fn device_signals(&self) -> Option<&DeviceSignals> { @@ -383,10 +649,14 @@ impl EcContext { self.geo_info.as_ref() } - /// Returns whether EC creation is permitted by consent for this request. + /// Returns whether Edge Cookie creation is allowed for this request. + /// + /// Resolved once at construction: closed when no provider is selected, + /// otherwise from the request's consent context (see + /// [`consent::ec_consent_granted`]). #[must_use] pub fn ec_allowed(&self) -> bool { - consent::ec_consent_granted(&self.consent) + self.ec_allowed } /// Returns the existing EC cookie value for revocation handling. @@ -399,35 +669,67 @@ impl EcContext { self.cookie_ec_value.as_deref() } - /// Returns `true` when the request carried a cookie EC and the selected - /// active EC differs from that cookie value. - #[must_use] - pub fn cookie_differs_from_active_ec(&self) -> bool { - matches!( - (self.cookie_ec_value.as_deref(), self.ec_value.as_deref()), - (Some(cookie), Some(active)) if cookie != active - ) - } - /// Returns the stable EC hash prefix from the active EC value. #[must_use] pub fn ec_hash(&self) -> Option<&str> { self.ec_value.as_deref().map(generation::ec_hash) } - /// Creates a test-only `EcContext` with explicit field values. + /// Attaches a selected provider to a test-only [`EcContext`]. + /// + /// The production constructor builds the provider from settings and + /// injected services. A test that only needs the provider's identifier + /// semantics (which identifiers it owns, and their canonical key form) + /// takes this shortcut instead. + #[cfg(test)] + #[must_use] + pub fn with_provider_for_test( + mut self, + provider: Arc, + ) -> Self { + self.selected_provider = Some(provider); + self + } + + /// Creates a test-only `EcContext` whose creation gate is derived from the + /// consent context, matching the production construction path. + /// + /// Use [`new_for_test_gated`](Self::new_for_test_gated) when a test needs + /// an explicit gate. #[cfg(test)] #[must_use] pub fn new_for_test(ec_value: Option, consent: ConsentContext) -> Self { + let ec_allowed = consent::ec_consent_granted(&consent); + Self::new_for_test_gated(ec_value, consent, ec_allowed) + } + + /// Creates a test-only `EcContext` with an explicit creation gate. + /// + /// `ec_allowed` stands in for the gating decision the production path + /// resolves at construction, so a test can exercise the gate-open and + /// gate-closed branches directly. + #[cfg(test)] + #[must_use] + pub fn new_for_test_gated( + ec_value: Option, + consent: ConsentContext, + ec_allowed: bool, + ) -> Self { Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, ec_generated: false, consent, + ec_allowed, client_ip: None, geo_info: None, device_signals: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } @@ -439,15 +741,22 @@ impl EcContext { consent: ConsentContext, client_ip: Option, ) -> Self { + let ec_allowed = consent::ec_consent_granted(&consent); Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, ec_generated: false, consent, + ec_allowed, client_ip, geo_info: None, device_signals: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } @@ -461,6 +770,7 @@ impl EcContext { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> Self { Self { ec_value, @@ -468,9 +778,15 @@ impl EcContext { ec_was_present, ec_generated, consent, + ec_allowed, client_ip: None, geo_info: None, device_signals: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } } @@ -494,6 +810,8 @@ pub(crate) fn current_timestamp() -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::ec::provider::{EcProviderSelection, ProviderCode}; + use crate::evidence::{OwnedRequestInfo, RequestInfo}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; @@ -512,6 +830,798 @@ mod tests { format!("{}.{suffix}", prefix_char.repeat(64)) } + /// A provider that records the `Cookie` header from the request info passed + /// to `generate`, so a test can prove request cookies reach a provider (a + /// client that stores values in cookies relies on this). + #[derive(Debug)] + struct CookieCapturingProvider { + seen_cookie: std::sync::Mutex>, + } + + impl EdgeCookieProvider for CookieCapturingProvider { + fn id(&self) -> &'static str { + "cookie-capturing" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0cc") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let cookie = request_info.header("cookie").map(ToOwned::to_owned); + *self.seen_cookie.lock().expect("should lock seen cookie") = cookie; + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn a_provider_reads_request_cookies_from_the_request_info() { + // RequestInfo contract: a provider given request info that carries + // headers can read request cookies through it (a client that stores + // values in cookies relies on this). The organic generate path passes a + // snapshot of the request headers through generate_with_provider, so a + // provider reads request cookies through it there too; this test + // supplies its own headers directly. + let mut headers = http::HeaderMap::new(); + headers.insert( + "cookie", + "client-id=abc123; ts-ec=xyz" + .parse() + .expect("should build a valid cookie header"), + ); + let request_info = OwnedRequestInfo::new("203.0.113.7".to_owned(), headers); + let provider = CookieCapturingProvider { + seen_cookie: std::sync::Mutex::new(None), + }; + + provider + .generate(&request_info, &IdentityInput::default()) + .expect("generation should succeed"); + + assert_eq!( + provider + .seen_cookie + .lock() + .expect("should lock seen cookie") + .as_deref(), + Some("client-id=abc123; ts-ec=xyz"), + "the provider should read the request cookies from the request info" + ); + } + + /// A provider whose identifiers are opaque and deliberately not the + /// built-in HMAC shape (no dot, mixed case), modeling a vendor identifier + /// such as a signed envelope. It accepts any of its own non-empty + /// identifiers. + #[derive(Debug)] + struct OpaqueIdProvider; + + impl EdgeCookieProvider for OpaqueIdProvider { + fn id(&self) -> &'static str { + "opaque" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0op") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + /// A geo that resolves to the non-regulated jurisdiction (US, no region), + /// so the consent gate is open and generation runs in provider tests. + fn non_regulated_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + } + } + + #[test] + fn read_from_request_reuses_the_provider_the_composition_root_resolved() { + // Reading EC state runs on every request, and it used to resolve + // `[ec] provider` itself even though the composition root had just + // resolved the same settings, so the provider was built twice per + // request. An adapter now threads the resolved provider through + // `RuntimeServices`, and the context has to take that instance. + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("opaque")); + + let ec_config = settings.ec.clone(); + let resolved = crate::ec::provider::build_shared_provider( + &ec_config, + Some(Arc::new(OpaqueIdProvider)), + ) + .expect("the composition root should resolve the selection") + .expect("the selection should yield a provider"); + + let services = crate::platform::test_support::noop_services_with_resolved_ec_provider( + Arc::clone(&resolved), + ); + let req = create_test_request(&[]); + let ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + + let used = ec + .selected_provider + .as_ref() + .expect("the context should hold the selected provider"); + assert!( + Arc::ptr_eq(used, &resolved), + "reading EC state should reuse the provider resolved at startup rather \ + than building a second one for this request" + ); + } + + #[test] + fn read_from_request_round_trips_an_opaque_provider_identifier() { + use crate::platform::test_support::noop_services_with_ec_provider; + + // A vendor identifier that is deliberately not the built-in HMAC shape + // (no dot, mixed case), the exact value the built-in check would drop. + const OPAQUE_ID: &str = "AbC123opaqueEnvelopeValueXYZ"; + const CODED_ID: &str = "t0op~AbC123opaqueEnvelopeValueXYZ"; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("opaque")); + let cookie = format!("ts-ec={CODED_ID}"); + let req = create_test_request(&[("cookie", &cookie)]); + + // With the opaque provider injected, its `accepts_id` governs read-back, + // so the identifier survives verbatim. + let services = noop_services_with_ec_provider(Arc::new(OpaqueIdProvider)); + let ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + Some(CODED_ID), + "an opaque provider identifier should round-trip through read-back verbatim" + ); + let _ = OPAQUE_ID; + + // Control: with the provider selected but not injected by the adapter, + // the request fails loudly instead of silently running stateless with + // the identifier dropped. + let err = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect_err("a selected but uninjected provider should fail the request"); + assert!( + err.to_string().contains("opaque"), + "the error should name the selected provider, got: {err}" + ); + + // Control: with no provider selected at all, the identifier is treated + // as absent, so a stateless deployment never uses or egresses it. + let mut stateless = create_test_settings(); + stateless.ec.provider = None; + stateless.ec.providers.hmac = None; + let ec_without = EcContext::read_from_request(&stateless, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec_without.ec_value(), + None, + "with no provider selected, an existing identifier is treated as absent" + ); + assert!( + !ec_without.ec_allowed(), + "with no provider selected, the gate stays closed" + ); + } + + /// A provider that records the request query parameter `id` and the `Cookie` + /// header it is given at generate time, proving request evidence (parameters + /// and cookies) reaches a provider through the organic generate path. + #[derive(Debug, Default)] + struct EvidenceCapturingProvider { + seen: std::sync::Mutex>, + } + + impl EdgeCookieProvider for EvidenceCapturingProvider { + fn id(&self) -> &'static str { + "evidence" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0ev") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let query_id = request_info.query_param("id").unwrap_or_default(); + let cookie = request_info.header("cookie").unwrap_or_default().to_owned(); + *self.seen.lock().expect("should lock seen evidence") = Some((query_id, cookie)); + Ok(GeneratedEdgeCookie { + id: Some("evidence-ec".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[test] + fn generate_passes_request_parameters_and_cookies_to_the_provider() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let provider = Arc::new(EvidenceCapturingProvider::default()); + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("evidence")); + + // A request carrying a query parameter and a (non-EC) cookie, with no + // existing `ts-ec` cookie so the generate path runs. + let req = Request::builder() + .method("GET") + .uri("http://example.com/page?id=abc123&debug=1") + .header("cookie", "client-id=xyz789") + .body(EdgeBody::empty()) + .expect("should build request"); + + let services = noop_services_with_ec_provider(provider.clone()); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, None) + .expect("should run generation"); + + let seen = provider + .seen + .lock() + .expect("should lock seen evidence") + .clone(); + assert_eq!( + seen, + Some(("abc123".to_owned(), "client-id=xyz789".to_owned())), + "the provider should read the request query parameter and cookies at generate time" + ); + assert_eq!( + ec.ec_value(), + Some("t0ev~evidence-ec"), + "the identifier the provider created should be committed under its code" + ); + } + + /// A provider that creates an opaque, mixed-case, non-HMAC identifier at + /// the edge, so a test can prove such an identifier persists to the KV identity + /// graph under its own value as the key. + #[derive(Debug)] + struct ServerOpaqueProvider; + + impl EdgeCookieProvider for ServerOpaqueProvider { + fn id(&self) -> &'static str { + "server-opaque" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0so") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("Opaque_EC_Value_MixedCase_123".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + #[test] + fn generate_persists_an_opaque_identifier_to_kv_under_its_own_key() { + use crate::platform::test_support::noop_services_with_ec_provider; + + const OPAQUE: &str = "t0so~Opaque_EC_Value_MixedCase_123"; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("server-opaque")); + let services = noop_services_with_ec_provider(Arc::new(ServerOpaqueProvider)); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + + // No existing cookie, so the edge creates one and persists it. + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, Some(&graph)) + .expect("should generate and persist"); + + assert_eq!( + ec.ec_value(), + Some(OPAQUE), + "the opaque identifier should be created" + ); + + // The entry is stored under the full identifier verbatim. + assert!( + graph.get(OPAQUE).expect("kv get should succeed").is_some(), + "the entry should exist under the opaque identifier key" + ); + + // A lowercased key must miss, proving the key preserves case rather than + // being lowercased like the built-in HMAC form (the clash this guards). + assert!( + graph + .get(&OPAQUE.to_lowercase()) + .expect("kv get should succeed") + .is_none(), + "the KV key must be case-sensitive and verbatim, not lowercased" + ); + } + + /// A provider that needs no client IP, recording the value it was given so + /// a test can prove a host that cannot determine one passes the documented + /// unavailable value rather than failing the request. + #[derive(Debug, Default)] + struct NoClientIpProvider { + seen_client_ip: std::sync::Mutex>, + } + + impl EdgeCookieProvider for NoClientIpProvider { + fn id(&self) -> &'static str { + "no-client-ip" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0ni") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + *self + .seen_client_ip + .lock() + .expect("should lock the seen client IP") = + Some(request_info.client_ip().to_owned()); + Ok(GeneratedEdgeCookie { + id: Some("no-ip-ec".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[test] + fn a_provider_that_reads_no_client_ip_mints_when_the_host_has_none() { + use crate::platform::test_support::noop_services_with_ec_provider_without_client_ip; + + // The requirement for a client IP belongs to the provider that uses + // one, not to core. A provider deriving identity from the request + // query and cookies runs on a host that cannot determine a client IP, + // and receives the documented unavailable value, the empty string. + let provider = Arc::new(EvidenceCapturingProvider::default()); + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("evidence")); + let req = Request::builder() + .method("GET") + .uri("http://example.com/page?id=abc123") + .header("cookie", "client-id=xyz789") + .body(EdgeBody::empty()) + .expect("should build request"); + + let services = noop_services_with_ec_provider_without_client_ip(provider.clone()); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + assert_eq!( + ec.client_ip(), + None, + "the host should supply no client IP in this test" + ); + + ec.generate_if_needed(&settings, None) + .expect("a provider that reads no client IP should still create an identifier"); + assert_eq!( + ec.ec_value(), + Some("t0ev~evidence-ec"), + "the identifier should be committed with no client IP available" + ); + } + + #[test] + fn the_hmac_provider_refuses_when_the_host_has_no_client_ip() { + // The other half: the built-in provider's only input is the client IP, + // so with none it fails rather than hashing the empty string into an + // identifier every visitor on that host would share. Identity cannot be + // established, so generate_if_needed returns the error, which the + // publisher and integration proxies log before serving the response + // without an Edge Cookie. + let settings = create_test_settings(); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = + EcContext::read_from_request_with_geo(&settings, &req, &noop_services(), Some(&geo)) + .expect("should read EC context"); + assert_eq!( + ec.client_ip(), + None, + "the host should supply no client IP in this test" + ); + + let err = ec + .generate_if_needed(&settings, None) + .expect_err("the HMAC provider should refuse without a client IP"); + assert!( + err.to_string().contains("client IP"), + "the error should name the missing client IP, got: {err}" + ); + assert_eq!( + ec.ec_value(), + None, + "no identifier should be committed when the provider refuses" + ); + } + + /// A provider that creates an identifier outside the cookie-safe alphabet, + /// to prove core rejects it at creation rather than rewriting it. + #[derive(Debug)] + struct IllegalIdProvider; + + impl EdgeCookieProvider for IllegalIdProvider { + fn id(&self) -> &'static str { + "illegal" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0il") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("bad;value with spaces".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + } + + #[test] + fn generate_rejects_an_identifier_outside_the_cookie_safe_alphabet() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("illegal")); + let services = noop_services_with_ec_provider(Arc::new(IllegalIdProvider)); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + + let err = ec + .generate_if_needed(&settings, None) + .expect_err("an identifier outside the alphabet should be rejected at creation"); + assert!( + err.to_string().contains("illegal"), + "the error should name the provider, got: {err}" + ); + assert_eq!( + ec.ec_value(), + None, + "no identifier should be committed after a creation rejection" + ); + } + + /// A provider that returns a caller-chosen response header and no + /// identifier, so a test can drive one provider response effect at a time + /// through the organic generate path. + #[derive(Debug)] + struct HeaderSettingProvider { + name: &'static str, + value: &'static str, + mint: bool, + } + + impl EdgeCookieProvider for HeaderSettingProvider { + fn id(&self) -> &'static str { + "header-setting" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0hs") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: self.mint.then(|| "provider-value".to_owned()), + response_headers: vec![( + http::HeaderName::from_bytes(self.name.as_bytes()) + .expect("should parse header name"), + http::HeaderValue::from_static(self.value), + )], + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + fn generate_with_header_setting_provider( + provider: HeaderSettingProvider, + graph: Option<&KvIdentityGraph>, + ) -> (Settings, Result>) { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("header-setting")); + let services = noop_services_with_ec_provider(Arc::new(provider)); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + let outcome = ec.generate_if_needed(&settings, graph).map(|()| ec); + (settings, outcome) + } + + #[test] + fn generate_rejects_a_provider_effect_inside_the_reserved_response_surface() { + // A provider that sets the managed `ts-ec` cookie would bypass core's + // identifier validation and its identity-graph row entirely, so the + // request fails rather than the effect being quietly dropped. The + // provider creates no identifier here, which is exactly the case the + // cookie write would otherwise slip through. + let (_settings, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name: "set-cookie", + value: "ts-ec=forged-value; Path=/", + mint: false, + }, + None, + ); + + let err = outcome.expect_err("a managed cookie effect should fail the request"); + assert!( + err.to_string().contains("header-setting"), + "the error should name the provider, got: {err}" + ); + + // The same for the reserved header namespace and for message framing. + for (name, value) in [("x-ts-ec", "forged"), ("transfer-encoding", "chunked")] { + let (_settings, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name, + value, + mint: false, + }, + None, + ); + assert!( + outcome.is_err(), + "`{name}` is reserved and should fail the request" + ); + } + } + + #[test] + fn generate_applies_a_provider_owned_cookie_to_the_response() { + // The other half of the rule: a provider's own cookie is not core's, so + // it survives generation and reaches the browser response unchanged, + // alongside the managed `ts-ec` cookie core writes itself. + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let (settings, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name: "set-cookie", + value: "acme-evidence=abc123; Path=/; Secure", + mint: true, + }, + Some(&graph), + ); + let ec = outcome.expect("a provider-owned cookie should not fail the request"); + assert_eq!( + ec.ec_value(), + Some("t0hs~provider-value"), + "the identifier should still be committed" + ); + + let mut response = http::Response::builder() + .status(200) + .body(EdgeBody::empty()) + .expect("should build test response"); + finalize::ec_finalize_response( + &settings, + &ec, + Some(&graph), + ®istry::PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("acme-evidence=abc123")), + "the provider's own cookie should reach the response, got: {cookies:?}" + ); + assert!( + cookies.iter().any(|cookie| cookie.starts_with("ts-ec=")), + "core's own managed cookie should still be written, got: {cookies:?}" + ); + } + + /// A provider whose identifier normalizes to a distinct canonical form, to + /// prove the identity graph is keyed by the canonical form. + /// + /// Shared with the identify and finalization tests, which need a provider + /// whose canonical key is not the value the browser carries. + #[derive(Debug)] + pub(crate) struct CanonicalizingProvider; + + impl EdgeCookieProvider for CanonicalizingProvider { + fn id(&self) -> &'static str { + "canonical" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0ca") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("MiXeD.CaseId".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_ascii_lowercase() + } + } + + #[test] + fn generate_keys_the_identity_graph_by_the_normalized_identifier() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("canonical")); + let services = noop_services_with_ec_provider(Arc::new(CanonicalizingProvider)); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, Some(&graph)) + .expect("should generate and persist"); + + assert_eq!( + ec.ec_value(), + Some("t0ca~MiXeD.CaseId"), + "the cookie value keeps the provider's exact identifier under its code" + ); + assert!( + graph + .get("t0ca~mixed.caseid") + .expect("should read the graph") + .is_some(), + "the graph row should be keyed by the code plus the canonical form" + ); + // Pin the read-side derivation to the key generation actually wrote. + // Identify, the withdrawal tombstones, and EID ingestion all read the + // row through `ec_kv_key`, so the two must never drift apart. + assert_eq!( + ec.ec_kv_key().as_deref(), + Some("t0ca~mixed.caseid"), + "the read-side key should be the key generation wrote" + ); + } + + #[test] + fn hmac_mints_a_coded_identifier_and_dual_reads_the_legacy_bare_form() { + let settings = create_test_settings(); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let services = crate::platform::test_support::noop_services_with_client_ip( + std::net::IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7)), + ); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, None) + .expect("should generate"); + let created = ec.ec_value().expect("should create an identifier"); + assert!( + created.starts_with("hmac~"), + "a fresh HMAC identifier should carry the hmac code, got {created}" + ); + + // A deployed pre-envelope cookie (bare form) still reads back, so the + // migration does not orphan existing identities. + let legacy = format!("{}.ABC123", "a".repeat(64)); + let cookie = format!("ts-ec={legacy}"); + let req = create_test_request(&[("cookie", &cookie)]); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + Some(legacy.as_str()), + "the legacy bare form should dual-read under the hmac provider" + ); + } + + #[test] + fn a_foreign_provider_code_is_treated_as_absent() { + // An identifier carrying another provider's code must never be adopted + // by the selected provider, so switching providers cannot silently mix + // identity populations. + let settings = create_test_settings(); + let foreign = format!("zz00~{}.ABC123", "a".repeat(64)); + let cookie = format!("ts-ec={foreign}"); + let req = create_test_request(&[("cookie", &cookie)]); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + None, + "an identifier with a foreign provider code is not this provider's" + ); + } + #[test] fn read_from_request_ignores_header_ec() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs new file mode 100644 index 000000000..cac0378e4 --- /dev/null +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -0,0 +1,1587 @@ +//! Edge Cookie identity providers. +//! +//! An [`EdgeCookieProvider`] derives an Edge Cookie identifier. The provider is +//! 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. +//! +//! Request evidence reaches a provider at call time rather than at +//! construction. [`EdgeCookieProvider::generate`] borrows a [`RequestInfo`], +//! which carries the normalized client IP, the User-Agent and the request +//! headers, for the life of the call, alongside an [`IdentityInput`] holding +//! the request's gating context. A provider reads what it needs and retains +//! nothing. Core snapshots the headers, path and query it lends to the provider +//! at generate time, and the provider itself keeps none of it. +//! +//! [`HmacProvider`] is the built-in server-side implementation. It derives the +//! identifier from the client IP using HMAC over the configured passphrase, the +//! behavior Trusted Server has always shipped. + +use std::sync::Arc; + +use error_stack::Report; +use serde::{Deserialize, Serialize}; + +use crate::consent::ConsentContext; +use crate::error::TrustedServerError; +use crate::evidence::RequestInfo; +use crate::redacted::Redacted; +use crate::settings::Ec; + +use super::cookies::ec_id_has_only_allowed_chars; +use super::generation; + +/// The Edge Cookie identity provider a deployment has selected. +/// +/// Deserialized from the `[ec] provider` string, and serialized back to the +/// same string, so the configuration surface is unchanged. Provider names are +/// open-ended (a vendor crate names its own), so every name other than the +/// explicit `"none"` becomes [`Named`](Self::Named) rather than a parse +/// failure, and whether the deployment can actually supply that provider is +/// decided by [`build_provider`]. +/// +/// No individual provider has a variant of its own, the one still built into +/// core included. Every provider is selected the same way, by name, so no +/// caller can be written around one provider being different, and moving the +/// built-in provider out into its own module changes nothing here. +/// +/// This is the one place the selector is spelled. Everything that needs to ask +/// which provider is selected matches on this rather than comparing string +/// literals. +#[derive(Debug, Clone, Eq, Hash, PartialEq, Deserialize, Serialize)] +#[serde(from = "String", into = "String")] +pub enum EcProviderSelection { + /// Explicit statelessness, spelled `"none"`. The same meaning as omitting + /// the selector: no Edge Cookie is created and no provider block may be + /// configured. + None, + + /// A provider selected by name, configured by the matching + /// `[ec.providers.]` block. [`build_provider`] resolves the name to + /// an implementation, whether that implementation is built into core or + /// injected by the adapter. + Named(String), +} + +impl EcProviderSelection { + /// The configuration spelling of explicit statelessness. + pub const NONE_KEY: &'static str = "none"; + + /// The configuration key this selection is written as. + #[must_use] + pub fn key(&self) -> &str { + match self { + Self::None => Self::NONE_KEY, + Self::Named(key) => key, + } + } +} + +impl From<&str> for EcProviderSelection { + fn from(key: &str) -> Self { + match key { + EcProviderSelection::NONE_KEY => Self::None, + other => Self::Named(other.to_owned()), + } + } +} + +impl From for EcProviderSelection { + fn from(key: String) -> Self { + match key.as_str() { + EcProviderSelection::NONE_KEY => Self::None, + _ => Self::Named(key), + } + } +} + +impl From for String { + fn from(selection: EcProviderSelection) -> Self { + match selection { + EcProviderSelection::None => EcProviderSelection::NONE_KEY.to_owned(), + EcProviderSelection::Named(key) => key, + } + } +} + +/// The configuration name of the 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 +/// [`build_provider`]. It is also [`HmacProvider::id`]'s return value and +/// [`HMAC_PROVIDER_CODE`]'s text. It goes with that resolution arm when the +/// built-in provider becomes a module of its own. +pub const HMAC_PROVIDER_KEY: &str = "hmac"; + +/// 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]; + +/// The registry code of the built-in HMAC provider. +/// +/// The same text as [`HMAC_PROVIDER_KEY`], but a different role: this is the +/// `{code}~` namespace stamped on every identifier the built-in provider +/// creates, and it is what [`generation`] matches when it decides whether an +/// enveloped identifier is one of its own. +pub const HMAC_PROVIDER_CODE: ProviderCode = crate::provider_code!(HMAC_PROVIDER_KEY); + +/// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. +/// +/// Request data reaches a provider through the `request_info` parameter of +/// [`EdgeCookieProvider::generate`], not through this struct and not through +/// anything injected into the provider's constructor. This struct carries only +/// the per-request gating context a provider may read for behavior beyond +/// gating. On the organic request path the gate has confirmed Edge Cookie +/// storage is allowed before `generate` is called. A direct +/// `edge_cookie::generate_ec_id` call, test-only today, reaches `generate` +/// without that gate. +#[derive(Default)] +pub struct IdentityInput<'a> { + /// The request's consent context, when available, for provider-specific + /// logic. The core gates generation before calling the provider, so a + /// provider reads this only to forward or record consent. [`HmacProvider`] + /// ignores it. + pub consent: Option<&'a ConsentContext>, +} + +/// The outcome of [`EdgeCookieProvider::generate`]. +/// +/// Carries the derived identifier, if any, and any response headers the provider +/// needs set on the outbound response. +#[derive(Debug, Default)] +pub struct GeneratedEdgeCookie { + /// The derived Edge Cookie identifier, or `None` when the provider produced + /// none for this request. + pub id: Option, + + /// Response headers the provider needs set on the outbound response, for + /// example to request additional client evidence on later requests. Empty + /// for providers that set no headers, such as [`HmacProvider`]. + /// + /// Core checks every header here against its own reserved response surface + /// (see [`reserved_response_effect`]) before it is applied, so a provider + /// may set its own cookies and headers but cannot reach into the surface + /// core manages. + pub response_headers: Vec<(http::HeaderName, http::HeaderValue)>, +} + +/// The cookie-name namespace Trusted Server manages. +/// +/// Every cookie core writes or reads as part of its own behavior is named +/// `ts-` (`ts-ec` in [`COOKIE_TS_EC`](crate::constants::COOKIE_TS_EC), +/// `ts-eids` in [`COOKIE_TS_EIDS`](crate::constants::COOKIE_TS_EIDS), and +/// `ts-tester` in [`COOKIE_TS_TESTER`](crate::constants::COOKIE_TS_TESTER)), so +/// core defends the whole prefix rather than a list that a new managed cookie +/// would silently outgrow. `sharedId` is deliberately not reserved: core only +/// reads it, and it belongs to the page's own identity stack. +const MANAGED_COOKIE_NAME_PREFIX: &[u8] = b"ts-"; + +/// The response-header namespace Trusted Server reserves for itself. +/// +/// Covers the fixed EC output headers and the per-partner +/// `x-ts-` headers, which is why the prefix is reserved rather +/// than the four names in +/// [`INTERNAL_HEADERS`](crate::constants::INTERNAL_HEADERS). +const RESERVED_RESPONSE_HEADER_PREFIX: &str = "x-ts-"; + +/// Response headers that frame an HTTP message, are hop-by-hop, or govern +/// caching. +/// +/// The hop-by-hop set is RFC 7230 §6.1, plus `content-length`, which frames the +/// body the adapter is about to write, and `cache-control`, which governs +/// whether the response may be cached. A provider that set any of these would +/// be rewriting the response envelope rather than adding evidence to it, and a +/// provider setting `cache-control` could make an identity-bearing response +/// publicly cacheable, so it is reserved with the rest. +const FRAMING_OR_HOP_BY_HOP_HEADERS: &[&str] = &[ + "cache-control", + "connection", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Why one provider response header falls inside core's reserved surface. +#[derive(Debug, Copy, Clone, Eq, PartialEq, derive_more::Display)] +pub enum ReservedResponseEffect { + /// A `Set-Cookie` naming a cookie in the `ts-` namespace core manages. + #[display("sets a cookie in the `ts-` namespace Trusted Server manages")] + ManagedCookie, + + /// A header in the `x-ts-` namespace core emits and strips. + #[display("sets a header in the reserved `x-ts-` namespace")] + ReservedHeader, + + /// A framing, hop-by-hop, or caching header core manages. + #[display("sets a framing, hop-by-hop, or caching header core manages")] + FramingHeader, +} + +/// The cookie name in a `Set-Cookie` value, as raw bytes. +/// +/// Reads the bytes rather than a `&str` so a value that is not valid UTF-8 +/// cannot smuggle a managed cookie name past the check. +fn set_cookie_name(value: &[u8]) -> &[u8] { + let pair_end = value.iter().position(|b| *b == b';').unwrap_or(value.len()); + let pair = &value[..pair_end]; + let name_end = pair.iter().position(|b| *b == b'=').unwrap_or(pair.len()); + pair[..name_end].trim_ascii() +} + +/// Classifies one provider response header against core's reserved surface. +/// +/// Returns `Some` when the header would reach into what core manages, and +/// `None` for everything else, including a provider's own cookie. Providers +/// legitimately need to set cookies of their own (an evidence cookie for a +/// later request, for example), so the rule reserves core's namespace rather +/// than banning `Set-Cookie` outright. +/// +/// A rejected effect fails the request rather than being dropped, because a +/// provider reaching into the reserved surface has broken its contract in the +/// same way as one creating an identifier outside the cookie-safe alphabet, and +/// that already fails the request. Serving the response instead would let a +/// provider set `ts-ec` directly, bypassing core's identifier validation and +/// its requirement that a created identifier have an identity-graph row. +#[must_use] +pub fn reserved_response_effect( + name: &http::HeaderName, + value: &http::HeaderValue, +) -> Option { + let lower = name.as_str(); + if lower == http::header::SET_COOKIE.as_str() { + let cookie_name = set_cookie_name(value.as_bytes()); + if cookie_name.len() >= MANAGED_COOKIE_NAME_PREFIX.len() + && cookie_name[..MANAGED_COOKIE_NAME_PREFIX.len()] + .eq_ignore_ascii_case(MANAGED_COOKIE_NAME_PREFIX) + { + return Some(ReservedResponseEffect::ManagedCookie); + } + return None; + } + if lower.starts_with(RESERVED_RESPONSE_HEADER_PREFIX) { + return Some(ReservedResponseEffect::ReservedHeader); + } + if FRAMING_OR_HOP_BY_HOP_HEADERS.contains(&lower) { + return Some(ReservedResponseEffect::FramingHeader); + } + None +} + +/// Applies a provider's response headers to a response that already carries +/// the publisher origin's own. +/// +/// Every header here accumulates with what the origin returned rather than +/// replacing it, because a provider on this seam only ever adds evidence about +/// the request. It is never correcting the origin's output, so core has no +/// grounds to discard a value it did not write. Working through the headers a +/// provider can actually set: +/// +/// - `Set-Cookie` can never be folded into one field line, so replacing it +/// drops every cookie the origin set, a publisher's session and sign-in +/// cookies included. This is the case the whole rule turns on, because +/// `response_headers` is a list of pairs precisely so a provider can set more +/// than one cookie of its own, and replacing collapses those too. +/// - The list-valued headers a provider realistically sets, `Vary` first among +/// them, mean the union of their field lines. Replacing the origin's +/// `Vary: Accept-Encoding` with the provider's own would break the cache +/// correctness the origin asked for. +/// - The single-valued headers where replacing would be the right answer are +/// exactly the ones a provider must not author at all, and +/// [`reserved_response_effect`] already fails the request for them: core's +/// `x-ts-` namespace, the `ts-` managed cookies, and the framing and +/// hop-by-hop set. +/// +/// So nothing a provider is permitted to set here needs to replace, and +/// accumulating is the direction that cannot silently destroy someone else's +/// header. Appending where one value was wanted leaves a duplicate a reviewer +/// can see; replacing where two were wanted leaves nothing at all. +pub(crate) fn apply_provider_response_headers(headers: &mut http::HeaderMap, provider_headers: I) +where + I: IntoIterator, +{ + for (name, value) in provider_headers { + headers.append(name, value); + } +} + +/// The registered short code that namespaces one Edge Cookie provider's +/// identifiers. +/// +/// Exactly four characters from `[a-z0-9]`, allocated append-only in the +/// provider-code registry and never reused. The code appears as the +/// `{code}~` prefix of every identifier the provider creates, so identifiers +/// from different providers can never collide in the cookie, the identity +/// graph, or a withdrawal, and each identifier records which provider +/// created it. +#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, derive_more::Display)] +pub struct ProviderCode(&'static str); + +impl ProviderCode { + /// Creates a provider code when `code` matches the registry format. + /// + /// Returns `None` when `code` is not exactly four characters of `[a-z0-9]`, + /// so a caller that assembles a code from anything other than a literal is + /// handed an answer it has to deal with rather than a panic. Nothing in + /// this function can panic, whatever it is called with and wherever it is + /// called from. + /// + /// Use [`provider_code!`](crate::provider_code) for a literal. That macro + /// runs this check while the crate is compiled, so a malformed code is a + /// build failure and the resulting value needs no unwrapping. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::ec::provider::ProviderCode; + /// + /// assert_eq!(ProviderCode::new("t0ac").map(ProviderCode::as_str), Some("t0ac")); + /// assert_eq!(ProviderCode::new("nope!"), None); + /// ``` + #[must_use] + pub const fn new(code: &'static str) -> Option { + let bytes = code.as_bytes(); + if bytes.len() != 4 { + return None; + } + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if !b.is_ascii_lowercase() && !b.is_ascii_digit() { + return None; + } + i += 1; + } + Some(Self(code)) + } + + /// The code as a string slice. + #[must_use] + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +/// Builds a [`ProviderCode`] from a constant, checked while the crate is +/// compiled. +/// +/// The check runs inside a `const` block, so a code that is not exactly four +/// characters of `[a-z0-9]` fails the build instead of panicking at run time, +/// and the value the macro produces needs no unwrapping. Every provider code in +/// this workspace is written through this macro, which is what makes +/// [`ProviderCode::new`]'s fallible form safe to hand to anyone else. +/// +/// # Examples +/// +/// ``` +/// use trusted_server_core::provider_code; +/// +/// assert_eq!(provider_code!("t0ac").as_str(), "t0ac"); +/// ``` +#[macro_export] +macro_rules! provider_code { + ($code:expr) => { + const { + match $crate::ec::provider::ProviderCode::new($code) { + Some(code) => code, + None => panic!("provider code must be exactly four characters of [a-z0-9]"), + } + } + }; +} + +/// The separator between a provider code and the provider's identifier value. +/// +/// The tilde is inside the cookie-safe identifier alphabet and outside the +/// built-in HMAC identifier's own characters, so a legacy bare identifier can +/// never be misread as a coded one. +pub const PROVIDER_CODE_SEPARATOR: char = '~'; + +/// Splits a full identifier into its provider-code prefix and value. +/// +/// Returns `(Some(code), value)` when the identifier starts with a well-formed +/// `{code}~` prefix, and `(None, full)` for a legacy bare identifier. The code +/// here is the raw string, not a validated [`ProviderCode`]: an unknown code +/// simply fails the ownership check against the selected provider. +#[must_use] +pub fn split_provider_code(full: &str) -> (Option<&str>, &str) { + if let Some((code, value)) = full.split_once(PROVIDER_CODE_SEPARATOR) + && code.len() == 4 + && code + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + { + return (Some(code), value); + } + (None, full) +} + +/// Whether the selected provider owns `full` as one of its identifiers. +/// +/// A coded identifier belongs to the provider whose registered code it +/// carries, with the value part accepted by that provider's +/// [`accepts_id`](EdgeCookieProvider::accepts_id). A legacy bare identifier +/// (no code prefix) belongs only to the built-in HMAC provider, which +/// dual-reads its pre-envelope form so deployed cookies keep working across +/// the migration. +/// +/// # Retiring the legacy bare reader +/// +/// The reader stays until a bare identifier can no longer arrive. A returning +/// visitor's bare cookie is never rewritten into the coded form, and its +/// `COOKIE_MAX_AGE` lifetime in [`cookies`](super::cookies) (one year, not +/// operator-configurable) runs from the moment it was written. The +/// identity-graph row is not fixed the same way: an ordinary page view that +/// ingests `ts-eids` or `sharedId` cookies runs `ingest_eid_cookies` in +/// `ec_finalize_response` (see [`finalize`](super::finalize)), which rewrites +/// the bare-keyed row with a fresh `ENTRY_TTL` in [`kv`](super::kv) (also one +/// year), so the row's clock restarts on each such view. The earliest safe +/// retirement is therefore one year after the last write that could still +/// leave a bare-keyed row, which is the later of the last release that could +/// still create a bare identifier stopping everywhere and the last page view +/// that refreshed such a row, plus however long a deployment's own rollout +/// takes to reach every point of presence. +/// +/// The other half of that condition, evidence that bare identifiers really +/// have stopped arriving, cannot be checked today. Nothing counts or logs a +/// bare-form read-back, so there is no observed legacy-reader traffic to look +/// at, and the elapsed time alone cannot tell anyone whether a deployment +/// somewhere is still serving them. Scheduling the removal needs that signal +/// to exist first. Until it does the reader stays, and keeping it costs one +/// string comparison per read-back. +#[must_use] +pub fn provider_owns_id(provider: &dyn EdgeCookieProvider, full: &str) -> bool { + match split_provider_code(full) { + (Some(code), value) => code == provider.code().as_str() && provider.accepts_id(value), + (None, value) => provider.id() == HMAC_PROVIDER_KEY && provider.accepts_id(value), + } +} + +/// The full created identifier for `value` under `provider`'s code. +#[must_use] +pub fn apply_provider_code(provider: &dyn EdgeCookieProvider, value: &str) -> String { + format!("{}{PROVIDER_CODE_SEPARATOR}{value}", provider.code()) +} + +/// The KV-key form of a full identifier under `provider`. +/// +/// The code prefix is preserved verbatim and the provider normalizes only its +/// own value part, so distinct providers' rows can never share a key and a +/// provider never sees another provider's syntax. +#[must_use] +pub fn provider_kv_key(provider: &dyn EdgeCookieProvider, full: &str) -> String { + match split_provider_code(full) { + (Some(code), value) => format!( + "{code}{PROVIDER_CODE_SEPARATOR}{}", + provider.normalize_id_for_kv(value) + ), + (None, value) => provider.normalize_id_for_kv(value), + } +} + +/// The providers whose identifiers a partner or diagnostic path accepts. +/// +/// Pull sync, batch sync, and the admin lookup each take an identifier from +/// outside the organic request path and have to decide whether Trusted Server +/// issued it. The answer is in two parts. The **global cookie bounds** (the +/// length cap and the cookie-safe alphabet, see `ec_id_has_only_allowed_chars`) +/// apply to every identifier whichever provider created it. The rest is +/// **dispatched by the `{code}~` prefix** to the provider that owns that code, +/// which canonicalizes its own value part and decides whether the canonical +/// form is one of its own. A code no provider in the set owns is rejected, so a +/// second provider's identifiers can never be adopted or written under this +/// deployment's keys. +/// +/// Batch sync keys its rows through [`canonical_kv_key`](Self::canonical_kv_key) +/// here. Pull sync and the admin lookup still read and write rows by the raw +/// active identifier rather than the canonical form, so for a provider whose +/// canonical form differs from the cookie value they can key the wrong row. +/// That gap is recorded on `EcContext::kv_key_for` and tracked as a known +/// issue for a later change. +/// +/// The set holds the deployment's active provider. The design's +/// `legacy_providers` reader list, the providers that never create but must still +/// recognize identifiers a previous provider issued, is not implemented on this +/// branch, so [`active`](Self::active) fills `readers` with the one active +/// provider. That is the seam: when the configured legacy readers land they are +/// built alongside the active provider and pushed into the same list, and +/// neither [`accepts`](Self::accepts) nor +/// [`canonical_kv_key`](Self::canonical_kv_key) changes. +pub struct AcceptedProviders<'a> { + readers: Vec<&'a dyn EdgeCookieProvider>, +} + +impl<'a> AcceptedProviders<'a> { + /// The set holding only the deployment's active provider. + /// + /// `None` means no provider is selected, so the deployment is stateless. + #[must_use] + pub fn active(provider: Option<&'a dyn EdgeCookieProvider>) -> Self { + Self { + readers: provider.into_iter().collect(), + } + } + + /// The provider in the set that owns `full`'s code. + /// + /// Dispatch is on the code alone, before any provider looks at a value, so + /// an identifier a partner echoed back in a different case still reaches + /// its own provider to be canonicalized rather than being rejected first. + /// A legacy bare identifier predates the envelope and belongs to the + /// built-in HMAC provider alone. + fn owner(&self, full: &str) -> Option<&'a dyn EdgeCookieProvider> { + let (code, _) = split_provider_code(full); + self.readers.iter().copied().find(|provider| match code { + Some(code) => provider.code().as_str() == code, + None => provider.id() == HMAC_PROVIDER_KEY, + }) + } + + /// Whether `full` is an identifier this deployment accepts. + #[must_use] + pub fn accepts(&self, full: &str) -> bool { + self.canonical_kv_key(full).is_some() + } + + /// The identity-graph key for `full`, or `None` when nothing in the set + /// accepts it. + /// + /// The owning provider supplies the canonical form of its own value part + /// and the code prefix is preserved verbatim, so two providers' rows can + /// never share a key. + #[must_use] + pub fn canonical_kv_key(&self, full: &str) -> Option { + if !ec_id_has_only_allowed_chars(full) { + return None; + } + match self.owner(full) { + Some(owner) => { + let key = provider_kv_key(owner, full); + provider_owns_id(owner, &key).then_some(key) + } + // No provider is selected, so there is no code to dispatch on and + // the built-in HMAC grammar is the fallback, the same fallback + // `EcContext::accepts_id` has always used for a stateless + // deployment. + None if self.readers.is_empty() => { + let key = generation::normalize_ec_id_for_kv(full); + generation::is_valid_ec_id(&key).then_some(key) + } + // A code that belongs to some other deployment's provider. + None => None, + } + } +} + +/// A strategy for deriving an Edge Cookie identifier. +/// +/// Implementations are selected by configuration. A provider derives the +/// identifier at the edge in [`generate`](Self::generate), and the page +/// response sets the `ts-ec` cookie. +/// +/// A provider that cannot derive an identifier at the edge returns a +/// [`GeneratedEdgeCookie`] whose [`id`](GeneratedEdgeCookie::id) is `None`, so +/// the request proceeds without an Edge Cookie rather than failing. +pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { + /// Returns the stable identifier for this provider, used in configuration + /// and logs. + fn id(&self) -> &'static str; + + /// The provider's registered code, the `{code}~` namespace of every + /// identifier it creates. + /// + /// Mandatory, with no default: a provider must allocate a unique code in + /// the provider-code registry before it can exist, so no two providers + /// can ever create colliding identifiers. Core applies the code at + /// creation and checks it at read-back, and the provider itself only ever + /// sees its own value part. + fn code(&self) -> ProviderCode; + + /// Derives an Edge Cookie identifier from the request evidence in + /// `request_info` and the gating context in `input`. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when derivation fails. + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result>; + + /// Returns whether `value` is a well-formed identifier this provider issues. + /// + /// Core calls this to decide whether an incoming `ts-ec` cookie value is a + /// usable Edge Cookie identifier before reading it back, keying the KV + /// identity graph, or withdrawing it. Core strips the provider's `{code}~` + /// prefix first, so this receives only the provider's own value part. + /// This keeps the identifier opaque to + /// core: a provider whose identifiers are not the built-in shape (for + /// example an opaque signed envelope) accepts its own format here, so its + /// identifier round-trips instead of being silently dropped on read-back. + /// + /// The default accepts the built-in HMAC identifier shape + /// (`<64 hex>.<6 alphanumeric>`), which is correct for [`HmacProvider`], the + /// one provider core builds in. + fn accepts_id(&self, value: &str) -> bool { + generation::is_valid_ec_id(value) + } + + /// Returns the KV-key form of `value` for this provider's identifiers. + /// + /// Core keys the identity graph by the returned string, so a provider whose + /// identifiers are case-sensitive or carry no separable segments returns the + /// value unchanged to avoid collapsing distinct identifiers into one key. + /// + /// The default lowercases the leading HMAC hash segment and preserves the + /// suffix, matching the built-in identifier shape. + fn normalize_id_for_kv(&self, value: &str) -> String { + generation::normalize_ec_id_for_kv(value) + } +} + +/// The built-in HMAC Edge Cookie provider. +/// +/// Derives the identifier from the client IP (read from the [`RequestInfo`] +/// passed at call time) and the configured passphrase via +/// [`generation::generate_ec_id`]. +/// +/// The client IP is this provider's only input, so it is this provider that +/// requires one. On a host that cannot supply one, [`RequestInfo::client_ip`] +/// is the empty string and [`generate`](Self::generate) fails rather than +/// hashing the empty string into an identifier every visitor on that host +/// would share. The failure is returned to the caller. The publisher proxy and +/// integration proxy log it and serve the response without an Edge Cookie. A +/// provider that reads other evidence makes its own decision and is unaffected. +#[derive(Debug, Clone)] +pub struct HmacProvider { + passphrase: Redacted, +} + +impl HmacProvider { + /// Creates an HMAC provider with the given passphrase. + #[must_use] + pub fn new(passphrase: Redacted) -> Self { + Self { passphrase } + } +} + +impl EdgeCookieProvider for HmacProvider { + fn id(&self) -> &'static str { + HMAC_PROVIDER_KEY + } + + fn code(&self) -> ProviderCode { + HMAC_PROVIDER_CODE + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let client_ip = request_info.client_ip(); + if client_ip.is_empty() { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: "Edge Cookie provider `hmac` requires the client IP, and this host \ + could not supply one" + .to_owned(), + })); + } + let id = generation::generate_ec_id(self.passphrase.expose(), 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 +/// this work merges IAB Tech Lab is itself a vendor shipping an HMAC provider, +/// so the two really can arrive under the same name in one deployment. The +/// resolution order alone would answer that by quietly preferring the built-in +/// one and dropping the injected provider, which an operator has no way to see, +/// so the pair is refused here and the error names both claimants. +/// +/// The check runs whatever the selector says, so an operator is told at startup +/// rather than on the first request that happens to select the contested name, +/// and it runs before the selection is read so a deployment cannot hide the +/// clash by selecting something else. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the injected provider's id +/// is one of [`BUILTIN_PROVIDER_KEYS`]. +fn ensure_no_name_collision( + injected: Option<&dyn EdgeCookieProvider>, +) -> Result<(), Report> { + let Some(injected) = injected else { + return Ok(()); + }; + let Some(claimed) = BUILTIN_PROVIDER_KEYS + .iter() + .find(|key| **key == injected.id()) + else { + return Ok(()); + }; + Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Edge Cookie provider name `{claimed}` is claimed twice, by the provider \ + built into Trusted Server core and by the provider this deployment's \ + adapter injects. Give the injected provider a name of its own and select \ + it under that name, because `[ec] provider = \"{claimed}\"` cannot mean \ + both of them." + ), + })) +} + +/// 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. +/// +/// # 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. +pub fn build_provider( + ec: &Ec, + injected: Option>, +) -> Result>, Report> { + ensure_no_name_collision(injected.as_deref())?; + let Some(selection) = ec.provider.as_ref() else { + return Ok(None); + }; + let provider: Option> = match selection { + // Explicit statelessness: the same meaning as omitting the selector. + EcProviderSelection::None => None, + // 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)?), + }; + Ok(provider) +} + +/// Resolves one provider name to its implementation. +/// +/// A name is looked for among the providers built into core first, and is +/// otherwise the name of a provider the adapter injects through +/// [`RuntimeServices`](crate::platform::RuntimeServices), the same seam the +/// device and geo providers use, so core never names a vendor. The injected +/// provider is used when its own id matches the name, and its +/// `[ec.providers.]` block is read by the adapter that built it. +/// +/// Looking at core first is safe only because +/// [`ensure_no_name_collision`] has already refused an injected provider that +/// claims a built-in name, so this order can never shadow one silently. +/// +/// # 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. +fn resolve_named_provider( + key: &str, + ec: &Ec, + 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 + // changes. + // + // Settings validation rejects a built-in name with no block before this + // runs, so reaching the error means the two checks have drifted apart. + // Stopping is the only safe answer: returning no provider would run the + // deployment stateless under a selector that says it has an identity + // provider. + if key == HMAC_PROVIDER_KEY { + let config = ec.providers.hmac.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: "Edge Cookie provider `hmac` is selected but has no \ + `[ec.providers.hmac]` configuration" + .to_owned(), + }) + })?; + return Ok(Box::new(HmacProvider::new(config.passphrase.clone()))); + } + + injected + .filter(|provider| provider.id() == key) + .map(|provider| Box::new(SharedProvider(provider)) as Box) + .ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Edge Cookie provider `{key}` is selected but this deployment's \ + adapter does not provide it" + ), + }) + }) +} + +/// Checks once, at startup, that this deployment can build the provider named +/// by the `[ec] provider` selector. +/// +/// The composition root calls this while it builds application state, passing +/// the same injected provider 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. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the selected provider cannot +/// be built from the services this deployment injects. +pub fn ensure_provider_available( + ec: &Ec, + injected: Option>, +) -> Result<(), Report> { + build_shared_provider(ec, injected)?; + Ok(()) +} + +/// Resolves the selected provider into a shared handle the composition root can +/// keep and the request path can reuse. +/// +/// The same resolution as [`build_provider`], returned as an `Arc` rather than +/// a `Box` so one instance can be threaded into +/// [`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. +/// +/// # Errors +/// +/// The same errors as [`build_provider`]. +pub fn build_shared_provider( + ec: &Ec, + injected: Option>, +) -> Result>, Report> { + Ok(build_provider(ec, injected)?.map(Arc::from)) +} + +/// The Edge Cookie provider to use for this request. +/// +/// Resolving `[ec] provider` reads no request data, so an adapter that resolved +/// it once while it built application state and threaded the result into +/// [`RuntimeServices::resolved_ec_provider`](crate::platform::RuntimeServices::resolved_ec_provider) +/// gets that same instance back here, and nothing is resolved or constructed +/// again on the request path. An adapter that threaded nothing resolves here +/// instead, from the same settings and the same injected provider, which is +/// what the core tests and any embedder driving core directly do. +/// +/// # Errors +/// +/// The same errors as [`build_provider`], and only when the adapter threaded +/// nothing, because a threaded provider has already been resolved successfully. +pub fn request_provider( + ec: &Ec, + services: &crate::platform::RuntimeServices, +) -> Result>, Report> { + if let Some(resolved) = services.resolved_ec_provider() { + return Ok(Some(resolved)); + } + build_shared_provider(ec, services.ec_provider()) +} + +/// Adapts an injected, shared [`EdgeCookieProvider`] to the owned `Box` that +/// [`build_provider`] returns. +/// +/// A vendor or host provider is injected as an `Arc` so it can live in +/// [`RuntimeServices`](crate::platform::RuntimeServices) and be cloned per +/// request. Every method delegates to the inner provider, so its behavior is +/// unchanged. +#[derive(Debug)] +struct SharedProvider(Arc); + +impl EdgeCookieProvider for SharedProvider { + fn code(&self) -> ProviderCode { + self.0.code() + } + + fn id(&self) -> &'static str { + self.0.id() + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result> { + self.0.generate(request_info, input) + } + + fn accepts_id(&self, value: &str) -> bool { + self.0.accepts_id(value) + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + self.0.normalize_id_for_kv(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::{EcProviders, HmacProviderConfig}; + + #[test] + fn a_malformed_provider_code_is_refused_rather_than_panicking() { + // `ProviderCode::new` is public, so a vendor crate can reach it with a + // value it assembled rather than a literal. Every rejected shape has to + // come back as `None`, because a panic here would take down whatever + // request the caller was serving. + for malformed in ["", "abc", "abcde", "AB12", "t0a_", "t0a-", "t0a ", "t.ac"] { + assert_eq!( + ProviderCode::new(malformed), + None, + "`{malformed}` is outside the registry format and should be refused" + ); + } + + assert_eq!( + ProviderCode::new("t0ac").map(ProviderCode::as_str), + Some("t0ac"), + "a well-formed code should still be accepted" + ); + } + + #[test] + fn the_provider_code_macro_keeps_the_compile_time_guarantee() { + // The macro checks a literal while the crate is compiled and yields the + // code itself, so the codes written across this workspace stay as + // strong as the old panicking constructor made them, with none of the + // run-time risk. + assert_eq!( + crate::provider_code!("t0ac").as_str(), + "t0ac", + "the macro should yield the code it was given" + ); + assert_eq!( + HMAC_PROVIDER_CODE.as_str(), + HMAC_PROVIDER_KEY, + "the built-in code should still be the built-in key" + ); + } + + #[test] + fn split_provider_code_separates_coded_and_legacy_forms() { + assert_eq!( + split_provider_code("hmac~abc.DEF123"), + (Some("hmac"), "abc.DEF123"), + "a four-character code before the first tilde splits off" + ); + assert_eq!( + split_provider_code("51dd~value~with~tildes"), + (Some("51dd"), "value~with~tildes"), + "only the first tilde splits, so a value may contain tildes" + ); + assert_eq!( + split_provider_code("abcdef.XYZ"), + (None, "abcdef.XYZ"), + "no tilde means the legacy bare form" + ); + assert_eq!( + split_provider_code("toolong~x"), + (None, "toolong~x"), + "a prefix that is not exactly four characters is not a code" + ); + assert_eq!( + split_provider_code("AB12~x"), + (None, "AB12~x"), + "uppercase is outside the code alphabet" + ); + } + + fn header(name: &str, value: &str) -> (http::HeaderName, http::HeaderValue) { + ( + http::HeaderName::from_bytes(name.as_bytes()).expect("should parse header name"), + http::HeaderValue::from_str(value).expect("should parse header value"), + ) + } + + #[test] + fn reserved_response_effect_rejects_the_namespace_core_manages() { + for (name, value, expected) in [ + ( + "set-cookie", + "ts-ec=hmac~deadbeef.abc123; Path=/", + ReservedResponseEffect::ManagedCookie, + ), + ( + "Set-Cookie", + " TS-EIDS=x; Path=/", + ReservedResponseEffect::ManagedCookie, + ), + ("x-ts-ec", "spoofed", ReservedResponseEffect::ReservedHeader), + ( + "X-TS-partner.example.com", + "uid", + ReservedResponseEffect::ReservedHeader, + ), + ("content-length", "0", ReservedResponseEffect::FramingHeader), + ( + "Transfer-Encoding", + "chunked", + ReservedResponseEffect::FramingHeader, + ), + ("connection", "close", ReservedResponseEffect::FramingHeader), + ( + "cache-control", + "public, max-age=31536000", + ReservedResponseEffect::FramingHeader, + ), + ( + "Cache-Control", + "public", + ReservedResponseEffect::FramingHeader, + ), + ] { + let (name, value) = header(name, value); + assert_eq!( + reserved_response_effect(&name, &value), + Some(expected), + "`{name}` should be reserved" + ); + } + } + + #[test] + fn reserved_response_effect_allows_provider_owned_effects() { + for (name, value) in [ + ("set-cookie", "acme-evidence=abc; Path=/; Secure"), + ("set-cookie", "sharedId=abc"), + ("accept-ch", "Sec-CH-UA-Full-Version-List"), + ("x-acme-probe", "1"), + ("vary", "Sec-CH-UA"), + ] { + let (name, value) = header(name, value); + assert_eq!( + reserved_response_effect(&name, &value), + None, + "`{name}` is the provider's own and should be allowed" + ); + } + } + + #[test] + fn reserved_response_effect_reads_a_non_utf8_set_cookie_as_bytes() { + // A `Set-Cookie` carrying a byte above 127 cannot be read as a string, + // so the cookie name is matched on raw bytes. Reading it as UTF-8 and + // giving up on failure would let this value through. + let name = http::header::SET_COOKIE; + let mut bytes = b"ts-ec=value".to_vec(); + bytes.push(0xff); + bytes.extend_from_slice(b"; Path=/"); + let value = + http::HeaderValue::from_bytes(&bytes).expect("should build a non-utf8 header value"); + assert!( + value.to_str().is_err(), + "the test value should not be readable as UTF-8" + ); + assert_eq!( + reserved_response_effect(&name, &value), + Some(ReservedResponseEffect::ManagedCookie), + "a non-UTF-8 Set-Cookie should still be matched on its cookie name" + ); + } + + /// A stand-in for a vendor provider an adapter injects. + #[derive(Debug)] + struct VendorProvider; + + impl EdgeCookieProvider for VendorProvider { + fn id(&self) -> &'static str { + "acme" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0ac") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn accepted_providers_splits_global_bounds_from_provider_dispatch() { + let hmac = HmacProvider::new(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())); + let hmac_value = format!("{}.ABC123", "a".repeat(64)); + let active = AcceptedProviders::active(Some(&hmac)); + + // The global bounds come first and apply whoever created the value. A + // character outside the cookie-safe alphabet, or a value over the + // length cap, never reaches a provider. + assert!( + !active.accepts(&format!("hmac~{hmac_value} with spaces")), + "the cookie-safe alphabet is a global bound" + ); + assert!( + !active.accepts(&format!("hmac~{}", "a".repeat(300))), + "the length cap is a global bound" + ); + + // Then dispatch by code to the provider that owns it. + assert!( + active.accepts(&format!("hmac~{hmac_value}")), + "the active provider's own code is accepted" + ); + assert!( + active.accepts(&hmac_value), + "the legacy bare form belongs to the built-in provider" + ); + assert!( + !active.accepts(&format!("t0ac~{hmac_value}")), + "a code no configured provider reads is rejected even in the HMAC shape" + ); + + // A vendor provider's own identifiers are accepted when it is the + // active one, and the built-in bare form then belongs to nobody. + let vendor = AcceptedProviders::active(Some(&VendorProvider)); + assert!( + vendor.accepts(&format!("t0ac~{hmac_value}")), + "the vendor provider's code is accepted when it is active" + ); + assert!( + !vendor.accepts(&hmac_value), + "the legacy bare form is the built-in provider's alone" + ); + + // With no provider selected the deployment is stateless, so the + // built-in grammar is the fallback, as it has always been. + let stateless = AcceptedProviders::active(None); + assert!( + stateless.accepts(&hmac_value), + "a stateless deployment falls back to the built-in grammar" + ); + assert!( + !stateless.accepts("not-an-identifier"), + "the fallback is still the built-in grammar, not anything goes" + ); + } + + #[test] + fn the_selector_round_trips_through_serialization() { + // The typed selector must not change the configuration surface. The + // same TOML has to parse to the same choice, and serializing has to + // write the same key back, so an existing operator configuration keeps + // working and a config push does not rewrite the selector. + for (key, expected) in [ + (EcProviderSelection::NONE_KEY, EcProviderSelection::None), + ( + HMAC_PROVIDER_KEY, + EcProviderSelection::Named(HMAC_PROVIDER_KEY.to_owned()), + ), + ("acme", EcProviderSelection::Named("acme".to_owned())), + ] { + let ec: Ec = toml::from_str(&format!("provider = \"{key}\"")) + .expect("should parse the [ec] section"); + assert_eq!( + ec.provider.as_ref(), + Some(&expected), + "`{key}` should select the provider it names" + ); + assert_eq!( + expected.key(), + key, + "`{key}` should report itself under the key it was written as" + ); + + // The serialized form is the string itself, byte for byte, so an + // operator configuration written before the selector was typed + // parses and is written back identically. + let value = + toml::Value::try_from(expected.clone()).expect("should serialize the selection"); + assert_eq!( + value, + toml::Value::String(key.to_owned()), + "`{key}` should serialize to exactly its own string" + ); + + let written = toml::to_string(&ec).expect("should serialize the [ec] section"); + assert!( + written.contains(&format!("provider = \"{key}\"")), + "`{key}` should be written back unchanged, got: {written}" + ); + + // A full round trip through the document leaves the same choice. + let reparsed: Ec = toml::from_str(&written).expect("should reparse the [ec] section"); + assert_eq!( + reparsed.provider.as_ref(), + Some(&expected), + "`{key}` should survive a serialize and parse round trip" + ); + } + } + + #[test] + fn each_selection_builds_what_its_string_key_built_before() { + // `none` is stateless, exactly as omitting the selector is. + let none = Ec { + provider: Some(EcProviderSelection::None), + ..Ec::default() + }; + assert!( + build_provider(&none, None) + .expect("explicit statelessness should build") + .is_none(), + "`none` should select no provider" + ); + + // `hmac` with its block builds the built-in provider. + let mut providers = EcProviders::default(); + providers.hmac = Some(HmacProviderConfig { + passphrase: test_passphrase(), + }); + let hmac = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + let built = build_provider(&hmac, None) + .expect("the hmac selection should build") + .expect("the hmac selection should yield a provider"); + assert_eq!( + built.id(), + HMAC_PROVIDER_KEY, + "`hmac` should select the built-in provider" + ); + assert_eq!( + built.code(), + HMAC_PROVIDER_CODE, + "the built-in provider should carry the built-in code" + ); + + // An arbitrary vendor key selects the provider the adapter injected + // under that same key. + let vendor = Ec { + provider: Some(EcProviderSelection::Named("acme".to_owned())), + ..Ec::default() + }; + let built = build_provider(&vendor, Some(Arc::new(VendorProvider))) + .expect("the vendor selection should build") + .expect("the vendor selection should yield a provider"); + assert_eq!( + built.id(), + "acme", + "a vendor key should select the injected provider of that id" + ); + } + + #[test] + fn provider_ownership_follows_the_code() { + let provider = HmacProvider::new(test_passphrase()); + let legacy = format!("{}.ABC123", "a".repeat(64)); + let coded = format!("hmac~{legacy}"); + let foreign = format!("zz00~{legacy}"); + assert!( + provider_owns_id(&provider, &coded), + "the provider owns identifiers carrying its own code" + ); + assert!( + provider_owns_id(&provider, &legacy), + "the built-in hmac provider dual-reads the legacy bare form" + ); + assert!( + !provider_owns_id(&provider, &foreign), + "an identifier with another provider's code is never owned" + ); + } + use crate::redacted::Redacted; + + fn test_passphrase() -> Redacted { + Redacted::from("a-test-passphrase-32-bytes-minimum".to_owned()) + } + + #[test] + fn default_id_semantics_match_the_builtin_shape() { + let provider = HmacProvider::new(test_passphrase()); + + // The default `accepts_id` accepts the built-in HMAC shape and rejects + // anything else, so a built-in provider's identifiers round-trip while an + // opaque value is left to a provider that overrides the check. + let valid = format!("{}.{}", "a".repeat(64), "abc123"); + assert!(provider.accepts_id(&valid), "should accept the HMAC shape"); + assert!( + !provider.accepts_id("not-hmac-shaped"), + "should reject a non-HMAC identifier by default" + ); + + // The default `normalize_id_for_kv` lowercases the hash segment. This is + // exactly the transform that would corrupt an opaque case-sensitive + // identifier, which is why such a provider overrides it. + let mixed = format!("{}.{}", "A".repeat(64), "abc123"); + assert_eq!( + provider.normalize_id_for_kv(&mixed), + format!("{}.{}", "a".repeat(64), "abc123"), + "the default should lowercase the hash segment" + ); + } + + #[test] + fn shared_provider_delegates_id_semantics_to_the_inner_provider() { + // `SharedProvider` wraps an adapter-injected provider. It must forward + // every trait method to the inner provider, including `accepts_id` and + // `normalize_id_for_kv`; a wrapper that silently used the defaults would + // drop an opaque vendor identifier on read-back. This guards that + // delegation directly. + #[derive(Debug)] + struct Inner; + + impl EdgeCookieProvider for Inner { + fn id(&self) -> &'static str { + "inner" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0in") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + value == "opaque-ok" + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + format!("kv:{value}") + } + } + + let shared = SharedProvider(Arc::new(Inner)); + + assert_eq!(shared.id(), "inner", "should delegate id"); + assert!( + shared.accepts_id("opaque-ok"), + "should delegate accepts_id acceptance to the inner provider" + ); + assert!( + !shared.accepts_id("something-else"), + "should delegate accepts_id rejection to the inner provider" + ); + assert_eq!( + shared.normalize_id_for_kv("x"), + "kv:x", + "should delegate normalize_id_for_kv to the inner provider" + ); + } + + /// A vendor provider that claims the name core already uses for its + /// built-in HMAC provider. + #[derive(Debug)] + struct VendorNamedHmacProvider; + + impl EdgeCookieProvider for VendorNamedHmacProvider { + fn id(&self) -> &'static str { + HMAC_PROVIDER_KEY + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0vh") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn two_providers_claiming_one_name_are_refused_and_both_are_named() { + // Once this work merges, IAB Tech Lab supplies an HMAC provider as a + // vendor module while core still supplies one of its own, so a + // deployment really can wire two providers called `hmac`. Resolution + // order alone would prefer the built-in one and drop the injected one + // with nothing said, which is the fault this guards. + let mut providers = EcProviders::default(); + providers.hmac = Some(HmacProviderConfig { + passphrase: test_passphrase(), + }); + let selected_hmac = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + + let err = build_provider(&selected_hmac, Some(Arc::new(VendorNamedHmacProvider))) + .expect_err("two providers claiming `hmac` should be refused"); + let message = err.to_string(); + assert!( + message.contains(HMAC_PROVIDER_KEY), + "the error should name the contested name, got: {message}" + ); + assert!( + message.contains("core") && message.contains("adapter"), + "the error should name both claimants, got: {message}" + ); + + // The clash is a wiring fault, not a property of the selection, so + // selecting something else does not hide it and the operator still + // learns at startup. + let selected_elsewhere = Ec { + 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"); + assert!( + err.to_string().contains(HMAC_PROVIDER_KEY), + "the startup check should name the contested name too, got: {err}" + ); + + // A vendor name of its own is unaffected. + let vendor = Ec { + provider: Some(EcProviderSelection::Named("acme".to_owned())), + ..Ec::default() + }; + build_provider(&vendor, Some(Arc::new(VendorProvider))) + .expect("a vendor provider under its own name should still build"); + } + + #[test] + fn a_selected_but_uninjected_vendor_provider_fails_loudly() { + let ec = Ec { + provider: Some(EcProviderSelection::from("acme")), + ..Ec::default() + }; + + let err = build_provider(&ec, None) + .expect_err("selecting a provider the adapter does not inject should error"); + assert!( + err.to_string().contains("acme"), + "the error should name the selected provider, got: {err}" + ); + } + + #[test] + fn selecting_hmac_without_its_block_fails_loudly() { + // `Ec::validate_provider_selection` rejects this pair before settings + // reach the composition root, so the state is built directly here to + // reach the seam. If the two checks ever drift apart, `build_provider` + // must still stop rather than hand back a stateless deployment. + let ec = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + ..Ec::default() + }; + + let err = build_provider(&ec, None) + .expect_err("selecting hmac with no [ec.providers.hmac] block should error"); + assert!( + err.to_string().contains("[ec.providers.hmac]"), + "the error should name the missing block, got: {err}" + ); + } + + #[test] + fn the_request_path_reuses_the_provider_the_composition_root_resolved() { + // A composition root resolves the selection once while it builds + // application state, which is the same work `build_provider` does on a + // request, so doing both means doing it twice for every request. The + // resolved provider is threaded into `RuntimeServices`, and this is the + // assertion that the request path takes it rather than resolving again: + // the same allocation, not merely an equal one. + let ec = Ec { + provider: Some(EcProviderSelection::Named("acme".to_owned())), + ..Ec::default() + }; + let resolved = build_shared_provider(&ec, Some(Arc::new(VendorProvider))) + .expect("the composition root should resolve the selection") + .expect("the selection should yield a provider"); + + let services = crate::platform::test_support::noop_services_with_resolved_ec_provider( + Arc::clone(&resolved), + ); + let for_request = request_provider(&ec, &services) + .expect("the request path should take the resolved provider") + .expect("the resolved provider should be there"); + + assert!( + Arc::ptr_eq(&resolved, &for_request), + "the request path should reuse the resolved provider, not build a second one" + ); + + // An adapter that threads nothing still resolves for itself, so core + // driven directly behaves exactly as it did before. + let unthreaded = + crate::platform::test_support::noop_services_with_ec_provider(Arc::new(VendorProvider)); + let built = request_provider(&ec, &unthreaded) + .expect("an unthreaded adapter should resolve on the request path") + .expect("the selection should yield a provider"); + assert_eq!( + built.id(), + "acme", + "resolving on the request path should still select the injected provider" + ); + } + + #[test] + fn the_startup_check_rejects_an_uninjected_provider_and_allows_statelessness() { + // A selection the adapter cannot supply is knowable without a request, + // so the composition root rejects it while application state is built. + let selected = Ec { + provider: Some(EcProviderSelection::from("acme")), + ..Ec::default() + }; + let err = ensure_provider_available(&selected, None) + .expect_err("an uninjected provider should fail the startup check"); + assert!( + err.to_string().contains("acme"), + "the error should name the selected provider, got: {err}" + ); + + // Statelessness is a supported deployment, spelled either way, and must + // never be turned into a startup error. + ensure_provider_available(&Ec::default(), 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) + .expect("should allow the explicit `none` selection"); + } +} diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 546605f8e..6e8a2a1e3 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -19,7 +19,7 @@ use crate::platform::{ }; use crate::settings::Settings; -use super::generation::{ec_hash, is_valid_ec_id}; +use super::generation::ec_hash; use super::kv::KvIdentityGraph; use super::kv_types::KvEntry; use super::rate_limiter::RateLimiter; @@ -62,9 +62,16 @@ pub fn build_pull_sync_context(ec_context: &EcContext) -> Option &'static str { + "opaque" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::provider_code!("t0op") + } + + fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &crate::ec::provider::IdentityInput<'_>, + ) -> Result< + crate::ec::provider::GeneratedEdgeCookie, + error_stack::Report, + > { + Ok(crate::ec::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[test] + fn build_pull_sync_context_accepts_the_active_non_hmac_provider() { + // A deployment whose active provider is not the built-in HMAC one must + // still dispatch pull sync for the identifiers that provider created. + // The built-in grammar rejected every non-`hmac` code, so these + // identifiers worked in the organic path and were silently skipped + // here. + const OPAQUE_ID: &str = "t0op~Opaque_Value_MixedCase"; + + let mut settings = crate::test_support::tests::create_test_settings(); + settings.ec.provider = Some(crate::ec::provider::EcProviderSelection::from("opaque")); + let services = crate::platform::test_support::noop_services_with_ec_provider( + std::sync::Arc::new(OpaqueProvider), + ); + let req = http::Request::builder() + .method("GET") + .uri("http://example.com") + .header("cookie", format!("ts-ec={OPAQUE_ID}")) + .body(EdgeBody::empty()) + .expect("should build test request"); + let geo = crate::geo::GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + }; + + let ec_context = + EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + assert_eq!( + ec_context.ec_value(), + Some(OPAQUE_ID), + "the opaque identifier should read back before pull sync sees it" + ); + + let context = build_pull_sync_context(&ec_context) + .expect("should dispatch pull sync for the active provider's identifier"); + assert_eq!( + context.ec_id(), + OPAQUE_ID, + "should carry the identifier through unchanged" + ); + } + #[test] fn build_pull_sync_context_rejects_invalid_ec_id() { let consent = ConsentContext { @@ -711,4 +798,23 @@ mod tests { "hour 1 rotation should move beta to front" ); } + + #[test] + fn build_pull_sync_context_accepts_a_minted_coded_ec_id() { + let consent = ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + // The form the creation path produces since the provider-code envelope. + let ec_id = format!("hmac~{}.ABC123", "a".repeat(64)); + let ec_context = EcContext::new_for_test(Some(ec_id.clone()), consent); + + let context = build_pull_sync_context(&ec_context) + .expect("should build pull sync context for a coded HMAC identifier"); + assert_eq!( + context.ec_id(), + ec_id, + "should dispatch the coded identifier as created" + ); + } } diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index a4cdb4730..944533b93 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -11,24 +11,28 @@ use crate::constants::{COOKIE_TS_EC, HEADER_X_TS_EC}; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; #[cfg(test)] -use crate::ec::generation::{generate_ec_id as generate_canonical_ec_id, normalize_ip}; +use crate::ec::generation::normalize_ip; +#[cfg(test)] +use crate::ec::provider::IdentityInput; +use crate::ec::provider::{provider_owns_id, request_provider}; use crate::error::TrustedServerError; #[cfg(test)] +use crate::evidence::BorrowedRequestInfo; use crate::platform::RuntimeServices; -#[cfg(test)] use crate::settings::Settings; -/// Generates a fresh EC ID based on client IP address. +/// Generates a fresh EC ID using the configured Edge Cookie provider. /// -/// Delegates to the canonical generator in [`crate::ec::generation`] so a -/// single normalization + HMAC path produces EC IDs. The canonical -/// `normalize_ip` format is a stable contract — EC hashes stored in KV -/// depend on it, and a divergent normalization would mint non-correlating -/// identities for the same client. +/// Routes through the pluggable provider model: the active `[ec] provider` +/// selection decides the outcome. Returns `Ok(None)` when no provider is +/// configured, so Trusted Server runs statelessly and creates no Edge Cookie. +/// `request_headers` lets a provider that derives identity from request +/// evidence read it; the built-in HMAC provider ignores it and uses only the +/// normalized client IP. /// /// # Errors /// -/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +/// - [`TrustedServerError::EdgeCookie`] if provider generation fails /// /// Currently exercised only by tests: the production EC lifecycle generates IDs /// through [`crate::ec`]/`EcContext` rather than this edge-cookie helper. @@ -36,32 +40,66 @@ use crate::settings::Settings; pub fn generate_ec_id( settings: &Settings, services: &RuntimeServices, -) -> Result> { - // Fallback to "unknown" when client IP is unavailable (e.g., local testing). - // All such requests share the same HMAC base; the random suffix provides uniqueness. + request_headers: Option<&http::HeaderMap>, +) -> Result, Report> { + // Fall back to "unknown" when the client IP is unavailable (for example in + // local testing). All such requests share the same HMAC base; the random + // suffix provides uniqueness. let client_ip = services - .client_info + .client_info() .client_ip .map(normalize_ip) .unwrap_or_else(|| "unknown".to_string()); log::trace!("Generating fresh EC ID from normalized client context"); - generate_canonical_ec_id(settings, &client_ip) + let Some(provider) = request_provider(&settings.ec, services)? else { + log::info!("No Edge Cookie provider configured; running statelessly"); + return Ok(None); + }; + + // The provider reads request data (for example the client IP) borrowed at + // call time, so nothing is cloned. + let request_info = BorrowedRequestInfo::new(&client_ip, request_headers); + // The publisher path gates creation on the request's consent context at + // the call site, and the built-in provider reads neither that result nor + // the consent context, so + // they are not threaded here. + let generated = provider.generate(&request_info, &IdentityInput::default())?; + let generated = crate::ec::provider::GeneratedEdgeCookie { + id: generated + .id + .map(|value| crate::ec::provider::apply_provider_code(provider.as_ref(), &value)), + response_headers: generated.response_headers, + }; + Ok(generated.id) } -/// Gets an existing EC ID from the request. +/// Reads whatever the request offers as an Edge Cookie identifier, before any +/// check that this deployment could have issued it. /// -/// Attempts to retrieve an existing EC ID from: -/// 1. The `x-ts-ec` header -/// 2. The `ts-ec` cookie +/// Reads the `x-ts-ec` header first and then the `ts-ec` cookie. Both are +/// client-controlled. `x-ts-ec` is stripped from responses but is not stripped +/// from inbound requests, so a caller must treat the result as an attacker's +/// choice of string. +/// +/// The only checks applied here are the global cookie bounds, the length cap +/// and the cookie-safe alphabet in +/// [`ec_id_has_only_allowed_chars`](crate::ec::cookies::ec_id_has_only_allowed_chars), +/// which every identifier must satisfy whichever provider created it. Those +/// bounds are a backstop on what may travel in a cookie, not a test of +/// authenticity, and on their own they accept any run of `[A-Za-z0-9._~-]`. /// -/// Returns `None` if neither source contains an EC ID. +/// Deciding whether this deployment issued the value needs the selected +/// provider, which this function does not have, so it is deliberately not +/// public. Use [`recognized_ec_id`], which applies provider ownership on top. /// /// # Errors /// /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails -pub fn get_ec_id(req: &Request) -> Result, Report> { +pub(crate) fn unvalidated_ec_id_from_request( + req: &Request, +) -> Result, Report> { if let Some(ec_id) = req .headers() .get(HEADER_X_TS_EC) @@ -93,13 +131,77 @@ pub fn get_ec_id(req: &Request) -> Result, Report, +) -> Result, Report> { + let Some(ec_id) = unvalidated_ec_id_from_request(req)? else { + return Ok(None); + }; + + let Some(provider) = request_provider(&settings.ec, services)? else { + log::debug!( + "No Edge Cookie provider configured; withholding the request's EC ID from egress" + ); + return Ok(None); + }; + + if provider_owns_id(provider.as_ref(), &ec_id) { + return Ok(Some(ec_id)); + } + + log::debug!( + "Withholding an EC ID provider `{}` does not recognize from egress", + provider.id(), + ); + Ok(None) +} + /// Gets or creates an EC ID from the request. /// /// Attempts to retrieve an existing EC ID from: /// 1. The `x-ts-ec` header /// 2. The `ts-ec` cookie /// -/// If neither exists, generates a new EC ID. +/// If neither exists, generates a new EC ID via the configured provider. +/// +/// Returns `Ok(None)` when no existing EC ID is present and no Edge Cookie +/// provider is configured, so the caller proceeds statelessly. /// /// # Errors /// @@ -109,14 +211,16 @@ pub(crate) fn get_or_generate_ec_id_from_http_request( settings: &Settings, services: &RuntimeServices, req: &Request, -) -> Result> { - if let Some(id) = get_ec_id(req)? { - return Ok(id); +) -> Result, Report> { + if let Some(id) = unvalidated_ec_id_from_request(req)? { + return Ok(Some(id)); } - // If no existing EC ID found, generate a fresh one - let ec_id = generate_ec_id(settings, services)?; - log::trace!("No existing EC ID found; generated a fresh EC ID"); + // If no existing EC ID found, generate a fresh one through the provider. + let ec_id = generate_ec_id(settings, services, Some(req.headers()))?; + if ec_id.is_some() { + log::trace!("No existing EC ID found; generated a fresh EC ID"); + } Ok(ec_id) } @@ -130,7 +234,7 @@ pub fn get_or_generate_ec_id( settings: &Settings, services: &RuntimeServices, req: &Request, -) -> Result> { +) -> Result, Report> { get_or_generate_ec_id_from_http_request(settings, services, req) } @@ -141,6 +245,7 @@ mod tests { use http::{HeaderName, header}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use crate::ec::generation::generate_ec_id as generate_canonical_ec_id; use crate::platform::test_support::{noop_services, noop_services_with_client_ip}; use crate::test_support::tests::create_test_settings; @@ -155,13 +260,24 @@ mod tests { 0x2001, 0x0db8, 0x85a3, 0x0000, 0x8a2e, 0x0370, 0x7334, 0x1234, )); - let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) - .expect("should generate EC ID via edge_cookie"); - let id_canonical = generate_canonical_ec_id(&settings, &normalize_ip(ip)) + let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + .expect("should generate EC ID via edge_cookie") + .expect("should configure the hmac provider in test settings"); + let passphrase = settings + .ec + .providers + .hmac + .as_ref() + .map(|hmac| hmac.passphrase.expose().as_str()) + .unwrap_or(""); + let id_canonical = generate_canonical_ec_id(passphrase, &normalize_ip(ip)) .expect("should generate EC ID via canonical generator"); + let bare_here = id_here + .strip_prefix("hmac~") + .expect("should carry the hmac provider code"); assert_eq!( - crate::ec::ec_hash(&id_here), + crate::ec::ec_hash(bare_here), crate::ec::ec_hash(&id_canonical), "should produce the same identity hash prefix as the canonical generator" ); @@ -178,6 +294,10 @@ mod tests { } fn is_ec_id_format(value: &str) -> bool { + // The coded envelope: hmac~<64hex>.<6alnum>. + let Some(value) = value.strip_prefix("hmac~") else { + return false; + }; let mut parts = value.split('.'); let hmac_part = match parts.next() { Some(part) => part, @@ -206,11 +326,27 @@ mod tests { fn test_generate_ec_id() { let settings: Settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, &noop_services()).expect("should generate EC ID"); + let ec_id = generate_ec_id(&settings, &noop_services(), None) + .expect("should generate EC ID") + .expect("should configure the hmac provider in test settings"); log::debug!("Generated EC ID: {}", ec_id); assert!( is_ec_id_format(&ec_id), - "should match EC ID format: {{64hex}}.{{6alnum}}" + "should match the coded EC ID format: hmac~{{64hex}}.{{6alnum}}" + ); + } + + #[test] + fn generate_ec_id_returns_none_when_no_provider_is_configured() { + let mut settings = create_test_settings(); + // No provider selected: Trusted Server runs statelessly. + settings.ec.provider = None; + + let id = generate_ec_id(&settings, &noop_services(), None) + .expect("generation should not error when no provider is configured"); + assert!( + id.is_none(), + "no Edge Cookie provider should mean no Edge Cookie is created" ); } @@ -219,10 +355,12 @@ mod tests { let settings = create_test_settings(); let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)); - let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) - .expect("should generate EC ID with client IP"); - let id_without_ip = generate_ec_id(&settings, &noop_services()) - .expect("should generate EC ID without client IP"); + let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + .expect("should generate EC ID with client IP") + .expect("should configure the hmac provider in test settings"); + let id_without_ip = generate_ec_id(&settings, &noop_services(), None) + .expect("should generate EC ID without client IP") + .expect("should configure the hmac provider in test settings"); let hmac_with_ip = id_with_ip.split_once('.').expect("should contain dot").0; let hmac_without_ip = id_without_ip.split_once('.').expect("should contain dot").0; @@ -235,22 +373,28 @@ mod tests { #[test] fn test_is_ec_id_format_accepts_valid_value() { - let value = format!("{}.{}", "a".repeat(64), "Ab12z9"); + let value = format!("hmac~{}.{}", "a".repeat(64), "Ab12z9"); assert!( is_ec_id_format(&value), - "should accept a valid EC ID format" + "should accept a valid coded EC ID format" ); } #[test] fn test_is_ec_id_format_rejects_invalid_values() { - let missing_suffix = "a".repeat(64); + let bare_legacy_shape = format!("{}.{}", "a".repeat(64), "Ab12z9"); + assert!( + !is_ec_id_format(&bare_legacy_shape), + "a freshly created identifier always carries the provider code" + ); + + let missing_suffix = format!("hmac~{}", "a".repeat(64)); assert!( !is_ec_id_format(&missing_suffix), "should reject missing suffix" ); - let invalid_hex = format!("{}.{}", "a".repeat(63) + "g", "Ab12z9"); + let invalid_hex = format!("hmac~{}.{}", "a".repeat(63) + "g", "Ab12z9"); assert!( !is_ec_id_format(&invalid_hex), "should reject non-hex HMAC content" @@ -269,16 +413,69 @@ mod tests { ); } + #[test] + fn an_identifier_this_deployment_never_issued_is_not_recognized() { + // `x-ts-ec` is stripped from responses but not from inbound requests, + // so a client can put whatever it likes in it, and the raw reader + // prefers the header over the cookie. The global cookie bounds accept + // any run of `[A-Za-z0-9._~-]`, so they cannot tell an identifier this + // deployment created from one an attacker typed. Provider ownership is + // what draws that line. + let settings = create_test_settings(); + let services = noop_services(); + + for forged in [ + // Passes the alphabet and the length cap, owned by nobody. + "not-an-identifier", + // The built-in shape under another deployment's provider code. + "zz00~aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.Ab1234", + // This deployment's code carrying a value its provider never creates. + "hmac~not-the-hmac-shape", + ] { + let req = create_test_request(&[(HEADER_X_TS_EC, forged)]); + + // The raw reader hands it straight back, which is exactly why it is + // not the check anything may rely on. + assert_eq!( + unvalidated_ec_id_from_request(&req) + .expect("should read the header") + .as_deref(), + Some(forged), + "the global bounds alone should accept `{forged}`" + ); + + assert_eq!( + recognized_ec_id(&settings, &services, &req) + .expect("should decide without erroring"), + None, + "`{forged}` was never issued here and must not be recognized" + ); + } + + // A value the selected provider does own is still recognized, so the + // check rejects forgeries rather than everything. + let issued = format!("hmac~{}.Ab1234", "a".repeat(64)); + let req = create_test_request(&[(HEADER_X_TS_EC, issued.as_str())]); + assert_eq!( + recognized_ec_id(&settings, &services, &req) + .expect("should decide without erroring") + .as_deref(), + Some(issued.as_str()), + "an identifier the selected provider owns should still be recognized" + ); + } + #[test] fn test_get_ec_id_with_header() { let settings = create_test_settings(); let req = create_test_request(&[(HEADER_X_TS_EC, "existing_ec_id")]); - let ec_id = get_ec_id(&req).expect("should get EC ID"); + let ec_id = unvalidated_ec_id_from_request(&req).expect("should get EC ID"); assert_eq!(ec_id, Some("existing_ec_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should reuse header EC ID"); + .expect("should reuse header EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_ec_id"); } @@ -290,11 +487,12 @@ mod tests { &format!("{}=existing_cookie_id", COOKIE_TS_EC), )]); - let ec_id = get_ec_id(&req).expect("should get EC ID"); + let ec_id = unvalidated_ec_id_from_request(&req).expect("should get EC ID"); assert_eq!(ec_id, Some("existing_cookie_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should reuse cookie EC ID"); + .expect("should reuse cookie EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_cookie_id"); } @@ -307,7 +505,8 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build test request"); - let ec_id = get_ec_id(&req).expect("should get EC ID from http request"); + let ec_id = + unvalidated_ec_id_from_request(&req).expect("should get EC ID from http request"); assert_eq!(ec_id, Some("existing_http_ec_id".to_string())); } @@ -326,7 +525,8 @@ mod tests { .expect("should build test request"); let ec_id = get_or_generate_ec_id_from_http_request(&settings, &noop_services(), &req) - .expect("should reuse cookie EC ID from http request"); + .expect("should reuse cookie EC ID from http request") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_http_cookie_id"); } @@ -334,7 +534,7 @@ mod tests { #[test] fn test_get_ec_id_none() { let req = create_test_request(&[]); - let ec_id = get_ec_id(&req).expect("should handle missing ID"); + let ec_id = unvalidated_ec_id_from_request(&req).expect("should handle missing ID"); assert!(ec_id.is_none()); } @@ -344,7 +544,8 @@ mod tests { let req = create_test_request(&[]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should get or generate EC ID"); + .expect("should get or generate EC ID") + .expect("should configure the hmac provider in test settings"); assert!(!ec_id.is_empty()); } @@ -355,7 +556,8 @@ mod tests { (header::COOKIE, &format!("{}=valid_cookie_id", COOKIE_TS_EC)), ]); - let ec_id = get_ec_id(&req).expect("should handle invalid header gracefully"); + let ec_id = + unvalidated_ec_id_from_request(&req).expect("should handle invalid header gracefully"); assert_eq!( ec_id, Some("valid_cookie_id".to_string()), @@ -369,7 +571,8 @@ mod tests { let req = create_test_request(&[(HEADER_X_TS_EC, "evil;injected")]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should generate fresh ID on invalid header"); + .expect("should generate fresh ID on invalid header") + .expect("should configure the hmac provider in test settings"); assert_ne!( ec_id, "evil;injected", "should not use tampered header value" @@ -387,7 +590,8 @@ mod tests { &format!("{}=bad