From 13d432c9d516c5c3f3ec7e5e32be875e704c6f07 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 18 Aug 2026 19:38:03 +0100 Subject: [PATCH 01/36] Add a pluggable Edge Cookie provider seam with the built-in HMAC provider First of five PRs decomposing the provider and permission epic. The EdgeCookieProvider trait routes Edge Cookie minting, cookie read-back, and KV keying through the selected provider, so a vendor identifier round-trips verbatim instead of being dropped by the built-in shape check. - [ec] provider selector with per-provider [ec.providers.] blocks. The deprecated [ec] passphrase form still starts for one release cycle: it maps to provider = "hmac" with a deprecation warning, and a configuration carrying both forms is rejected. provider = "none" spells explicit statelessness. A configured block that is not the selected provider is rejected at startup, as is a block with no selector. - Global identifier bounds enforced by core at mint, read-back, and cookie write: the cookie-safe alphabet [A-Za-z0-9._~-] and a 256-byte cap. An identifier outside the bounds is rejected loudly, never rewritten, so the cookie value and the identity-graph key can never silently diverge. - The identity graph is keyed by the provider's canonical form of the identifier (normalize_id_for_kv), so equivalent representations of one identity share one row. - Request evidence abstraction (crate::evidence) giving providers read access to the client IP, headers (including cookies), URL path, and query parameters. - Adapter injection seam: RuntimeServices carries an optional vendor provider, so a vendor provider lives in its own crate and core never names it. A selected provider the adapter does not inject fails the request loudly rather than silently running stateless. - Provider generate failures log at error level with the request proceeding stateless. Edge Cookie creation and use stay gated by the existing consent context exactly as on main, including with no provider selected; the permission model replaces that input in the third PR of this series. Config migration: move [ec] passphrase to [ec.providers.hmac] and set [ec] provider = "hmac". The old form keeps working for one release with a warning. Passphrases shorter than 32 characters are now rejected at startup; previously they were accepted. The design spec for this slice and the next lives at docs/superpowers/specs/2026-07-30-pluggable-providers-design.md, the 2026-07-31 draft revised to match the implementation with a revision-record table of every divergence. Every provider carries a mandatory registered four-character code (provider-code-registry.md): core mints {code}~value, checks the code at read-back, and keys the identity graph with it, so identifiers from different providers can never collide and a switch of provider cannot silently adopt another provider's identities. The built-in hmac provider mints hmac~. and dual-reads its pre-envelope bare form for one release cycle. --- crates/edgecookie/README.md | 9 + .../src/middleware.rs | 3 + .../tests/routes.rs | 3 + .../src/middleware.rs | 3 + .../tests/routes.rs | 6 + .../trusted-server-adapter-fastly/src/app.rs | 17 +- .../trusted-server-adapter-fastly/src/main.rs | 3 + .../src/middleware.rs | 3 + .../src/middleware.rs | 3 + .../tests/routes.rs | 3 + crates/trusted-server-core/src/config.rs | 3 + .../trusted-server-core/src/config_payload.rs | 22 +- crates/trusted-server-core/src/ec/cookies.rs | 203 ++--- crates/trusted-server-core/src/ec/finalize.rs | 153 +++- .../trusted-server-core/src/ec/generation.rs | 80 +- crates/trusted-server-core/src/ec/identify.rs | 23 +- crates/trusted-server-core/src/ec/mod.rs | 772 +++++++++++++++++- crates/trusted-server-core/src/ec/provider.rs | 517 ++++++++++++ crates/trusted-server-core/src/edge_cookie.rs | 154 +++- crates/trusted-server-core/src/evidence.rs | 293 +++++++ .../src/integrations/google_tag_manager.rs | 6 + .../src/integrations/prebid.rs | 3 + .../src/integrations/registry.rs | 2 +- crates/trusted-server-core/src/lib.rs | 1 + .../src/platform/test_support.rs | 22 + .../trusted-server-core/src/platform/types.rs | 38 +- .../src/response_privacy.rs | 3 + crates/trusted-server-core/src/settings.rs | 406 ++++++++- .../trusted-server-core/src/test_support.rs | 4 + .../configs/trusted-server.integration.toml | 5 +- .../tests/parity.rs | 3 + trusted-server.example.toml | 26 +- 32 files changed, 2515 insertions(+), 277 deletions(-) create mode 100644 crates/edgecookie/README.md create mode 100644 crates/trusted-server-core/src/ec/provider.rs create mode 100644 crates/trusted-server-core/src/evidence.rs diff --git a/crates/edgecookie/README.md b/crates/edgecookie/README.md new file mode 100644 index 000000000..186b8c304 --- /dev/null +++ b/crates/edgecookie/README.md @@ -0,0 +1,9 @@ +# Edge Cookie providers + +Vendor Edge Cookie provider crates live here, one per vendor, for example +`crates/edgecookie/`. Each implements the `EdgeCookieProvider` trait +from `trusted-server-core` and is wired in by an adapter. + +The built-in default provider (HMAC over the client IP) ships in +`trusted-server-core` (`ec::provider`), so no crate is needed for it. This +directory is a placeholder until a vendor provider is added. diff --git a/crates/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..5de96be92 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -33,6 +33,9 @@ fn test_router() -> edgezero_core::router::RouterService { proxy_secret = "integration-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-cloudflare/src/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..93b0f9db9 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -36,6 +36,9 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -85,6 +88,9 @@ fn make_router() -> RouterService { proxy_secret = "integration-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 06a0a155f..a2d743868 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -822,7 +822,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 +1358,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 +1430,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -1859,6 +1865,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -2512,6 +2521,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 +2939,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/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/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index f75ea687e..2389ccebc 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -35,6 +35,9 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 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/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index ac0e0c05b..1b3da4785 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -13,8 +13,6 @@ //! endpoint (`/_ts/api/v1/identify`) exposes the EC ID in its response //! body for legitimate JS use cases. -use std::borrow::Cow; - use edgezero_core::body::Body as EdgeBody; use http::{HeaderValue, Response, header}; @@ -24,64 +22,26 @@ use crate::settings::Settings; /// Maximum age for the EC cookie (1 year in seconds). const COOKIE_MAX_AGE: i32 = 365 * 24 * 60 * 60; +/// Maximum length in bytes of an Edge Cookie identifier. +/// +/// A global bound enforced wherever an identifier enters the system (mint, +/// cookie read-back, cookie write), so no provider can emit a value the cookie +/// layer, logs, or the KV key space cannot carry. +pub(crate) const MAX_EC_ID_LEN: usize = 256; + fn is_allowed_ec_id_char(c: char) -> bool { - c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '~') } -// Outbound allowlist for cookie sanitization: permits [a-zA-Z0-9._-] as a -// defense-in-depth backstop when setting the Set-Cookie header. This is -// intentionally broader than the inbound format validator +// Identifier allowlist: [A-Za-z0-9._~-], the cookie-safe alphabet every +// Edge Cookie identifier must fit regardless of which provider minted it. +// This is intentionally broader than the built-in format validator // (`generation::is_valid_ec_id`), which enforces the exact -// `<64-hex>.<6-alphanumeric>` structure and is used to reject untrusted -// request values before they enter the system. +// `<64-hex>.<6-alphanumeric>` structure of the HMAC provider; an opaque +// vendor identifier only has to fit the alphabet and the length bound. #[must_use] pub(crate) fn ec_id_has_only_allowed_chars(ec_id: &str) -> bool { - ec_id.chars().all(is_allowed_ec_id_char) -} - -fn sanitize_ec_id_for_cookie(ec_id: &str) -> Cow<'_, str> { - if ec_id_has_only_allowed_chars(ec_id) { - return Cow::Borrowed(ec_id); - } - - let safe_id = ec_id - .chars() - .filter(|c| is_allowed_ec_id_char(*c)) - .collect::(); - - log::warn!( - "Stripped disallowed characters from EC ID before setting cookie (len {} -> {}); \ - callers should reject invalid request IDs before cookie creation", - ec_id.len(), - safe_id.len(), - ); - - Cow::Owned(safe_id) -} - -/// Returns `true` if every byte in `value` is a valid RFC 6265 `cookie-octet`. -/// An empty string is always rejected. -/// -/// RFC 6265 restricts cookie values to printable US-ASCII excluding whitespace, -/// double-quote, comma, semicolon, and backslash. Rejecting these characters -/// prevents header-injection attacks where a crafted value could append -/// spurious cookie attributes (e.g. `evil; Domain=.attacker.com`). -/// -/// Non-ASCII characters (multi-byte UTF-8) are always rejected because their -/// byte values exceed `0x7E`. -#[must_use] -fn is_safe_cookie_value(value: &str) -> bool { - // RFC 6265 §4.1.1 cookie-octet: - // 0x21 — '!' - // 0x23–0x2B — '#' through '+' (excludes 0x22 DQUOTE) - // 0x2D–0x3A — '-' through ':' (excludes 0x2C comma) - // 0x3C–0x5B — '<' through '[' (excludes 0x3B semicolon) - // 0x5D–0x7E — ']' through '~' (excludes 0x5C backslash, 0x7F DEL) - // All control characters (0x00–0x20) and non-ASCII (0x80+) are also excluded. - !value.is_empty() - && value - .bytes() - .all(|b| matches!(b, 0x21 | 0x23..=0x2B | 0x2D..=0x3A | 0x3C..=0x5B | 0x5D..=0x7E)) + !ec_id.is_empty() && ec_id.len() <= MAX_EC_ID_LEN && ec_id.chars().all(is_allowed_ec_id_char) } /// Formats a `Set-Cookie` header value for the EC cookie. @@ -98,56 +58,48 @@ fn format_set_cookie(domain: &str, value: &str, max_age: i32) -> String { /// /// Per spec §5.2, the EC cookie domain is computed from /// `settings.publisher.domain` (not `cookie_domain`) to ensure the EC -/// cookie is always scoped to the publisher's apex domain. The EC ID is -/// sanitized through a narrow outbound allowlist as a defense-in-depth -/// backstop against header injection. +/// cookie is always scoped to the publisher's apex domain. Callers validate +/// the identifier with [`ec_id_has_only_allowed_chars`] before this point; +/// an identifier is rejected outright rather than rewritten, so the cookie +/// value and the identity-graph key can never silently diverge. #[must_use] pub(crate) fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String { - let safe_id = sanitize_ec_id_for_cookie(ec_id); - format_set_cookie( &settings.publisher.ec_cookie_domain(), - safe_id.as_ref(), + ec_id, COOKIE_MAX_AGE, ) } /// Sets the EC ID cookie on the given response. /// -/// Validates `ec_id` against RFC 6265 `cookie-octet` rules before -/// interpolation. If the value contains unsafe characters (e.g. semicolons), -/// the cookie is not set and a warning is logged. This prevents an attacker -/// from injecting spurious cookie attributes via a controlled ID value. +/// Validates `ec_id` against the identifier alphabet and length bound before +/// interpolation. An identifier that fails validation is rejected and the +/// cookie is not set, with an error logged; the value is never rewritten, so +/// a provider identifier survives byte for byte or not at all. This also +/// prevents an attacker from injecting spurious cookie attributes via a +/// controlled ID value. /// /// `cookie_domain` comes from operator configuration and is considered trusted. -/// -/// # Panics (debug only) -/// -/// Debug-asserts that `ec_id` passes [`super::generation::is_valid_ec_id`] -/// as a defense-in-depth check against cookie injection. pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_id: &str) { - if !is_safe_cookie_value(ec_id) { - log::warn!( - "Rejecting EC ID for Set-Cookie: value of {} bytes contains characters illegal in a cookie value", - ec_id.len() + if !ec_id_has_only_allowed_chars(ec_id) { + log::error!( + "Rejecting EC ID for Set-Cookie: value of {} bytes is empty, over {} bytes, or \ + contains characters outside the identifier alphabet", + ec_id.len(), + MAX_EC_ID_LEN, ); return; } - debug_assert!( - super::generation::is_valid_ec_id(ec_id), - "EC ID must be validated before cookie creation: got '{ec_id}'" - ); - match HeaderValue::from_str(&create_ec_cookie(settings, ec_id)) { Ok(val) => { response.headers_mut().append(header::SET_COOKIE, val); } Err(e) => { - // Unreachable in practice — is_safe_cookie_value and the debug - // assertion above gate the value, and format_set_cookie emits - // only controlled bytes. Logged for defense-in-depth symmetry - // with the rejection logging above. + // Unreachable in practice: the identifier allowlist above gates + // the value, and format_set_cookie emits only controlled bytes. + // Logged for defense-in-depth symmetry with the rejection above. log::warn!("Skipping EC Set-Cookie: invalid header value: {e}"); } } @@ -177,6 +129,28 @@ pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) #[cfg(test)] mod tests { use super::*; + + #[test] + fn identifier_bounds_reject_oversize_and_accept_tilde() { + assert!( + ec_id_has_only_allowed_chars("a.~-_Z9"), + "the cookie-safe alphabet includes the tilde" + ); + assert!( + !ec_id_has_only_allowed_chars(""), + "an empty identifier is rejected" + ); + let oversize = "a".repeat(MAX_EC_ID_LEN + 1); + assert!( + !ec_id_has_only_allowed_chars(&oversize), + "an identifier over the length cap is rejected" + ); + let at_cap = "a".repeat(MAX_EC_ID_LEN); + assert!( + ec_id_has_only_allowed_chars(&at_cap), + "an identifier at the length cap is accepted" + ); + } use crate::test_support::tests::create_test_settings; use http::header; @@ -226,17 +200,21 @@ mod tests { } #[test] - fn create_ec_cookie_sanitizes_disallowed_chars_in_id() { + fn set_ec_cookie_rejects_disallowed_chars_outright() { + // Rejection, never rewriting: an identifier outside the alphabet must + // not produce a cookie at all, so the cookie value and the identity + // graph key can never silently diverge. let settings = create_test_settings(); - let result = create_ec_cookie(&settings, "evil;injected\r\nfoo=bar\0baz"); - let value = result - .strip_prefix(&format!("{COOKIE_TS_EC}=")) - .and_then(|s| s.split_once(';').map(|(v, _)| v)) - .expect("should have cookie value portion"); - - assert_eq!( - value, "evilinjectedfoobarbaz", - "should strip disallowed characters and preserve safe chars" + let mut response = Response::new(EdgeBody::empty()); + set_ec_cookie( + &settings, + &mut response, + "evil;injected +foo=bar", + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "an identifier outside the alphabet should set no cookie" ); } @@ -289,47 +267,6 @@ mod tests { ); } - #[test] - fn is_safe_cookie_value_rejects_empty_string() { - assert!(!is_safe_cookie_value(""), "should reject empty string"); - } - - #[test] - fn is_safe_cookie_value_accepts_valid_ec_id_characters() { - assert!( - is_safe_cookie_value("abcdef0123456789.ABCDEFabcdef"), - "should accept hex digits, dots, and alphanumeric characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_non_ascii() { - assert!( - !is_safe_cookie_value("val\u{fc}e"), - "should reject non-ASCII UTF-8 characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_illegal_characters() { - assert!(!is_safe_cookie_value("val;ue"), "should reject semicolon"); - assert!(!is_safe_cookie_value("val,ue"), "should reject comma"); - assert!( - !is_safe_cookie_value("val\"ue"), - "should reject double-quote" - ); - assert!(!is_safe_cookie_value("val\\ue"), "should reject backslash"); - assert!(!is_safe_cookie_value("val ue"), "should reject space"); - assert!( - !is_safe_cookie_value("val\x00ue"), - "should reject null byte" - ); - assert!( - !is_safe_cookie_value("val\x7fue"), - "should reject DEL character" - ); - } - #[test] fn expire_ec_cookie_sets_max_age_zero() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index a553bb7a7..d09d8097a 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -8,12 +8,11 @@ use std::collections::HashSet; use edgezero_core::body::Body as EdgeBody; use http::Response; -use super::consent::{ec_consent_granted, ec_consent_withdrawn}; use crate::settings::Settings; use super::EcContext; +use super::consent::ec_consent_withdrawn; use super::cookies::{expire_ec_cookie, set_ec_cookie}; -use super::generation::is_valid_ec_id; use super::kv::KvIdentityGraph; use super::log_id; use super::prebid_eids::ingest_eid_cookies; @@ -29,12 +28,16 @@ const EC_RESPONSE_HEADERS: &[&str] = &[ /// Finalizes EC response behavior for all routes. /// -/// Applies withdrawal handling, last-seen updates, cookie reconciliation, -/// Prebid EID ingestion, and cookie writes for new EC generation. +/// Applies the resolved consent gate, last-seen updates, cookie +/// reconciliation, Prebid EID ingestion, and cookie writes for new EC generation. /// -/// On consent withdrawal, the browser response clears the EC cookie -/// immediately and the EC identity-graph KV tombstone is the authoritative -/// revocation marker. There is no separate consent KV store to clean up. +/// When the request carries an explicit withdrawal signal (a storage opt-out or +/// a TCF record refusing storage) and the client presented a cookie, the browser +/// response clears the EC cookie immediately and the EC identity-graph KV +/// tombstone is the authoritative revocation marker. A request that is merely +/// not permitted (pre-consent or fail-closed) strips EC response headers but +/// leaves an already-issued cookie intact. There is no separate consent KV +/// store to clean up. /// /// `eids_cookie` should be the raw value of the `ts-eids` cookie extracted /// from the request *before* routing consumes it. @@ -47,19 +50,27 @@ pub fn ec_finalize_response( sharedid_cookie: Option<&str>, response: &mut Response, ) { - let consent_allows_ec = ec_consent_granted(ec_context.consent()); - let consent_withdrawn = ec_consent_withdrawn(ec_context.consent()); - - if !consent_allows_ec { - // Always strip EC-specific response headers when consent is not - // currently usable for this request. This covers both explicit - // revocation and fail-closed cases such as missing geo or undecodable - // consent input. + // Apply any response headers the active provider asked for during + // generation (for example to request more client evidence). This is empty + // unless a provider produced headers, so it is safe on every path. + for (name, value) in ec_context.response_headers() { + response.headers_mut().insert(name, value.clone()); + } + + let ec_permitted = ec_context.ec_allowed(); + + if !ec_permitted { + // Always strip EC-specific response headers when EC is not permitted for + // this request, covering both an explicit withdrawal and fail-closed + // cases such as missing geo or undecodable consent input. clear_ec_headers_on_response(response, Some(registry)); // Only expire the browser cookie and tombstone the identity-graph row - // when the request carries an explicit withdrawal signal. - if consent_withdrawn && ec_context.cookie_was_present() { + // when the request carries an explicit withdrawal signal. A pre-consent + // or fail-closed state (consent is simply not granted) strips headers + // but must not destroy an already-issued identifier, or a returning user + // would be permanently withdrawn before they ever get to consent. + if ec_consent_withdrawn(ec_context.consent()) && ec_context.cookie_was_present() { expire_ec_cookie(settings, response); // Compute once for the authoritative identity-graph tombstones. @@ -82,8 +93,8 @@ pub fn ec_finalize_response( return; } - // Returning user: consent is granted and EC came from request. - if ec_context.ec_was_present() && !ec_context.ec_generated() && consent_allows_ec { + // Returning user: EC is permitted and came from the request. + if ec_context.ec_was_present() && !ec_context.ec_generated() && ec_permitted { if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) { ingest_eid_cookies(eids_cookie, sharedid_cookie, ec_id, graph, registry); } @@ -156,13 +167,13 @@ fn withdrawal_ec_ids(ec_context: &EcContext) -> HashSet { let mut hashes = HashSet::new(); if let Some(cookie_ec_id) = ec_context.existing_cookie_ec_id() - && is_valid_ec_id(cookie_ec_id) + && ec_context.accepts_id(cookie_ec_id) { hashes.insert(cookie_ec_id.to_owned()); } if let Some(active_ec_id) = ec_context.ec_value() - && is_valid_ec_id(active_ec_id) + && ec_context.accepts_id(active_ec_id) { hashes.insert(active_ec_id.to_owned()); } @@ -219,6 +230,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, jurisdiction: Jurisdiction, + ec_allowed: bool, ) -> EcContext { let consent = ConsentContext { jurisdiction, @@ -232,6 +244,7 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, ) } @@ -241,6 +254,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> EcContext { EcContext::new_for_test_with_cookie( ec_value.map(str::to_owned), @@ -248,6 +262,7 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, ) } @@ -275,7 +290,14 @@ mod tests { #[test] fn withdrawal_ec_ids_returns_cookie_ec_only_when_active_missing() { let cookie_ec = sample_ec_id("cook1e"); - let ec_context = make_context(None, Some(&cookie_ec), true, false, Jurisdiction::Unknown); + let ec_context = make_context( + None, + Some(&cookie_ec), + true, + false, + Jurisdiction::Unknown, + false, + ); let ids = withdrawal_ec_ids(&ec_context); @@ -295,6 +317,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let ids = withdrawal_ec_ids(&ec_context); @@ -313,6 +336,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let ids = withdrawal_ec_ids(&ec_context); @@ -331,6 +355,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let ids = withdrawal_ec_ids(&ec_context); @@ -402,7 +427,7 @@ mod tests { ..Default::default() }; let ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", "stale"); set_header(&mut response, "x-ts-eids", "[]"); @@ -459,6 +484,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -493,6 +519,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -527,6 +554,7 @@ mod tests { false, true, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -554,7 +582,7 @@ mod tests { #[test] fn finalize_denied_without_cookie_is_noop() { let settings = create_test_settings(); - let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown); + let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown, false); let mut response = empty_response(); let test_registry = PartnerRegistry::empty(); @@ -579,7 +607,12 @@ mod tests { } #[test] - fn finalize_unknown_jurisdiction_strips_headers_without_expiring_cookie() { + fn finalize_not_permitted_without_withdrawal_keeps_cookie() { + // When EC is not permitted (here a fail-closed unknown jurisdiction with + // no geo) but the request carries no explicit withdrawal signal, the + // response strips EC headers yet must leave an already-issued cookie + // intact. A pre-consent or transient fail-closed request must not + // permanently withdraw a returning user before they get to consent. let settings = create_test_settings(); let ec_id = sample_ec_id("unk001"); let ec_context = make_context( @@ -588,6 +621,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", &ec_id); @@ -606,15 +640,78 @@ mod tests { assert!( get_header(&response, "x-ts-ec").is_none(), - "should strip EC header when consent cannot be verified" + "should strip EC header when EC is not permitted" ); assert!( get_header(&response, "x-ts-eids").is_none(), - "should strip EID header when consent cannot be verified" + "should strip EID header when EC is not permitted" + ); + assert!( + get_header(&response, "set-cookie").is_none(), + "a not-permitted request without a withdrawal signal should keep the cookie" + ); + } + + #[test] + fn set_ec_cookie_on_response_writes_the_ts_ec_cookie() { + // The positive case: when an EC value is present, the finalize path + // writes the ts-ec cookie to the browser, carrying the EC id. + let settings = create_test_settings(); + let ec_id = sample_ec_id("setck1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + true, + ); + let mut response = empty_response(); + + set_ec_cookie_on_response(&settings, &ec_context, &mut response); + + let set_cookie = + get_header_str(&response, "set-cookie").expect("an EC value should write a Set-Cookie"); + assert!( + set_cookie.contains("ts-ec=") && set_cookie.contains(&ec_id), + "should write the ts-ec cookie carrying the EC id, got: {set_cookie}" ); + } + + #[test] + fn closed_consent_gate_writes_no_ec_cookie() { + // The gate: with the consent gate closed (ec_allowed = false), no + // ts-ec cookie is written, even when an EC value and a generated flag are + // present. The consent gate is what suppresses the cookie. + let settings = create_test_settings(); + let ec_id = sample_ec_id("gated1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + false, + ); + let mut response = empty_response(); + + // Pass a KV graph so the missing-graph guard cannot be the reason the + // cookie is suppressed; the closed gate must be doing the work. + let kv = KvIdentityGraph::failing("test_store"); + let test_registry = PartnerRegistry::empty(); + ec_finalize_response( + &settings, + &ec_context, + Some(&kv), + &test_registry, + None, + None, + &mut response, + ); + assert!( get_header(&response, "set-cookie").is_none(), - "should not expire the cookie without an explicit withdrawal signal" + "a closed consent gate must not write a ts-ec cookie" ); } } diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs index 2924b7692..a3bfb1dd6 100644 --- a/crates/trusted-server-core/src/ec/generation.rs +++ b/crates/trusted-server-core/src/ec/generation.rs @@ -11,7 +11,6 @@ use rand::Rng; use sha2::Sha256; use crate::error::TrustedServerError; -use crate::settings::Settings; type HmacSha256 = Hmac; @@ -81,19 +80,39 @@ fn generate_random_suffix(length: usize) -> String { /// /// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails pub fn generate_ec_id( - settings: &Settings, + passphrase: &str, client_ip: &str, ) -> Result> { - let mut mac = HmacSha256::new_from_slice(settings.ec.passphrase.expose().as_bytes()) - .change_context(TrustedServerError::EdgeCookie { + generate_hmac_ec_id(passphrase, &[client_ip]) +} + +/// Mints an Edge Cookie identifier as HMAC-SHA256 over the given parts plus a +/// random suffix, in the `{64hex}.{6alnum}` format. +/// +/// The parts are joined with a unit separator (`\u{1f}`), which cannot appear in +/// a client IP, User-Agent, JA4, or HTTP/2 fingerprint, so distinct part lists +/// cannot collide. A provider that derives identity from several request signals +/// (for example a Fastly provider over JA4, H2, IP, and UA) passes them as +/// separate parts. Each part must be pre-normalized by the caller. +/// +/// # Errors +/// +/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +pub fn generate_hmac_ec_id( + passphrase: &str, + parts: &[&str], +) -> Result> { + let mut mac = HmacSha256::new_from_slice(passphrase.as_bytes()).change_context( + TrustedServerError::EdgeCookie { message: "Failed to create HMAC instance".to_string(), - })?; - mac.update(client_ip.as_bytes()); + }, + )?; + // A unit separator cannot occur in any part, so distinct lists never collide. + mac.update(parts.join("\u{1f}").as_bytes()); let hmac_hash = hex::encode(mac.finalize().into_bytes()); - // Append random 6-character alphanumeric suffix for additional uniqueness. - let random_suffix = generate_random_suffix(6); - let ec_id = format!("{hmac_hash}.{random_suffix}"); + // Append a random 6-character alphanumeric suffix for additional uniqueness. + let ec_id = format!("{hmac_hash}.{}", generate_random_suffix(6)); log::trace!("Generated fresh EC ID: {}", super::log_id(&ec_id)); @@ -175,7 +194,39 @@ mod tests { use super::*; use std::net::{Ipv4Addr, Ipv6Addr}; - use crate::test_support::tests::create_test_settings; + const TEST_PASSPHRASE: &str = "test-secret-key-32-bytes-minimum"; + + #[test] + fn generate_hmac_ec_id_is_stable_per_parts_and_collision_resistant() { + // The 64-char hex prefix is HMAC over the parts and is stable for the + // same parts; the random suffix varies, so compare prefixes only. + let prefix = |parts: &[&str]| { + generate_hmac_ec_id(TEST_PASSPHRASE, parts) + .expect("should generate") + .split('.') + .next() + .expect("should have a prefix") + .to_owned() + }; + + assert_eq!( + prefix(&["a", "b"]), + prefix(&["a", "b"]), + "the same parts should yield the same stable prefix" + ); + assert_ne!( + prefix(&["a", "b"]), + prefix(&["a", "c"]), + "different parts should yield a different prefix" + ); + // The unit separator prevents a join collision: ["a", "b"] must not hash + // the same as ["ab"]. + assert_ne!( + prefix(&["a", "b"]), + prefix(&["ab"]), + "the separator should prevent ['a','b'] colliding with ['ab']" + ); + } #[test] fn normalize_ipv4_unchanged() { @@ -215,8 +266,7 @@ mod tests { #[test] fn generate_produces_valid_format() { - let settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, "192.168.1.1").expect("should generate EC ID"); + let ec_id = generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate EC ID"); assert!( is_valid_ec_id(&ec_id), "should match EC ID format: {{64hex}}.{{6alnum}}, got: {ec_id}" @@ -225,10 +275,10 @@ mod tests { #[test] fn generate_same_ip_produces_consistent_hash_prefix() { - let settings = create_test_settings(); - let first = generate_ec_id(&settings, "192.168.1.1").expect("should generate first EC ID"); + let first = + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate first EC ID"); let second = - generate_ec_id(&settings, "192.168.1.1").expect("should generate second EC ID"); + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate second EC ID"); assert_eq!( ec_hash(&first), diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 6ca251905..eeadaa290 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -10,7 +10,6 @@ use http::{Request, Response, StatusCode}; use url::Url; use super::auth::authenticate_bearer; -use super::consent::ec_consent_granted; use crate::error::TrustedServerError; use crate::openrtb::{Eid, Uid}; use crate::settings::Settings; @@ -62,7 +61,7 @@ pub fn handle_identify( ); }; - if !ec_consent_granted(ec_context.consent()) { + if !ec_context.ec_allowed() { return json_response_with_origin( StatusCode::FORBIDDEN, &serde_json::json!({ "consent": "denied" }), @@ -332,7 +331,6 @@ fn apply_cors_headers(response: &mut Response, origin: &str) { #[cfg(test)] mod tests { use super::*; - use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; use crate::ec::registry::PartnerRegistry; use crate::redacted::Redacted; @@ -352,13 +350,12 @@ mod tests { ); } - fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { let consent = ConsentContext { - jurisdiction, source: ConsentSource::Cookie, ..ConsentContext::default() }; - EcContext::new_for_test(ec_value.map(str::to_owned), consent) + EcContext::new_for_test_gated(ec_value.map(str::to_owned), consent, ec_allowed) } fn make_test_partner(source_domain: &str, api_token: &str) -> EcPartner { @@ -472,7 +469,7 @@ mod tests { .uri("https://edge.test-publisher.com/identify") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -514,7 +511,7 @@ mod tests { .header("authorization", "Bearer wrong-token") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -539,7 +536,7 @@ mod tests { .header("authorization", format!("Bearer {VALID_API_TOKEN}")) .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::Unknown, None); + let ec_context = make_ec_context(false, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct denied response"); @@ -573,7 +570,7 @@ mod tests { .header("authorization", format!("Bearer {VALID_API_TOKEN}")) .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response"); @@ -599,7 +596,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build test request"); let ec_id = format!("{}.ABC123", "a".repeat(64)); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id)); + let ec_context = make_ec_context(true, Some(&ec_id)); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct degraded identify response"); @@ -652,7 +649,7 @@ mod tests { .header("origin", "https://evil.example") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct forbidden response"); @@ -678,7 +675,7 @@ mod tests { .header("origin", "https://www.test-publisher.com") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response with CORS headers"); diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 840ce90d3..6bf07625f 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -45,6 +45,7 @@ pub mod kv_backend; pub mod kv_types; pub mod partner; pub mod prebid_eids; +pub mod provider; pub mod pull_sync; pub mod rate_limiter; pub mod registry; @@ -60,6 +61,8 @@ pub fn log_id(ec_id: &str) -> String { format!("{prefix}\u{2026}") } +use std::sync::Arc; + use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; use error_stack::Report; @@ -70,10 +73,12 @@ use crate::constants::COOKIE_TS_EC; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; +use crate::evidence::BorrowedRequestInfo; use crate::geo::GeoInfo; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; +use provider::{EdgeCookieProvider, GeneratedEdgeCookie, IdentityInput, build_provider}; use self::kv::KvIdentityGraph; use self::kv_types::KvEntry; @@ -126,7 +131,15 @@ fn request_ec_id_if_allowed(value: &str, source: &str) -> Option { /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails pub fn get_ec_id(req: &Request) -> Result, Report> { let parsed = parse_ec_from_request(req)?; - let ec_id = parsed.cookie_ec.filter(|v| is_valid_ec_id(v)); + // Accept the coded form (any provider's `{code}~value` within the global + // identifier bounds) and the legacy bare HMAC form. Provider-aware + // ownership lives in `EcContext`; this helper only reads the string. + let ec_id = parsed + .cookie_ec + .filter(|v| match provider::split_provider_code(v) { + (Some(_), value) => !value.is_empty() && cookies::ec_id_has_only_allowed_chars(v), + (None, value) => is_valid_ec_id(value), + }); if let Some(ref id) = ec_id { log::trace!("Existing EC ID found: {}", log_id(id)); } @@ -152,6 +165,10 @@ pub struct EcContext { ec_generated: bool, /// The consent context for this request. consent: ConsentContext, + /// Whether Edge Cookie creation is allowed for this request. Resolved once + /// at construction from the consent context and read via + /// [`ec_allowed`](Self::ec_allowed). + ec_allowed: bool, /// The normalized client IP, captured early before the request body /// is consumed. `None` when the platform cannot determine client IP. client_ip: Option, @@ -161,6 +178,27 @@ pub struct EcContext { /// Set via [`EcContext::set_device_signals`] before /// [`EcContext::generate_if_needed`] is called. device_signals: Option, + /// The selected Edge Cookie provider (built-in or injected), built once at + /// construction. Core asks it whether an identifier is well formed + /// ([`accepts_id`](crate::ec::provider::EdgeCookieProvider::accepts_id)) so + /// an opaque vendor identifier round-trips through read-back and withdrawal + /// instead of being dropped by the built-in shape check. `None` when no + /// provider is configured. + selected_provider: Option>, + /// A snapshot of the request evidence a provider reads at generation time: + /// the request headers (so a provider can read cookies and client hints), and + /// the URL path and query string (so it can read request parameters). + /// Captured once at construction, and only when a provider is configured, so + /// a deployment with no Edge Cookie provider clones nothing. A provider reads + /// these through [`RequestInfo`](crate::evidence::RequestInfo) at generate + /// time. + request_headers: http::HeaderMap, + request_path: String, + request_query: String, + /// Response headers a provider asked to set, captured during + /// [`EcContext::generate_if_needed`] and applied to the response by EC + /// finalization. Empty for providers that set no headers. + response_headers: Vec<(http::HeaderName, http::HeaderValue)>, } impl EcContext { @@ -200,13 +238,48 @@ impl EcContext { ) -> Result> { let parsed = parse_ec_from_request(req)?; - let ec_value = parsed.cookie_ec.clone().filter(|v| is_valid_ec_id(v)); + // Build the selected provider once. It is used here to decide whether + // the incoming cookie value is a usable identifier. Building it needs + // no request data, so nothing is cloned from the request. + let ec_provider = services.ec_provider(); + let selected_provider: Option> = + build_provider(&settings.ec, ec_provider.clone())?.map(Arc::from); + + // Read back an existing identifier only when the selected provider + // accepts its shape, so an opaque vendor identifier (for example a signed + // envelope) round-trips instead of being silently dropped by the built-in + // shape check. With no provider configured, Trusted Server is stateless: + // an existing identifier is treated as absent so it is never used or + // egressed, while the raw cookie value stays available to withdrawal + // handling below. + let ec_value = parsed.cookie_ec.clone().filter(|v| { + selected_provider + .as_ref() + .is_some_and(|selected| provider::provider_owns_id(selected.as_ref(), v)) + }); let ec_was_present = ec_value.is_some(); if let Some(ref id) = ec_value { log::trace!("Existing EC ID found: {}", log_id(id)); } + // Snapshot the request evidence a provider reads at generation time (the + // headers, so it can read cookies and client hints, and the URL path and + // query, so it can read request parameters). Capture only when a provider + // is configured and no identifier already exists, so a no-provider + // deployment and a returning visitor clone nothing. Generation runs after + // the request body may be consumed, so the snapshot is owned. + let (request_headers, request_path, request_query) = + if selected_provider.is_some() && ec_value.is_none() { + ( + req.headers().clone(), + req.uri().path().to_owned(), + req.uri().query().unwrap_or_default().to_owned(), + ) + } else { + (http::HeaderMap::new(), String::new(), String::new()) + }; + // Capture the client IP from platform services (normalized). let client_ip = services .client_info() @@ -223,11 +296,20 @@ impl EcContext { kv_store: None, }); + // Gate Edge Cookie creation and use on the request's consent context + // (jurisdiction and consent signals). With no provider selected nothing + // may mint or use an identifier, so the gate is closed rather than open + // by default. Downstream consumers read the stored result via + // [`EcContext::ec_allowed`] rather than re-deriving it. + let ec_allowed = selected_provider + .as_ref() + .is_some_and(|_| consent::ec_consent_granted(&consent)); + log::info!( - "EC context: present={}, cookie_present={}, consent_allowed={}, jurisdiction={}", + "EC context: present={}, cookie_present={}, ec_allowed={}, jurisdiction={}", ec_was_present, parsed.cookie_ec.is_some(), - consent::ec_consent_granted(&consent), + ec_allowed, consent.jurisdiction, ); @@ -237,9 +319,15 @@ impl EcContext { ec_was_present, ec_generated: false, consent, + ec_allowed, client_ip, geo_info: geo_info.cloned(), device_signals: None, + selected_provider, + request_headers, + request_path, + request_query, + response_headers: Vec::new(), }) } @@ -265,22 +353,105 @@ impl EcContext { return Ok(()); } - if !consent::ec_consent_granted(&self.consent) { + // A deployment with no provider selected is stateless: nothing to + // generate, and not an error. Reuse the provider built at read time + // rather than building it again. + let Some(ec_provider) = self.selected_provider.clone() else { + log::trace!("EC generation skipped: no Edge Cookie provider configured"); + return Ok(()); + }; + + if !self.ec_allowed { log::info!( - "EC generation skipped: consent not granted (jurisdiction={})", + "EC generation skipped: EC creation not permitted (jurisdiction={})", self.consent.jurisdiction, ); return Ok(()); } - let client_ip = self.client_ip.as_deref().ok_or_else(|| { - Report::new(TrustedServerError::EdgeCookie { + // EC generation needs the client IP; checked after the cheap skip + // guards so a stateless deployment on a host with no client IP does not + // log spurious errors. The provider reads it borrowed at generate time + // (see [`generate_with_provider`]), so nothing is cloned here. + if self.client_ip.is_none() { + return Err(Report::new(TrustedServerError::EdgeCookie { message: "Client IP required for EC generation but unavailable".to_owned(), - }) - })?; + })); + } + + self.generate_with_provider(ec_provider.as_ref(), settings, kv) + } - let ec_id = generation::generate_ec_id(settings, client_ip)?; - log::info!("Generated new EC ID: {}", log_id(&ec_id)); + /// Derives and commits an EC identifier using a specific provider. + /// + /// Split out of [`generate_if_needed`](Self::generate_if_needed) so the + /// provider is supplied explicitly: the configured path builds it from + /// settings, and tests pass one in to observe the [`IdentityInput`] a + /// provider receives. The request evidence captured at read time (client + /// IP, headers, and the URL path and query) is passed borrowed through + /// [`RequestInfo`](crate::evidence::RequestInfo), so a provider can read + /// cookies and request parameters at generate time; the built-ins read + /// only the client IP. The skip guards (existing EC, consent gate) + /// stay in [`generate_if_needed`](Self::generate_if_needed). + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when the client IP is + /// unavailable, the provider fails to derive an identifier, or persisting a + /// generated identifier to the KV identity graph fails. + fn generate_with_provider( + &mut self, + ec_provider: &dyn EdgeCookieProvider, + settings: &Settings, + kv: Option<&KvIdentityGraph>, + ) -> Result<(), Report> { + let input = IdentityInput { + consent: Some(&self.consent), + }; + // Pass the request evidence captured at read time, borrowed: the client + // IP, the request headers (so a provider reads cookies and client hints), + // and the URL path and query (so it reads request parameters). A built-in + // provider reads only the client IP; a vendor provider reads what it + // needs through [`RequestInfo`]. + let request_info = BorrowedRequestInfo::new( + self.client_ip.as_deref().unwrap_or_default(), + Some(&self.request_headers), + ) + .with_request_target(&self.request_path, &self.request_query); + let generated: GeneratedEdgeCookie = ec_provider.generate(&request_info, &input)?; + // Capture any response headers the provider asked for, even when it + // produced no identifier (for example while it still needs more client + // evidence). EC finalization applies them to the response. + self.response_headers = generated.response_headers; + let generated_id = generated + .id + .map(|value| crate::ec::provider::apply_provider_code(ec_provider, &value)); + let Some(ec_id) = generated_id else { + log::info!( + "EC generation produced no identifier (provider={}); proceeding without an EC", + ec_provider.id(), + ); + return Ok(()); + }; + // Enforce the global identifier bounds at mint: the cookie-safe + // alphabet and the length cap apply to every provider, so no + // implementation can emit a value the cookie layer or the identity + // graph cannot carry. Rejection is loud and total; the identifier is + // never rewritten. + if !ec_id_has_only_allowed_chars(&ec_id) { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Provider `{}` produced an identifier that is empty, over {} bytes, or outside the cookie-safe alphabet", + ec_provider.id(), + cookies::MAX_EC_ID_LEN, + ), + })); + } + log::info!( + "Generated new EC ID (provider={}): {}", + ec_provider.id(), + log_id(&ec_id), + ); self.ec_value = Some(ec_id); self.ec_generated = true; @@ -297,7 +468,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 +496,21 @@ impl EcContext { self.ec_value.as_deref() } + /// Returns whether `value` is a well-formed identifier for the selected + /// provider. + /// + /// Lets core validate a cookie or active identifier (for example before + /// withdrawing it) through the provider that issued it, rather than assuming + /// the built-in shape. Falls back to the built-in shape when no provider is + /// configured. + #[must_use] + pub(crate) fn accepts_id(&self, value: &str) -> bool { + self.selected_provider.as_ref().map_or_else( + || is_valid_ec_id(value), + |provider| provider::provider_owns_id(provider.as_ref(), value), + ) + } + /// Returns whether the `ts-ec` cookie was present on the incoming request. #[must_use] pub fn cookie_was_present(&self) -> bool { @@ -348,7 +540,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 +558,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 +584,13 @@ impl EcContext { self.geo_info.as_ref() } - /// Returns whether EC creation is permitted by consent for this request. + /// Returns whether Edge Cookie creation is allowed for this request. + /// + /// Resolved once at construction from the consent context (see + /// [`consent::ec_consent_granted`]). #[must_use] pub fn ec_allowed(&self) -> bool { - consent::ec_consent_granted(&self.consent) + self.ec_allowed } /// Returns the existing EC cookie value for revocation handling. @@ -399,35 +603,51 @@ impl EcContext { self.cookie_ec_value.as_deref() } - /// Returns `true` when the request carried a cookie EC and the selected - /// active EC differs from that cookie value. - #[must_use] - pub fn cookie_differs_from_active_ec(&self) -> bool { - matches!( - (self.cookie_ec_value.as_deref(), self.ec_value.as_deref()), - (Some(cookie), Some(active)) if cookie != active - ) - } - /// Returns the stable EC hash prefix from the active EC value. #[must_use] pub fn ec_hash(&self) -> Option<&str> { self.ec_value.as_deref().map(generation::ec_hash) } - /// Creates a test-only `EcContext` with explicit field values. + /// Creates a test-only `EcContext` whose creation gate is derived from the + /// consent context, matching the production construction path. + /// + /// Use [`new_for_test_gated`](Self::new_for_test_gated) when a test needs + /// an explicit gate. #[cfg(test)] #[must_use] pub fn new_for_test(ec_value: Option, consent: ConsentContext) -> Self { + let ec_allowed = consent::ec_consent_granted(&consent); + Self::new_for_test_gated(ec_value, consent, ec_allowed) + } + + /// Creates a test-only `EcContext` with an explicit creation gate. + /// + /// `ec_allowed` stands in for the gating decision the production path + /// resolves at construction, so a test can exercise the gate-open and + /// gate-closed branches directly. + #[cfg(test)] + #[must_use] + pub fn new_for_test_gated( + ec_value: Option, + consent: ConsentContext, + ec_allowed: bool, + ) -> Self { Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, ec_generated: false, consent, + ec_allowed, client_ip: None, geo_info: None, device_signals: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } @@ -439,15 +659,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 +688,7 @@ impl EcContext { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> Self { Self { ec_value, @@ -468,9 +696,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 +728,8 @@ pub(crate) fn current_timestamp() -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::ec::provider::ProviderCode; + use crate::evidence::{OwnedRequestInfo, RequestInfo}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; @@ -512,6 +748,488 @@ mod tests { format!("{}.{suffix}", prefix_char.repeat(64)) } + /// A provider that records the `Cookie` header from the request info passed + /// to `generate`, so a test can prove request cookies reach a provider (a + /// client that stores values in cookies relies on this). + #[derive(Debug)] + struct CookieCapturingProvider { + seen_cookie: std::sync::Mutex>, + } + + impl EdgeCookieProvider for CookieCapturingProvider { + fn id(&self) -> &'static str { + "cookie-capturing" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0cc") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let cookie = request_info.header("cookie").map(ToOwned::to_owned); + *self.seen_cookie.lock().expect("should lock seen cookie") = cookie; + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn a_provider_reads_request_cookies_from_the_request_info() { + // RequestInfo contract: a provider given request info that carries + // headers can read request cookies through it (a client that stores + // values in cookies relies on this). The organic generate path passes + // no header snapshot; a caller that has headers supplies them. + let mut headers = http::HeaderMap::new(); + headers.insert( + "cookie", + "client-id=abc123; ts-ec=xyz" + .parse() + .expect("should build a valid cookie header"), + ); + let request_info = OwnedRequestInfo::new("203.0.113.7".to_owned(), headers); + let provider = CookieCapturingProvider { + seen_cookie: std::sync::Mutex::new(None), + }; + + provider + .generate(&request_info, &IdentityInput::default()) + .expect("generation should succeed"); + + assert_eq!( + provider + .seen_cookie + .lock() + .expect("should lock seen cookie") + .as_deref(), + Some("client-id=abc123; ts-ec=xyz"), + "the provider should read the request cookies from the request info" + ); + } + + /// A provider whose identifiers are opaque and deliberately not the + /// built-in HMAC shape (no dot, mixed case), modeling a vendor identifier + /// such as a signed envelope. It accepts any of its own non-empty + /// identifiers. + #[derive(Debug)] + struct OpaqueIdProvider; + + impl EdgeCookieProvider for OpaqueIdProvider { + fn id(&self) -> &'static str { + "opaque" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0op") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + /// A geo that resolves to the non-regulated jurisdiction (US, no region), + /// so the consent gate is open and generation runs in provider tests. + fn non_regulated_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + } + } + + #[test] + fn read_from_request_round_trips_an_opaque_provider_identifier() { + use crate::platform::test_support::noop_services_with_ec_provider; + + // A vendor identifier that is deliberately not the built-in HMAC shape + // (no dot, mixed case) — the exact value the built-in check would drop. + const OPAQUE_ID: &str = "AbC123opaqueEnvelopeValueXYZ"; + const CODED_ID: &str = "t0op~AbC123opaqueEnvelopeValueXYZ"; + + let mut settings = create_test_settings(); + settings.ec.provider = Some("opaque".to_owned()); + let cookie = format!("ts-ec={CODED_ID}"); + let req = create_test_request(&[("cookie", &cookie)]); + + // With the opaque provider injected, its `accepts_id` governs read-back, + // so the identifier survives verbatim. + let services = noop_services_with_ec_provider(Arc::new(OpaqueIdProvider)); + let ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + Some(CODED_ID), + "an opaque provider identifier should round-trip through read-back verbatim" + ); + let _ = OPAQUE_ID; + + // Control: with the provider selected but not injected by the adapter, + // the request fails loudly instead of silently running stateless with + // the identifier dropped. + let err = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect_err("a selected but uninjected provider should fail the request"); + assert!( + err.to_string().contains("opaque"), + "the error should name the selected provider, got: {err}" + ); + + // Control: with no provider selected at all, the identifier is treated + // as absent, so a stateless deployment never uses or egresses it. + let mut stateless = create_test_settings(); + stateless.ec.provider = None; + stateless.ec.providers.hmac = None; + let ec_without = EcContext::read_from_request(&stateless, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec_without.ec_value(), + None, + "with no provider selected, an existing identifier is treated as absent" + ); + assert!( + !ec_without.ec_allowed(), + "with no provider selected, the gate stays closed" + ); + } + + /// A provider that records the request query parameter `id` and the `Cookie` + /// header it is given at generate time, proving request evidence (parameters + /// and cookies) reaches a provider through the organic generate path. + #[derive(Debug, Default)] + struct EvidenceCapturingProvider { + seen: std::sync::Mutex>, + } + + impl EdgeCookieProvider for EvidenceCapturingProvider { + fn id(&self) -> &'static str { + "evidence" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0ev") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let query_id = request_info.query_param("id").unwrap_or_default(); + let cookie = request_info.header("cookie").unwrap_or_default().to_owned(); + *self.seen.lock().expect("should lock seen evidence") = Some((query_id, cookie)); + Ok(GeneratedEdgeCookie { + id: Some("evidence-ec".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[test] + fn generate_passes_request_parameters_and_cookies_to_the_provider() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let provider = Arc::new(EvidenceCapturingProvider::default()); + let mut settings = create_test_settings(); + settings.ec.provider = Some("evidence".to_owned()); + + // A request carrying a query parameter and a (non-EC) cookie, with no + // existing `ts-ec` cookie so the generate path runs. + let req = Request::builder() + .method("GET") + .uri("http://example.com/page?id=abc123&debug=1") + .header("cookie", "client-id=xyz789") + .body(EdgeBody::empty()) + .expect("should build request"); + + let services = noop_services_with_ec_provider(provider.clone()); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, None) + .expect("should run generation"); + + let seen = provider + .seen + .lock() + .expect("should lock seen evidence") + .clone(); + assert_eq!( + seen, + Some(("abc123".to_owned(), "client-id=xyz789".to_owned())), + "the provider should read the request query parameter and cookies at generate time" + ); + assert_eq!( + ec.ec_value(), + Some("t0ev~evidence-ec"), + "the identifier the provider minted should be committed under its code" + ); + } + + /// A provider that mints an opaque, mixed-case, non-HMAC identifier at the + /// edge, so a test can prove such an identifier persists to the KV identity + /// graph under its own value as the key. + #[derive(Debug)] + struct ServerOpaqueProvider; + + impl EdgeCookieProvider for ServerOpaqueProvider { + fn id(&self) -> &'static str { + "server-opaque" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0so") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("Opaque_EC_Value_MixedCase_123".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + #[test] + fn generate_persists_an_opaque_identifier_to_kv_under_its_own_key() { + use crate::platform::test_support::noop_services_with_ec_provider; + + const OPAQUE: &str = "t0so~Opaque_EC_Value_MixedCase_123"; + + let mut settings = create_test_settings(); + settings.ec.provider = Some("server-opaque".to_owned()); + let services = noop_services_with_ec_provider(Arc::new(ServerOpaqueProvider)); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + + // No existing cookie, so the edge mints and persists. + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, Some(&graph)) + .expect("should generate and persist"); + + assert_eq!( + ec.ec_value(), + Some(OPAQUE), + "the opaque identifier should be minted" + ); + + // The entry is stored under the full identifier verbatim. + assert!( + graph.get(OPAQUE).expect("kv get should succeed").is_some(), + "the entry should exist under the opaque identifier key" + ); + + // A lowercased key must miss, proving the key preserves case rather than + // being lowercased like the built-in HMAC form (the clash this guards). + assert!( + graph + .get(&OPAQUE.to_lowercase()) + .expect("kv get should succeed") + .is_none(), + "the KV key must be case-sensitive and verbatim, not lowercased" + ); + } + + /// A provider that mints an identifier outside the cookie-safe alphabet, + /// to prove core rejects it at mint rather than rewriting it. + #[derive(Debug)] + struct IllegalIdProvider; + + impl EdgeCookieProvider for IllegalIdProvider { + fn id(&self) -> &'static str { + "illegal" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0il") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("bad;value with spaces".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + } + + #[test] + fn generate_rejects_an_identifier_outside_the_cookie_safe_alphabet() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some("illegal".to_owned()); + let services = noop_services_with_ec_provider(Arc::new(IllegalIdProvider)); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + + let err = ec + .generate_if_needed(&settings, None) + .expect_err("an identifier outside the alphabet should be rejected at mint"); + assert!( + err.to_string().contains("illegal"), + "the error should name the provider, got: {err}" + ); + assert_eq!( + ec.ec_value(), + None, + "no identifier should be committed after a mint rejection" + ); + } + + /// A provider whose identifier normalizes to a distinct canonical form, to + /// prove the identity graph is keyed by the canonical form. + #[derive(Debug)] + struct CanonicalizingProvider; + + impl EdgeCookieProvider for CanonicalizingProvider { + fn id(&self) -> &'static str { + "canonical" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0ca") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("MiXeD.CaseId".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_ascii_lowercase() + } + } + + #[test] + fn generate_keys_the_identity_graph_by_the_normalized_identifier() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some("canonical".to_owned()); + let services = noop_services_with_ec_provider(Arc::new(CanonicalizingProvider)); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, Some(&graph)) + .expect("should generate and persist"); + + assert_eq!( + ec.ec_value(), + Some("t0ca~MiXeD.CaseId"), + "the cookie value keeps the provider's exact identifier under its code" + ); + assert!( + graph + .get("t0ca~mixed.caseid") + .expect("should read the graph") + .is_some(), + "the graph row should be keyed by the code plus the canonical form" + ); + } + + #[test] + fn hmac_mints_a_coded_identifier_and_dual_reads_the_legacy_bare_form() { + let settings = create_test_settings(); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let services = crate::platform::test_support::noop_services_with_client_ip( + std::net::IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7)), + ); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, None) + .expect("should generate"); + let minted = ec.ec_value().expect("should mint an identifier"); + assert!( + minted.starts_with("hmac~"), + "a fresh HMAC identifier should carry the hmac code, got {minted}" + ); + + // A deployed pre-envelope cookie (bare form) still reads back, so the + // migration does not orphan existing identities. + let legacy = format!("{}.ABC123", "a".repeat(64)); + let cookie = format!("ts-ec={legacy}"); + let req = create_test_request(&[("cookie", &cookie)]); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + Some(legacy.as_str()), + "the legacy bare form should dual-read under the hmac provider" + ); + } + + #[test] + fn a_foreign_provider_code_is_treated_as_absent() { + // An identifier carrying another provider's code must never be adopted + // by the selected provider, so switching providers cannot silently mix + // identity populations. + let settings = create_test_settings(); + let foreign = format!("zz00~{}.ABC123", "a".repeat(64)); + let cookie = format!("ts-ec={foreign}"); + let req = create_test_request(&[("cookie", &cookie)]); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + None, + "an identifier with a foreign provider code is not this provider's" + ); + } + #[test] fn read_from_request_ignores_header_ec() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs new file mode 100644 index 000000000..ec6eeef3c --- /dev/null +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -0,0 +1,517 @@ +//! Edge Cookie identity providers. +//! +//! An [`EdgeCookieProvider`] derives an Edge Cookie identifier. Providers are +//! wired by dependency injection: a provider's constructor takes the services it +//! needs (for example [`RequestInfo`] for the client IP) +//! (the adapter, through [`build_provider`]) supplies instances per request. A +//! provider that needs a service the host does not supply cannot be built, so +//! the request stops rather than silently degrading. +//! +//! The provider is selected by configuration, with no default. [`HmacProvider`] +//! is the built-in server-side implementation that derives the identifier from +//! the client IP using HMAC, the behavior Trusted Server has always shipped. + +use std::sync::Arc; + +use error_stack::Report; + +use crate::consent::ConsentContext; +use crate::error::TrustedServerError; +use crate::evidence::RequestInfo; +use crate::redacted::Redacted; +use crate::settings::Ec; + +use super::generation; + +/// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. +/// +/// Request data (client IP, User-Agent, headers, host signals) reaches a +/// provider through the services injected into its constructor, not through this +/// struct. This carries only the per-request gating context a provider may read +/// for behavior beyond gating. The gate has already confirmed Edge Cookie +/// storage is allowed before `generate` is called. +#[derive(Default)] +pub struct IdentityInput<'a> { + /// The request's consent context, when available, for provider-specific + /// logic. The core gates generation before calling the provider, so a + /// provider reads this only to forward or record consent. [`HmacProvider`] + /// ignores it. + pub consent: Option<&'a ConsentContext>, +} + +/// The outcome of [`EdgeCookieProvider::generate`]. +/// +/// Carries the derived identifier, if any, and any response headers the provider +/// needs set on the outbound response. +#[derive(Debug, Default)] +pub struct GeneratedEdgeCookie { + /// The derived Edge Cookie identifier, or `None` when the provider produced + /// none for this request. + pub id: Option, + + /// Response headers the provider needs set on the outbound response, for + /// example to request additional client evidence on later requests. Empty + /// for providers that set no headers, such as [`HmacProvider`]. + pub response_headers: Vec<(http::HeaderName, http::HeaderValue)>, +} + +/// A strategy for deriving an Edge Cookie identifier. +/// +/// Implementations are selected by configuration. A provider derives the +/// identifier at the edge in [`generate`](Self::generate), and the page +/// response sets the `ts-ec` cookie. +/// +/// A provider returns `Ok(None)` from [`generate`](Self::generate) when it +/// cannot derive an identifier at the edge, so the request proceeds without an +/// Edge Cookie rather than failing. +/// The registered short code that namespaces one Edge Cookie provider's +/// identifiers. +/// +/// Exactly four characters from `[a-z0-9]`, allocated append-only in +/// `docs/superpowers/specs/provider-code-registry.md` and never reused. The +/// code appears as the `{code}~` prefix of every identifier the provider +/// mints, so identifiers from different providers can never collide in the +/// cookie, the identity graph, or a withdrawal, and each identifier records +/// which provider created it. +#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, derive_more::Display)] +pub struct ProviderCode(&'static str); + +impl ProviderCode { + /// Creates a provider code, validating the registry format. + /// + /// # Panics + /// + /// Panics when `code` is not exactly four characters of `[a-z0-9]`. Codes + /// are compile-time literals, so the panic fires in tests and never on a + /// request path. + #[must_use] + pub const fn new(code: &'static str) -> Self { + let bytes = code.as_bytes(); + assert!( + bytes.len() == 4, + "provider code must be exactly four characters" + ); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + assert!( + b.is_ascii_lowercase() || b.is_ascii_digit(), + "provider code characters must be [a-z0-9]" + ); + i += 1; + } + Self(code) + } + + /// The code as a string slice. + #[must_use] + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +/// The separator between a provider code and the provider's identifier value. +/// +/// The tilde is inside the cookie-safe identifier alphabet and outside the +/// built-in HMAC identifier's own characters, so a legacy bare identifier can +/// never be misread as a coded one. +pub const PROVIDER_CODE_SEPARATOR: char = '~'; + +/// Splits a full identifier into its provider-code prefix and value. +/// +/// Returns `(Some(code), value)` when the identifier starts with a well-formed +/// `{code}~` prefix, and `(None, full)` for a legacy bare identifier. The code +/// here is the raw string, not a validated [`ProviderCode`]: an unknown code +/// simply fails the ownership check against the selected provider. +#[must_use] +pub fn split_provider_code(full: &str) -> (Option<&str>, &str) { + if let Some((code, value)) = full.split_once(PROVIDER_CODE_SEPARATOR) + && code.len() == 4 + && code + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + { + return (Some(code), value); + } + (None, full) +} + +/// Whether the selected provider owns `full` as one of its identifiers. +/// +/// A coded identifier belongs to the provider whose registered code it +/// carries, with the value part accepted by that provider's +/// [`accepts_id`](EdgeCookieProvider::accepts_id). A legacy bare identifier +/// (no code prefix) belongs only to the built-in HMAC provider, which +/// dual-reads its pre-envelope form for one release cycle so deployed cookies +/// keep working across the migration. +#[must_use] +pub fn provider_owns_id(provider: &dyn EdgeCookieProvider, full: &str) -> bool { + match split_provider_code(full) { + (Some(code), value) => code == provider.code().as_str() && provider.accepts_id(value), + (None, value) => provider.id() == "hmac" && provider.accepts_id(value), + } +} + +/// The full minted identifier for `value` under `provider`'s code. +#[must_use] +pub fn apply_provider_code(provider: &dyn EdgeCookieProvider, value: &str) -> String { + format!("{}{PROVIDER_CODE_SEPARATOR}{value}", provider.code()) +} + +/// The KV-key form of a full identifier under `provider`. +/// +/// The code prefix is preserved verbatim and the provider normalizes only its +/// own value part, so distinct providers' rows can never share a key and a +/// provider never sees another provider's syntax. +#[must_use] +pub fn provider_kv_key(provider: &dyn EdgeCookieProvider, full: &str) -> String { + match split_provider_code(full) { + (Some(code), value) => format!( + "{code}{PROVIDER_CODE_SEPARATOR}{}", + provider.normalize_id_for_kv(value) + ), + (None, value) => provider.normalize_id_for_kv(value), + } +} + +pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { + /// Returns the stable identifier for this provider, used in configuration + /// and logs. + fn id(&self) -> &'static str; + + /// The provider's registered code, the `{code}~` namespace of every + /// identifier it mints. + /// + /// Mandatory, with no default: a provider must allocate a unique code in + /// `docs/superpowers/specs/provider-code-registry.md` before it can exist, + /// so no two providers can ever mint colliding identifiers. Core applies + /// the code at mint and checks it at read-back, and the provider itself + /// only ever sees its own value part. + fn code(&self) -> ProviderCode; + + /// Derives an Edge Cookie identifier from the provider's injected services + /// and the request's gating context. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when derivation fails. + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result>; + + /// Returns whether `value` is a well-formed identifier this provider issues. + /// + /// Core calls this to decide whether an incoming `ts-ec` cookie value is a + /// usable Edge Cookie identifier before reading it back, keying the KV + /// identity graph, or withdrawing it. Core strips the provider's `{code}~` + /// prefix first, so this receives only the provider's own value part. + /// This keeps the identifier opaque to + /// core: a provider whose identifiers are not the built-in shape (for + /// example an opaque signed envelope) accepts its own format here, so its + /// identifier round-trips instead of being silently dropped on read-back. + /// + /// The default accepts the built-in HMAC identifier shape + /// (`<64 hex>.<6 alphanumeric>`), which is correct for [`HmacProvider`] and + /// the other core providers. + fn accepts_id(&self, value: &str) -> bool { + generation::is_valid_ec_id(value) + } + + /// Returns the KV-key form of `value` for this provider's identifiers. + /// + /// Core keys the identity graph by the returned string, so a provider whose + /// identifiers are case-sensitive or carry no separable segments returns the + /// value unchanged to avoid collapsing distinct identifiers into one key. + /// + /// The default lowercases the leading HMAC hash segment and preserves the + /// suffix, matching the built-in identifier shape. + fn normalize_id_for_kv(&self, value: &str) -> String { + generation::normalize_ec_id_for_kv(value) + } +} + +/// The built-in HMAC Edge Cookie provider. +/// +/// Derives the identifier from the client IP (read from the [`RequestInfo`] +/// passed at call time) and the configured passphrase via +/// [`generation::generate_ec_id`]. +#[derive(Debug, Clone)] +pub struct HmacProvider { + passphrase: Redacted, +} + +impl HmacProvider { + /// Creates an HMAC provider with the given passphrase. + #[must_use] + pub fn new(passphrase: Redacted) -> Self { + Self { passphrase } + } +} + +impl EdgeCookieProvider for HmacProvider { + fn id(&self) -> &'static str { + "hmac" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("hmac") + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + let id = generation::generate_ec_id(self.passphrase.expose(), request_info.client_ip())?; + Ok(GeneratedEdgeCookie { + id: Some(id), + response_headers: Vec::new(), + }) + } +} + +/// Builds the Edge Cookie provider named by the `[ec] provider` selector, +/// injecting the services it needs. +/// +/// This is the composition root for the built-in providers. The 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 +/// +/// None of the built-in constructions fail today. The `Result` is the seam for +/// a provider whose construction can fail (for example one requiring a host +/// service the deployment does not supply), so such a misconfiguration fails +/// loudly rather than minting a degraded identifier. +pub fn build_provider( + ec: &Ec, + injected: Option>, +) -> Result>, Report> { + let Some(key) = ec.provider.as_deref() else { + return Ok(None); + }; + let provider: Option> = match key { + // Explicit statelessness: the same meaning as omitting the selector. + "none" => None, + "hmac" => ec + .providers + .hmac + .as_ref() + .map(|config| Box::new(HmacProvider::new(config.passphrase.clone())) as _), + // Any other key names a vendor or host provider the adapter injects + // through [`RuntimeServices`](crate::platform::RuntimeServices), the same + // seam the device and geo providers use, so core never names a vendor. + // The injected provider is used when its own id matches the selected key, + // and its `[ec.providers.]` block is read by the adapter that built + // it. A selected key with no matching injected provider is a deployment + // error: fail loudly rather than silently running stateless. + other => { + let provider = injected + .filter(|provider| provider.id() == other) + .map(|provider| Box::new(SharedProvider(provider)) as _); + if provider.is_none() { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Edge Cookie provider `{other}` is selected but this deployment's \ + adapter does not provide it" + ), + })); + } + provider + } + }; + Ok(provider) +} + +/// Adapts an injected, shared [`EdgeCookieProvider`] to the owned `Box` that +/// [`build_provider`] returns. +/// +/// A vendor or host provider is injected as an `Arc` so it can live in +/// [`RuntimeServices`](crate::platform::RuntimeServices) and be cloned per +/// request. Every method delegates to the inner provider, so its behavior is +/// unchanged. +#[derive(Debug)] +struct SharedProvider(Arc); + +impl EdgeCookieProvider for SharedProvider { + fn code(&self) -> ProviderCode { + self.0.code() + } + + fn id(&self) -> &'static str { + self.0.id() + } + + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result> { + self.0.generate(request_info, input) + } + + fn accepts_id(&self, value: &str) -> bool { + self.0.accepts_id(value) + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + self.0.normalize_id_for_kv(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_provider_code_separates_coded_and_legacy_forms() { + assert_eq!( + split_provider_code("hmac~abc.DEF123"), + (Some("hmac"), "abc.DEF123"), + "a four-character code before the first tilde splits off" + ); + assert_eq!( + split_provider_code("51dd~value~with~tildes"), + (Some("51dd"), "value~with~tildes"), + "only the first tilde splits, so a value may contain tildes" + ); + assert_eq!( + split_provider_code("abcdef.XYZ"), + (None, "abcdef.XYZ"), + "no tilde means the legacy bare form" + ); + assert_eq!( + split_provider_code("toolong~x"), + (None, "toolong~x"), + "a prefix that is not exactly four characters is not a code" + ); + assert_eq!( + split_provider_code("AB12~x"), + (None, "AB12~x"), + "uppercase is outside the code alphabet" + ); + } + + #[test] + fn provider_ownership_follows_the_code() { + let provider = HmacProvider::new(test_passphrase()); + let legacy = format!("{}.ABC123", "a".repeat(64)); + let coded = format!("hmac~{legacy}"); + let foreign = format!("zz00~{legacy}"); + assert!( + provider_owns_id(&provider, &coded), + "the provider owns identifiers carrying its own code" + ); + assert!( + provider_owns_id(&provider, &legacy), + "the built-in hmac provider dual-reads the legacy bare form" + ); + assert!( + !provider_owns_id(&provider, &foreign), + "an identifier with another provider's code is never owned" + ); + } + use crate::redacted::Redacted; + + fn test_passphrase() -> Redacted { + Redacted::from("a-test-passphrase-32-bytes-minimum".to_owned()) + } + + #[test] + fn default_id_semantics_match_the_builtin_shape() { + let provider = HmacProvider::new(test_passphrase()); + + // The default `accepts_id` accepts the built-in HMAC shape and rejects + // anything else, so a built-in provider's identifiers round-trip while an + // opaque value is left to a provider that overrides the check. + let valid = format!("{}.{}", "a".repeat(64), "abc123"); + assert!(provider.accepts_id(&valid), "should accept the HMAC shape"); + assert!( + !provider.accepts_id("not-hmac-shaped"), + "should reject a non-HMAC identifier by default" + ); + + // The default `normalize_id_for_kv` lowercases the hash segment. This is + // exactly the transform that would corrupt an opaque case-sensitive + // identifier, which is why such a provider overrides it. + let mixed = format!("{}.{}", "A".repeat(64), "abc123"); + assert_eq!( + provider.normalize_id_for_kv(&mixed), + format!("{}.{}", "a".repeat(64), "abc123"), + "the default should lowercase the hash segment" + ); + } + + #[test] + fn shared_provider_delegates_id_semantics_to_the_inner_provider() { + // `SharedProvider` wraps an adapter-injected provider. It must forward + // every trait method to the inner provider, including `accepts_id` and + // `normalize_id_for_kv`; a wrapper that silently used the defaults would + // drop an opaque vendor identifier on read-back. This guards that + // delegation directly. + #[derive(Debug)] + struct Inner; + + impl EdgeCookieProvider for Inner { + fn id(&self) -> &'static str { + "inner" + } + + fn code(&self) -> ProviderCode { + ProviderCode::new("t0in") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + value == "opaque-ok" + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + format!("kv:{value}") + } + } + + let shared = SharedProvider(Arc::new(Inner)); + + assert_eq!(shared.id(), "inner", "should delegate id"); + assert!( + shared.accepts_id("opaque-ok"), + "should delegate accepts_id acceptance to the inner provider" + ); + assert!( + !shared.accepts_id("something-else"), + "should delegate accepts_id rejection to the inner provider" + ); + assert_eq!( + shared.normalize_id_for_kv("x"), + "kv:x", + "should delegate normalize_id_for_kv to the inner provider" + ); + } + + #[test] + fn a_selected_but_uninjected_vendor_provider_fails_loudly() { + let ec = Ec { + provider: Some("acme".to_owned()), + ..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}" + ); + } +} diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index a4cdb4730..e77f1a82c 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -11,24 +11,29 @@ use crate::constants::{COOKIE_TS_EC, HEADER_X_TS_EC}; use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; #[cfg(test)] -use crate::ec::generation::{generate_ec_id as generate_canonical_ec_id, normalize_ip}; +use crate::ec::generation::normalize_ip; +#[cfg(test)] +use crate::ec::provider::{IdentityInput, build_provider}; use crate::error::TrustedServerError; #[cfg(test)] +use crate::evidence::BorrowedRequestInfo; +#[cfg(test)] use crate::platform::RuntimeServices; #[cfg(test)] use crate::settings::Settings; -/// Generates a fresh EC ID based on client IP address. +/// Generates a fresh EC ID using the configured Edge Cookie provider. /// -/// Delegates to the canonical generator in [`crate::ec::generation`] so a -/// single normalization + HMAC path produces EC IDs. The canonical -/// `normalize_ip` format is a stable contract — EC hashes stored in KV -/// depend on it, and a divergent normalization would mint non-correlating -/// identities for the same client. +/// Routes through the pluggable provider model: the active `[ec] provider` +/// selection decides the outcome. Returns `Ok(None)` when no provider is +/// configured, so Trusted Server runs statelessly and mints no Edge Cookie. +/// `request_headers` lets a provider that derives identity from request +/// evidence read it; the built-in HMAC provider ignores it and uses only the +/// normalized client IP. /// /// # Errors /// -/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +/// - [`TrustedServerError::EdgeCookie`] if provider generation fails /// /// Currently exercised only by tests: the production EC lifecycle generates IDs /// through [`crate::ec`]/`EcContext` rather than this edge-cookie helper. @@ -36,18 +41,39 @@ use crate::settings::Settings; pub fn generate_ec_id( settings: &Settings, services: &RuntimeServices, -) -> Result> { - // Fallback to "unknown" when client IP is unavailable (e.g., local testing). - // All such requests share the same HMAC base; the random suffix provides uniqueness. + request_headers: Option<&http::HeaderMap>, +) -> Result, Report> { + // Fall back to "unknown" when the client IP is unavailable (for example in + // local testing). All such requests share the same HMAC base; the random + // suffix provides uniqueness. let client_ip = services - .client_info + .client_info() .client_ip .map(normalize_ip) .unwrap_or_else(|| "unknown".to_string()); log::trace!("Generating fresh EC ID from normalized client context"); - generate_canonical_ec_id(settings, &client_ip) + let Some(provider) = build_provider(&settings.ec, services.ec_provider())? 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. @@ -99,7 +125,10 @@ pub fn get_ec_id(req: &Request) -> Result, Report, -) -> Result> { +) -> Result, Report> { if let Some(id) = get_ec_id(req)? { - return Ok(id); + return Ok(Some(id)); } - // If no existing EC ID found, generate a fresh one - let ec_id = generate_ec_id(settings, services)?; - log::trace!("No existing EC ID found; generated a fresh EC ID"); + // If no existing EC ID found, generate a fresh one through the provider. + let ec_id = generate_ec_id(settings, services, Some(req.headers()))?; + if ec_id.is_some() { + log::trace!("No existing EC ID found; generated a fresh EC ID"); + } Ok(ec_id) } @@ -130,7 +161,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 +172,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 +187,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 +221,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 +253,27 @@ mod tests { fn test_generate_ec_id() { let settings: Settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, &noop_services()).expect("should generate EC ID"); + let ec_id = generate_ec_id(&settings, &noop_services(), None) + .expect("should generate EC ID") + .expect("should configure the hmac provider in test settings"); log::debug!("Generated EC ID: {}", ec_id); assert!( is_ec_id_format(&ec_id), - "should match EC ID format: {{64hex}}.{{6alnum}}" + "should match the coded EC ID format: hmac~{{64hex}}.{{6alnum}}" + ); + } + + #[test] + fn generate_ec_id_returns_none_when_no_provider_is_configured() { + let mut settings = create_test_settings(); + // No provider selected: Trusted Server runs statelessly. + settings.ec.provider = None; + + let id = generate_ec_id(&settings, &noop_services(), None) + .expect("generation should not error when no provider is configured"); + assert!( + id.is_none(), + "no Edge Cookie provider should mean no Edge Cookie is minted" ); } @@ -219,10 +282,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 +300,28 @@ mod tests { #[test] fn test_is_ec_id_format_accepts_valid_value() { - let value = format!("{}.{}", "a".repeat(64), "Ab12z9"); + let value = format!("hmac~{}.{}", "a".repeat(64), "Ab12z9"); assert!( is_ec_id_format(&value), - "should accept a valid EC ID format" + "should accept a valid coded EC ID format" ); } #[test] fn test_is_ec_id_format_rejects_invalid_values() { - let missing_suffix = "a".repeat(64); + let bare_legacy_shape = format!("{}.{}", "a".repeat(64), "Ab12z9"); + assert!( + !is_ec_id_format(&bare_legacy_shape), + "a fresh mint always carries the provider code" + ); + + let missing_suffix = format!("hmac~{}", "a".repeat(64)); assert!( !is_ec_id_format(&missing_suffix), "should reject missing suffix" ); - let invalid_hex = format!("{}.{}", "a".repeat(63) + "g", "Ab12z9"); + let invalid_hex = format!("hmac~{}.{}", "a".repeat(63) + "g", "Ab12z9"); assert!( !is_ec_id_format(&invalid_hex), "should reject non-hex HMAC content" @@ -278,7 +349,8 @@ mod tests { assert_eq!(ec_id, Some("existing_ec_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should reuse header EC ID"); + .expect("should reuse header EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_ec_id"); } @@ -294,7 +366,8 @@ mod tests { assert_eq!(ec_id, Some("existing_cookie_id".to_string())); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should reuse cookie EC ID"); + .expect("should reuse cookie EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_cookie_id"); } @@ -326,7 +399,8 @@ mod tests { .expect("should build test request"); let ec_id = get_or_generate_ec_id_from_http_request(&settings, &noop_services(), &req) - .expect("should reuse cookie EC ID from http request"); + .expect("should reuse cookie EC ID from http request") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_http_cookie_id"); } @@ -344,7 +418,8 @@ mod tests { let req = create_test_request(&[]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should get or generate EC ID"); + .expect("should get or generate EC ID") + .expect("should configure the hmac provider in test settings"); assert!(!ec_id.is_empty()); } @@ -369,7 +444,8 @@ mod tests { let req = create_test_request(&[(HEADER_X_TS_EC, "evil;injected")]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) - .expect("should generate fresh ID on invalid header"); + .expect("should generate fresh ID on invalid header") + .expect("should configure the hmac provider in test settings"); assert_ne!( ec_id, "evil;injected", "should not use tampered header value" diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs new file mode 100644 index 000000000..78e0d21ca --- /dev/null +++ b/crates/trusted-server-core/src/evidence.rs @@ -0,0 +1,293 @@ +//! Service interfaces injected into providers. +//! +//! Trusted Server wires providers by dependency injection. A provider's +//! constructor takes the services it needs as `Arc`, and the adapter +//! (the composition root) supplies instances per request. A provider that needs +//! a service the host does not supply cannot be built, so the request stops +//! rather than silently degrading. +//! +//! These traits are the service interfaces. Request-scoped data outlives the +//! live request only when snapshotted, so an implementation owns its data where +//! needed ([`OwnedRequestInfo`] is the built-in owned snapshot). + +use http::HeaderMap; + +/// Read-only access to the current request's basic information. +/// +/// The request data any host can supply: the normalized client IP, the +/// User-Agent, and request headers. A provider receives it by reference at call +/// time (`generate`/`detect`), reads what it needs, and does not retain it. +pub trait RequestInfo: Send + Sync + core::fmt::Debug { + /// The normalized client IP, or `""` when the host cannot determine it. + fn client_ip(&self) -> &str; + + /// The `User-Agent` header value, or `""` when absent. + fn user_agent(&self) -> &str; + + /// An arbitrary request header by name (case-insensitive), or `None`. + /// + /// Request cookies are read through this, from the `Cookie` header (a + /// provider that stores values in cookies parses them from it). + fn header(&self, name: &str) -> Option<&str>; + + /// The names of all request headers present, for a provider that enumerates + /// evidence (for example to forward client hints). The default is empty. + fn header_names(&self) -> Vec<&str> { + Vec::new() + } + + /// The request path (the URL path, without the query string), or `""` when + /// request info was built without a URL. + /// + /// A provider reads the request target through this together with + /// [`query`](Self::query); `RequestInfo` is the evidence abstraction, so more + /// request accessors can be added here (as defaulted methods) without + /// breaking existing implementations. + fn path(&self) -> &str { + "" + } + + /// The raw request query string (the part after `?`, without the leading + /// `?`), or `""` when the request carried none. + /// + /// A provider reads request parameters through this, or the + /// [`query_param`](Self::query_param) convenience. The default is empty, for + /// request info built without a URL. + fn query(&self) -> &str { + "" + } + + /// The first value of query parameter `name`, percent-decoded, or `None` + /// when the parameter is absent. + /// + /// Parses [`query`](Self::query) with `application/x-www-form-urlencoded` + /// rules, matching how the browser encodes query parameters. + fn query_param(&self, name: &str) -> Option { + url::form_urlencoded::parse(self.query().as_bytes()) + .find_map(|(key, value)| (&*key == name).then(|| value.into_owned())) + } +} + +/// An owned [`RequestInfo`] built from a request snapshot. +/// +/// Owns the client IP and a header snapshot, for a context that cannot borrow +/// the live request for the duration of the call. The request path uses +/// [`BorrowedRequestInfo`]; this owned variant serves tests and any future +/// host whose request data cannot be borrowed. +#[derive(Debug, Default, Clone)] +pub struct OwnedRequestInfo { + client_ip: String, + headers: HeaderMap, + path: String, + query: String, +} + +impl OwnedRequestInfo { + /// Builds owned request info from the client IP and a header snapshot. + /// + /// The request target ([`path`](RequestInfo::path) and + /// [`query`](RequestInfo::query)) is empty; attach it with + /// [`with_request_target`](Self::with_request_target) when the caller has the + /// URL. + #[must_use] + pub fn new(client_ip: String, headers: HeaderMap) -> Self { + Self { + client_ip, + headers, + path: String::new(), + query: String::new(), + } + } + + /// Attaches the request target (URL path and query string) to this snapshot, + /// so a provider can read request parameters through + /// [`query_param`](RequestInfo::query_param). + #[must_use] + pub fn with_request_target(mut self, path: String, query: String) -> Self { + self.path = path; + self.query = query; + self + } +} + +impl RequestInfo for OwnedRequestInfo { + fn client_ip(&self) -> &str { + &self.client_ip + } + + fn user_agent(&self) -> &str { + self.headers + .get(http::header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + } + + fn header(&self, name: &str) -> Option<&str> { + self.headers.get(name).and_then(|value| value.to_str().ok()) + } + + fn header_names(&self) -> Vec<&str> { + self.headers.keys().map(http::HeaderName::as_str).collect() + } + + fn path(&self) -> &str { + &self.path + } + + fn query(&self) -> &str { + &self.query + } +} + +/// A borrowed [`RequestInfo`] over the live request, with no allocation. +/// +/// The composition root builds one per request from the normalized client IP and +/// an optional borrow of the request headers, then passes it to a provider by +/// shared reference at call time (`generate`/`detect`). It borrows rather than +/// owns, so it must not outlive the request. A provider reads it during the call +/// and does not retain it, so no per-request `HeaderMap` clone is needed. +#[derive(Debug)] +pub struct BorrowedRequestInfo<'a> { + client_ip: &'a str, + headers: Option<&'a HeaderMap>, + path: &'a str, + query: &'a str, +} + +impl<'a> BorrowedRequestInfo<'a> { + /// Borrows request info from the client IP and optional request headers. + /// + /// Pass `None` for headers on a path that only needs the client IP. The + /// request target ([`path`](RequestInfo::path) and + /// [`query`](RequestInfo::query)) is empty; attach it with + /// [`with_request_target`](Self::with_request_target) when the caller has the + /// URL. + #[must_use] + pub fn new(client_ip: &'a str, headers: Option<&'a HeaderMap>) -> Self { + Self { + client_ip, + headers, + path: "", + query: "", + } + } + + /// Attaches the borrowed request target (URL path and query string), so a + /// provider can read request parameters through + /// [`query_param`](RequestInfo::query_param). + #[must_use] + pub fn with_request_target(mut self, path: &'a str, query: &'a str) -> Self { + self.path = path; + self.query = query; + self + } +} + +impl RequestInfo for BorrowedRequestInfo<'_> { + fn client_ip(&self) -> &str { + self.client_ip + } + + fn user_agent(&self) -> &str { + self.headers + .and_then(|headers| headers.get(http::header::USER_AGENT)) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + } + + fn header(&self, name: &str) -> Option<&str> { + self.headers + .and_then(|headers| headers.get(name)) + .and_then(|value| value.to_str().ok()) + } + + fn header_names(&self) -> Vec<&str> { + self.headers + .map(|headers| headers.keys().map(http::HeaderName::as_str).collect()) + .unwrap_or_default() + } + + fn path(&self) -> &str { + self.path + } + + fn query(&self) -> &str { + self.query + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn headers_with_cookie() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + "client-id=abc123; ts-ec=xyz" + .parse() + .expect("should parse cookie header"), + ); + headers + } + + #[test] + fn query_param_decodes_and_selects_the_first_value() { + let info = OwnedRequestInfo::new(String::new(), HeaderMap::new()) + .with_request_target("/page".to_owned(), "id=a%20b&id=second&flag=1".to_owned()); + + assert_eq!( + info.query_param("id").as_deref(), + Some("a b"), + "should percent-decode and return the first value for a repeated key" + ); + assert_eq!(info.query_param("flag").as_deref(), Some("1")); + assert_eq!( + info.query_param("missing"), + None, + "an absent parameter should be None" + ); + } + + #[test] + fn path_and_query_accessors_return_the_request_target() { + let info = OwnedRequestInfo::new(String::new(), HeaderMap::new()) + .with_request_target("/a/b".to_owned(), "x=1".to_owned()); + assert_eq!(info.path(), "/a/b"); + assert_eq!(info.query(), "x=1"); + } + + #[test] + fn request_info_defaults_to_an_empty_target() { + let info = OwnedRequestInfo::new("203.0.113.5".to_owned(), HeaderMap::new()); + assert_eq!(info.path(), "", "path should default to empty"); + assert_eq!(info.query(), "", "query should default to empty"); + assert_eq!( + info.query_param("id"), + None, + "query_param over an empty query should be None" + ); + } + + #[test] + fn a_provider_reads_cookies_from_the_header() { + let info = OwnedRequestInfo::new("203.0.113.5".to_owned(), headers_with_cookie()); + assert_eq!( + info.header("cookie"), + Some("client-id=abc123; ts-ec=xyz"), + "cookies are read through the Cookie header" + ); + } + + #[test] + fn borrowed_request_info_exposes_the_same_target() { + let headers = headers_with_cookie(); + let info = BorrowedRequestInfo::new("203.0.113.5", Some(&headers)) + .with_request_target("/page", "id=abc123"); + + assert_eq!(info.path(), "/page"); + assert_eq!(info.query(), "id=abc123"); + assert_eq!(info.query_param("id").as_deref(), Some("abc123")); + assert_eq!(info.header("cookie"), Some("client-id=abc123; ts-ec=xyz")); + } +} diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 162e9eb8c..4aceca9dc 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -1566,6 +1566,9 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] @@ -1599,6 +1602,9 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index d0cf37275..47b899a13 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -3057,6 +3057,9 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#; diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 280eae847..4399dc912 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -974,7 +974,7 @@ impl IntegrationRegistry { // may lack consent signals such as the Sec-GPC header. if is_navigation_request(&req) { if let Err(err) = ec_context.generate_if_needed(settings, kv) { - log::warn!("EC generation failed for integration proxy: {err:?}"); + log::error!("EC generation failed for integration proxy: {err:?}"); } } else { log::debug!( diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 48e92faed..3801ed9f3 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -47,6 +47,7 @@ pub mod creative_opportunities; pub mod ec; pub(crate) mod edge_cookie; pub mod error; +pub mod evidence; pub mod geo; pub mod host_header; pub(crate) mod host_rewrite; diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 917f1bf50..cecb902fa 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -688,6 +688,28 @@ pub(crate) fn noop_services() -> RuntimeServices { build_services_with_config(NoopConfigStore) } +/// Build a [`RuntimeServices`] with an injected Edge Cookie provider, so a test +/// can exercise the adapter-injection path an opaque-identifier vendor provider +/// reaches core through. +pub(crate) fn noop_services_with_ec_provider( + ec_provider: Arc, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + // A fixed client IP so the generate path (which requires one) can run. + .client_info(ClientInfo { + client_ip: Some("203.0.113.10".parse().expect("should parse test client IP")), + ..ClientInfo::default() + }) + .ec_provider(ec_provider) + .build() +} + /// Build a [`RuntimeServices`] whose auction telemetry sink is the supplied /// recording (or otherwise custom) sink, so tests can assert which terminal /// auction events were emitted. diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 7a3d09334..a6c535ba9 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -9,6 +9,7 @@ use super::{ PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, }; +use crate::ec::provider::EdgeCookieProvider; /// Geographic information extracted from a request. /// @@ -18,7 +19,7 @@ use super::{ pub struct GeoInfo { /// City name. pub city: String, - /// Two-letter country code. + /// ISO 3166-1 alpha-2 country code, for example `US` or `GB`. pub country: String, /// Continent name. pub continent: String, @@ -28,7 +29,8 @@ pub struct GeoInfo { pub longitude: f64, /// DMA (Designated Market Area) / metro code. pub metro_code: i64, - /// Region code. + /// ISO 3166-2 subdivision code without the country prefix, for example `CA` + /// for California, or `None` when no region resolves. pub region: Option, /// Autonomous System Number (e.g. `7922` = Comcast). /// Used to distinguish home ISP vs. corporate VPN. @@ -188,6 +190,12 @@ pub struct RuntimeServices { pub(crate) auction_telemetry_sink: Arc, /// Per-request client metadata extracted at the entry point. pub(crate) client_info: ClientInfo, + /// A vendor or host Edge Cookie provider the adapter injects, selected when + /// `[ec] provider` names it. `None` when only the built-in providers are in + /// use. This is the seam that lets a vendor Edge Cookie provider live in its + /// own crate and be injected, so core never names a vendor (the same + /// pattern as [`geo`](Self::geo)). + pub(crate) ec_provider: Option>, } impl RuntimeServices { @@ -275,6 +283,17 @@ impl RuntimeServices { &self.client_info } + /// Returns the adapter-injected Edge Cookie provider, when one is wired. + /// + /// `None` when the deployment uses only the built-in providers (which core + /// builds itself). A vendor or host provider is injected here by the + /// adapter, so [`build_provider`](crate::ec::provider::build_provider) can + /// return it without core naming the vendor. + #[must_use] + pub fn ec_provider(&self) -> Option> { + self.ec_provider.clone() + } + /// Wrap the KV store in a [`super::KvHandle`] for ergonomic access to /// JSON helpers, pagination, and validation. #[must_use] @@ -342,6 +361,7 @@ pub struct RuntimeServicesBuilder { geo: Option>, auction_telemetry_sink: Option>, client_info: Option, + ec_provider: Option>, } impl RuntimeServicesBuilder { @@ -357,6 +377,7 @@ impl RuntimeServicesBuilder { geo: None, auction_telemetry_sink: None, client_info: None, + ec_provider: None, } } @@ -436,6 +457,18 @@ impl RuntimeServicesBuilder { self } + /// Set the adapter-injected Edge Cookie provider. + /// + /// Optional: leave it unset for a deployment that uses only the built-in + /// providers. Set it to inject a vendor or host provider selected by + /// `[ec] provider`, so the provider lives in its own crate and core never + /// names it. + #[must_use] + pub fn ec_provider(mut self, ec_provider: Arc) -> Self { + self.ec_provider = Some(ec_provider); + self + } + /// Construct [`RuntimeServices`] from the accumulated configuration. /// /// # Panics @@ -476,6 +509,7 @@ impl RuntimeServicesBuilder { client_info: self .client_info .expect("should set client_info before building RuntimeServices"), + ec_provider: self.ec_provider, } } } diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 8674429ea..d2a413a71 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -219,6 +219,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 0c78ab00b..a650a6622 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -476,9 +476,38 @@ impl EcPartner { #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct Ec { - /// Publisher passphrase used as HMAC key for EC generation. - #[validate(custom(function = Ec::validate_passphrase))] - pub passphrase: Redacted, + /// The key of the Edge Cookie identity provider to activate. + /// + /// Names one of the blocks under [`providers`](Self::providers), for + /// example `"hmac"`. Set it in the `[ec]` TOML section or override it with + /// the `TRUSTED_SERVER__ec__provider` environment variable so the same + /// compiled WebAssembly can switch providers at deployment. When absent, no + /// Edge Cookie is generated and Trusted Server runs statelessly; the + /// explicit `"none"` spells the same choice. Selecting a provider whose + /// block is missing is rejected at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + #[serde(default)] + pub provider: Option, + + /// Deprecated location of the HMAC passphrase, read so a configuration + /// written for the previous release still starts. + /// + /// [`migrate_legacy_passphrase`](Self::migrate_legacy_passphrase) maps it + /// to `provider = "hmac"` with the passphrase in the `[ec.providers.hmac]` + /// block and logs a deprecation warning, so a fleet can move configuration + /// and binaries independently. A configuration carrying both the old and + /// the new form is rejected rather than guessed at. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passphrase: Option>, + + /// Configuration blocks for the available Edge Cookie identity providers. + /// + /// Each provider has its own optional `[ec.providers.]` block. The + /// [`provider`](Self::provider) selector names which one is active, so a + /// block can be configured (or kept) without being the one in use. + #[serde(default)] + #[validate(nested)] + pub providers: EcProviders, /// Fastly KV store name for the EC identity graph. #[serde(default)] @@ -566,6 +595,191 @@ impl Ec { } Ok(()) } + + /// Validates that the selected provider names a configured block. + /// + /// When [`provider`](Self::provider) is set, the matching block under + /// [`providers`](Self::providers) must be present, so a deployment that + /// selects a provider (in TOML or via the environment override) but has not + /// configured it fails fast at startup rather than silently running + /// stateless. When no provider is selected, Trusted Server runs statelessly + /// and this check passes. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the selected provider + /// key is unknown or its `[ec.providers.]` block is absent. + pub fn validate_provider_selection(&self) -> Result<(), Report> { + let Some(key) = self.provider.as_deref() else { + if !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec.providers.*] blocks are configured but no [ec] provider is \ + selected. Set [ec] provider = \"\" to activate one, or \ + remove the blocks to run statelessly" + .to_owned(), + })); + } + return Ok(()); + }; + + // `"none"` is explicit statelessness: the same meaning as omitting + // the selector, spelled out. It is subject to the same rule that no + // provider blocks may be left configured. + if key == "none" { + if !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider = \"none\" selects stateless operation, but \ + [ec.providers.*] blocks are configured. Remove the blocks, or \ + select the provider they configure" + .to_owned(), + })); + } + return Ok(()); + } + + let configured = match key { + "hmac" => self.providers.hmac.is_some(), + // A vendor or host provider the adapter injects is configured when + // its `[ec.providers.]` block is present. The adapter validates + // the block's own contents when it builds the provider. + other => self.providers.has_vendor(other), + }; + + if !configured { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Edge Cookie provider `{key}` is selected but has no `[ec.providers.{key}]` configuration" + ), + })); + } + + // Every configured block must be the selected one. An unreferenced + // block is almost always a mistake (a mistyped selector or a stale + // block), and accepting it silently invites configuration drift. + let mut unreferenced: Vec = Vec::new(); + if self.providers.hmac.is_some() && key != "hmac" { + unreferenced.push("hmac".to_owned()); + } + for vendor_key in self.providers.vendor_keys() { + if vendor_key != key { + unreferenced.push(vendor_key.to_owned()); + } + } + if unreferenced.is_empty() { + Ok(()) + } else { + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[ec.providers.{}] is configured but `{key}` is selected. Remove the \ + unselected block, or correct the selector", + unreferenced.join("], [ec.providers.") + ), + })) + } + } + + /// Migrates the deprecated `[ec] passphrase` form to the provider layout. + /// + /// A configuration still carrying the old key keeps working for one + /// release cycle: it maps to `provider = "hmac"` with the passphrase in + /// the `[ec.providers.hmac]` block, and a deprecation warning names the + /// new location. A configuration carrying both forms is rejected so a + /// half-edited file fails loudly instead of one form silently winning. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when both the deprecated + /// key and any part of the provider configuration are present. + pub fn migrate_legacy_passphrase(&mut self) -> Result<(), Report> { + let Some(passphrase) = self.passphrase.take() else { + return Ok(()); + }; + if self.provider.is_some() || !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] passphrase (deprecated) and the [ec] provider configuration \ + are both present. Keep exactly one form: move the passphrase to \ + [ec.providers.hmac] and delete the old key" + .to_owned(), + })); + } + log::warn!( + "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ + set [ec] provider = \"hmac\"" + ); + self.provider = Some("hmac".to_owned()); + self.providers.hmac = Some(HmacProviderConfig { passphrase }); + Ok(()) + } +} + +/// Configuration blocks for the available Edge Cookie identity providers. +/// +/// Each provider is configured in its own `[ec.providers.]` block, for +/// example: +/// +/// ```toml +/// [ec.providers.hmac] +/// passphrase = "replace-with-32-plus-byte-random-secret" +/// ``` +/// +/// The active provider is chosen by the [`Ec::provider`] selector, so a block +/// can be present without being in use. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +pub struct EcProviders { + /// The built-in HMAC-over-client-IP provider, keyed `hmac`. + #[serde(default)] + #[validate(nested)] + pub hmac: Option, + + /// Configuration blocks for vendor or host providers that live in their own + /// crates and are injected by the adapter. Any `[ec.providers.]` block + /// whose key is not a built-in is captured here as raw values, and the + /// adapter that constructs the provider deserializes its own block into the + /// vendor crate's config type. Core never names a vendor, so a new provider + /// adds nothing here. + #[serde(flatten)] + vendor: HashMap, +} + +impl EcProviders { + /// Returns the raw configuration block for a vendor provider `key`, or + /// `None` when no `[ec.providers.]` block is present. The adapter that + /// builds the provider deserializes this into its own config type. + #[must_use] + pub fn vendor_config(&self, key: &str) -> Option<&JsonValue> { + self.vendor.get(key) + } + + /// Whether a vendor provider configuration block is present for `key`. + #[must_use] + pub fn has_vendor(&self, key: &str) -> bool { + self.vendor.contains_key(key) + } + + /// The keys of the configured vendor provider blocks. + pub(crate) fn vendor_keys(&self) -> impl Iterator { + self.vendor.keys().map(String::as_str) + } + + /// Whether any provider configuration block is present. + /// + /// Used by [`Ec::validate_provider_selection`] to reject a half-migrated + /// configuration that carries provider blocks with no selector, which + /// would otherwise silently run stateless. + #[must_use] + pub fn is_empty(&self) -> bool { + self.hmac.is_none() && self.vendor.is_empty() + } +} + +/// Configuration for the built-in HMAC Edge Cookie provider. +/// +/// Mapped from the `[ec.providers.hmac]` TOML block. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +pub struct HmacProviderConfig { + /// Publisher passphrase used as the HMAC key for EC generation. + #[validate(custom(function = Ec::validate_passphrase))] + pub passphrase: Redacted, } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] @@ -2917,6 +3131,8 @@ impl Settings { }) })?; + settings.ec.migrate_legacy_passphrase()?; + settings.ec.validate_provider_selection()?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -3007,8 +3223,10 @@ impl Settings { pub fn reject_placeholder_secrets(&self) -> Result<(), Report> { let mut insecure_fields: Vec = Vec::new(); - if Ec::is_placeholder_passphrase(self.ec.passphrase.expose()) { - insecure_fields.push("ec.passphrase".to_owned()); + if let Some(hmac) = &self.ec.providers.hmac + && Ec::is_placeholder_passphrase(hmac.passphrase.expose()) + { + insecure_fields.push("ec.providers.hmac.passphrase".to_owned()); } if Publisher::is_placeholder_proxy_secret(self.publisher.proxy_secret.expose()) { insecure_fields.push("publisher.proxy_secret".to_owned()); @@ -4371,9 +4589,14 @@ mod tests { ); assert_eq!(settings.publisher.origin_host_header_override, None); assert_eq!( - settings.ec.passphrase.expose(), - "test-secret-key-32-bytes-minimum" + settings.ec.provider.as_deref(), + Some("hmac"), + "test settings should select the hmac EC provider" ); + let Some(hmac) = &settings.ec.providers.hmac else { + panic!("test settings should configure the hmac EC provider"); + }; + assert_eq!(hmac.passphrase.expose(), "test-secret-key-32-bytes-minimum"); settings.validate().expect("Failed to validate settings"); } @@ -4540,6 +4763,34 @@ mod tests { ); } + #[test] + fn provider_selection_allows_no_provider_for_stateless_operation() { + let ec = Ec::default(); + assert!(ec.provider.is_none(), "default Ec selects no provider"); + ec.validate_provider_selection() + .expect("should allow no provider selected and run statelessly"); + } + + #[test] + fn provider_selection_rejects_a_selector_without_a_configured_block() { + // Point the selector at a provider whose `[ec.providers.]` block is + // absent, mirroring a deployment that sets the env override to a + // provider it never configured. + let toml_str = + crate_test_settings_str().replace(r#"provider = "hmac""#, r#"provider = "acme""#); + + let err = Settings::from_toml(&toml_str) + .expect_err("selecting an unconfigured provider should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "unconfigured provider selection should be a configuration error, got: {:?}", + err.current_context() + ); + } + #[test] fn cache_asset_rule_globs_respect_path_separators() { let toml_str = format!( @@ -4646,6 +4897,25 @@ mod tests { ); } + #[test] + fn provider_blocks_without_a_selector_are_rejected() { + // A half-migrated configuration that carries an [ec.providers.hmac] + // block but never selects it would silently run stateless; reject it + // at startup instead. + let toml_str = crate_test_settings_str().replace("provider = \"hmac\"\n", ""); + + let err = Settings::from_toml(&toml_str) + .expect_err("a provider block with no selector should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + #[test] fn cache_asset_rule_policy_validation_rejects_unsafe_config() { let missing_ttl = format!( @@ -4759,6 +5029,35 @@ mod tests { ); } + #[test] + fn legacy_passphrase_migrates_to_the_hmac_provider() { + let mut ec = Ec { + passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), + ..Ec::default() + }; + ec.migrate_legacy_passphrase() + .expect("should migrate the deprecated form"); + assert_eq!( + ec.provider.as_deref(), + Some("hmac"), + "the deprecated passphrase should select the hmac provider" + ); + assert_eq!( + ec.providers + .hmac + .as_ref() + .expect("should configure the hmac block") + .passphrase + .expose(), + "test-secret-key-32-bytes-minimum", + "the passphrase should move into the hmac block" + ); + assert!( + ec.passphrase.is_none(), + "the deprecated field should be consumed by the migration" + ); + } + #[test] fn cache_asset_rule_validation_rejects_invalid_config() { let duplicate_ids = format!( @@ -4836,6 +5135,74 @@ mod tests { ); } + #[test] + fn legacy_passphrase_alongside_provider_config_is_rejected() { + let mut ec = Ec { + passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), + provider: Some("hmac".to_owned()), + ..Ec::default() + }; + let err = ec + .migrate_legacy_passphrase() + .expect_err("both forms present should be rejected"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + + #[test] + fn provider_none_is_explicit_stateless() { + let ec = Ec { + provider: Some("none".to_owned()), + ..Ec::default() + }; + ec.validate_provider_selection() + .expect("explicit none with no blocks should be valid"); + } + + #[test] + fn provider_none_with_configured_blocks_is_rejected() { + let ec = Ec { + provider: Some("none".to_owned()), + providers: EcProviders { + hmac: Some(HmacProviderConfig { + passphrase: Redacted::new("test-secret-key-32-bytes-minimum".to_owned()), + }), + ..EcProviders::default() + }, + ..Ec::default() + }; + assert!( + ec.validate_provider_selection().is_err(), + "none alongside configured blocks should be rejected" + ); + } + + #[test] + fn an_unselected_provider_block_is_rejected() { + // A vendor selector with the vendor block present, plus a stray hmac + // block, is almost always a stale or mistyped configuration. + let toml_str = crate_test_settings_str().replace( + "provider = \"hmac\"", + "provider = \"acme\"\n\n [ec.providers.acme]\n api_key = \"example\"", + ); + let err = Settings::from_toml(&toml_str) + .expect_err("a configured but unselected block should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + #[test] fn validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( @@ -5230,7 +5597,9 @@ origin_host_header_overide = "www.example.com""#, let mut settings = Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); settings.publisher.proxy_secret = Redacted::new("unit-test-proxy-secret".to_owned()); - settings.ec.passphrase = Redacted::new("test-secret-key-32-bytes-minimum".to_owned()); + settings.ec.providers.hmac = Some(HmacProviderConfig { + passphrase: Redacted::new("test-secret-key-32-bytes-minimum".to_owned()), + }); settings.handlers[0].password = Redacted::new("replace-with-admin-password-32-bytes".to_owned()); @@ -5869,6 +6238,9 @@ origin_host_header_overide = "www.example.com""#, proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -5900,6 +6272,9 @@ origin_host_header_overide = "www.example.com""#, max_buffered_body_bytes = 0 [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ); @@ -7054,6 +7429,9 @@ origin_host_header_overide = "www.example.com""#, proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -7387,6 +7765,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7471,6 +7852,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7507,6 +7891,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7549,6 +7936,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 5f094c0d2..f755a0bcd 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -31,7 +31,11 @@ pub mod tests { rewrite_attributes = ["href", "link", "url"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [request_signing] config_store_id = "test-config-store-id" secret_store_id = "test-secret-store-id" diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index d8e35d179..fa6fef6e8 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -10,10 +10,13 @@ origin_url = "http://127.0.0.1:8888" proxy_secret = "integration-test-proxy-secret" [ec] -passphrase = "integration-test-ec-secret-padded-32" +provider = "hmac" ec_store = "ec_identity_store" pull_sync_concurrency = 3 +[ec.providers.hmac] +passphrase = "integration-test-ec-secret-padded-32" + [[ec.partners]] name = "Integration Test Partner" source_domain = "inttest.example.com" diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index acf7f5f4b..853a48ee9 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -43,6 +43,9 @@ fn test_settings() -> Settings { proxy_secret = "parity-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/trusted-server.example.toml b/trusted-server.example.toml index b0e359cb4..3b644c51d 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -6,10 +6,13 @@ # `trusted-server.toml`. Copy it (`ts config init`), fill in the required # values, and push it (`ts config push`) as an EdgeZero app-config blob. # -# Only three sections are REQUIRED for the server to start and pass validation: +# Only two sections are REQUIRED for the server to start and pass validation: # 1. [[handlers]] covering /_ts/admin (admin authentication) # 2. [publisher] (domain + origin) -# 3. [ec] passphrase (Edge Cookie identity secret) +# +# Edge Cookie identity is optional and stays off until an [ec] provider is +# selected, so no identity secret is needed to start. See the Edge Cookie +# section below. # # Everything below those is OPTIONAL. Most optional blocks are commented out — # uncomment and edit one to enable it — but a few integrations are kept as active @@ -70,12 +73,16 @@ proxy_secret = "change-me-proxy-secret" # ----------------------------------------------------------------------------- -# REQUIRED — Edge Cookie (EC) identity +# OPTIONAL — Edge Cookie (EC) identity # ----------------------------------------------------------------------------- [ec] -# Secret used to derive EC identifiers. Must be >= 32 chars and non-placeholder -# in production (deploy validation rejects known placeholders). -passphrase = "trusted-server-placeholder-secret" +# Edge Cookie identity is OFF by default: with no provider selected, Trusted +# Server runs statelessly and generates no Edge Cookie. Activate one by +# uncommenting the selector AND its [ec.providers.] block together (a +# block with no selector is rejected at startup), or set the selector with the +# TRUSTED_SERVER__ec__provider environment variable. The built-in hmac provider +# is host-neutral; a vendor provider needs its own cargo feature. +# provider = "hmac" # KV store that persists EC identity state. This is the physical store name # bound per adapter (e.g. `ec_identity_store` in fastly.toml); edgezero.toml's # logical KV id is `trusted_server_kv`. @@ -86,6 +93,13 @@ pull_sync_concurrency = 3 # cluster_trust_threshold = 10 # entries with cluster_size <= this are individual users # cluster_recheck_secs = 3600 # re-evaluate cluster_size after this many seconds +# Built-in HMAC provider block. Uncomment it together with the +# `provider = "hmac"` selector above. The secret used to derive EC identifiers +# must be >= 32 chars and non-placeholder in production (deploy validation +# rejects known placeholders). +# [ec.providers.hmac] +# passphrase = "replace-with-32-plus-byte-random-secret" + # Optional identity partners (SSP/DSP/identity vendors). Each needs a real, # non-placeholder api_token (>= 32 bytes) at deploy. Configure real partners via # private config, not this template. From ea518121478f6a79da939b9ab1f1a10c0c47ef68 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 27 Aug 2026 16:06:40 +0100 Subject: [PATCH 02/36] Accept the provider-code envelope on the partner-facing identifier paths Since the provider-code envelope, the mint path issues identifiers as hmac~{64hex}.{6alnum}, and that is the value identify hands to partners. Pull sync, batch sync and the admin lookup still validated the bare shape through is_valid_ec_id, so pull sync skipped every freshly minted identifier, batch sync answered invalid_ec_id for the value partners were given, and the admin lookup answered 400. CI stayed green because the lifecycle scenario seeds a bare cookie. is_valid_ec_id now accepts the hmac envelope as well as the legacy bare form and rejects any other provider's code, and normalize_ec_id_for_kv keeps the envelope so the key matches the one written at mint. Tests cover the validator, the normalizer and each of the three call sites with a coded identifier. --- crates/trusted-server-core/src/ec/admin.rs | 13 ++- .../trusted-server-core/src/ec/batch_sync.rs | 17 ++++ .../trusted-server-core/src/ec/generation.rs | 92 +++++++++++++++++-- crates/trusted-server-core/src/ec/mod.rs | 2 +- .../trusted-server-core/src/ec/pull_sync.rs | 19 ++++ 5 files changed, 131 insertions(+), 12 deletions(-) diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index 6219af7a9..6cf4b3921 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -361,7 +361,7 @@ fn requested_ec_id(req: &Request) -> Result; const ALPHANUMERIC_CHARSET: &[u8] = @@ -139,12 +144,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. +/// +/// A minted identifier 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 mint. 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 => (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. @@ -158,17 +184,30 @@ 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 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. /// -/// 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. +/// A minted identifier carries the provider-code envelope, `hmac~` before +/// the bare value, and that is the form the partner-facing paths (pull sync, +/// batch sync, the admin lookup) receive, so both the enveloped and the +/// legacy bare form are accepted. An identifier under any other provider's +/// code is not an HMAC identifier and is rejected here: those paths accept +/// only the built-in provider's identifiers today. #[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 => bare, + (Some(_), _) => return false, + (None, bare) => bare, + }; + let mut parts = bare.split('.'); let Some(hmac_part) = parts.next() else { return false; }; @@ -371,4 +410,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 minted 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/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 6bf07625f..c9417a9fc 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -858,7 +858,7 @@ mod tests { 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. + // (no dot, mixed case), the exact value the built-in check would drop. const OPAQUE_ID: &str = "AbC123opaqueEnvelopeValueXYZ"; const CODED_ID: &str = "t0op~AbC123opaqueEnvelopeValueXYZ"; diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 546605f8e..1caa498c0 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -711,4 +711,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 mint 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 minted" + ); + } } From 1f4a70b64317f31b9c386e708c249d49df369140 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 27 Aug 2026 16:29:20 +0100 Subject: [PATCH 03/36] Rename the legacy passphrase migration so CodeQL stops tainting Settings CodeQL's cleartext-logging query treats a call whose name contains "passphrase" as a sensitive source, and because the method mutates the Settings it belongs to, every later log line that prints anything from Settings (store names, timeouts, header names) is reported as writing a secret to a log. The passphrase itself is a Redacted and none of the flagged lines prints it. The method now describes what it does, migrate_legacy_ec_layout, and its behavior is unchanged. --- crates/trusted-server-core/src/settings.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index a650a6622..851690bc8 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -492,7 +492,7 @@ pub struct Ec { /// Deprecated location of the HMAC passphrase, read so a configuration /// written for the previous release still starts. /// - /// [`migrate_legacy_passphrase`](Self::migrate_legacy_passphrase) maps it + /// [`migrate_legacy_ec_layout`](Self::migrate_legacy_ec_layout) maps it /// to `provider = "hmac"` with the passphrase in the `[ec.providers.hmac]` /// block and logs a deprecation warning, so a fleet can move configuration /// and binaries independently. A configuration carrying both the old and @@ -690,7 +690,7 @@ impl Ec { /// /// Returns [`TrustedServerError::Configuration`] when both the deprecated /// key and any part of the provider configuration are present. - pub fn migrate_legacy_passphrase(&mut self) -> Result<(), Report> { + pub fn migrate_legacy_ec_layout(&mut self) -> Result<(), Report> { let Some(passphrase) = self.passphrase.take() else { return Ok(()); }; @@ -3131,7 +3131,7 @@ impl Settings { }) })?; - settings.ec.migrate_legacy_passphrase()?; + settings.ec.migrate_legacy_ec_layout()?; settings.ec.validate_provider_selection()?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -5035,7 +5035,7 @@ mod tests { passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), ..Ec::default() }; - ec.migrate_legacy_passphrase() + ec.migrate_legacy_ec_layout() .expect("should migrate the deprecated form"); assert_eq!( ec.provider.as_deref(), @@ -5143,7 +5143,7 @@ mod tests { ..Ec::default() }; let err = ec - .migrate_legacy_passphrase() + .migrate_legacy_ec_layout() .expect_err("both forms present should be rejected"); assert!( matches!( From f0ca12a774cf5021157a6641ec94c0b234ee68d2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:02:37 +0100 Subject: [PATCH 04/36] Stop serving without identity when a selected provider is unavailable A reviewer raised a P1 against the pluggable Edge Cookie provider work: three of the four adapters broke the provider contract that an unavailable required service or an uninjected provider stops the request. The Axum, Cloudflare and Spin adapters each read the Edge Cookie context with `EcContext::read_from_request_with_geo(...).unwrap_or_else(...)`, logged a warning and continued with `EcContext::default()`. A deployment whose selected provider could not be built therefore came up and served every request with no identity, silently. The Fastly adapter already kept the report and answered with an error response. `build_ec_context` on the three adapters now returns `Result>` and every call site propagates it to that adapter's own `http_error`, the same helper Fastly uses, so all four answer with the same status and shape. The design this implements has the composition root check a selected provider's needs once at startup rather than per request, so `ensure_provider_available` was added to `ec/provider.rs` and is called from `build_state_with_settings` on all four adapters (Fastly included, so the rule is uniform). Building a provider reads no request data, so a selection an adapter can never supply now fails when application state is built, and the three adapters answer every route from their existing `startup_error_router` instead of coming up. Statelessness, meaning no `[ec] provider` selector or the explicit `"none"`, still passes and still serves. The widening question was checked rather than assumed. `read_from_request_with_geo` can only fail from two places: the provider build, and a `Cookie` header that is not valid UTF-8. A malformed cookie value is dropped with a warning by `request_ec_id_if_allowed`, consent parsing returns a value rather than a `Result`, and the geo lookup is already swallowed by the adapter before the call, so no ordinary parse problem reaches the error path and none is turned into a failed request. Tests: each of the three adapters gains a route test proving an uninjected provider fails at startup, and an in-crate test proving `build_ec_context` returns the error rather than a default context. Core gains a test that the startup check rejects an uninjected provider and still allows statelessness both ways. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:317 (P1) --- crates/trusted-server-adapter-axum/src/app.rs | 122 ++++++++++++++--- .../tests/routes.rs | 50 +++++++ .../src/app.rs | 115 +++++++++++++--- .../tests/routes.rs | 50 +++++++ .../trusted-server-adapter-fastly/src/app.rs | 15 +++ crates/trusted-server-adapter-spin/src/app.rs | 125 +++++++++++++++--- .../tests/routes.rs | 50 +++++++ crates/trusted-server-core/src/ec/provider.rs | 50 +++++++ 8 files changed, 529 insertions(+), 48 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 9a371f805..f3c1403ba 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,18 @@ 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 initialise. 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. + ensure_provider_available(&settings.ec, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -153,13 +161,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 +189,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 +235,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 +471,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 +490,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 +657,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/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 5de96be92..cd96dfdcf 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -832,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..2c20d4470 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::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}; @@ -108,11 +109,18 @@ fn settings_from_cloudflare_config_json() -> Result Result, Report> { + // Composition root: reject a provider selection this adapter can never + // supply, once, before any request is served. This adapter 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. + ensure_provider_available(&settings.ec, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -134,12 +142,25 @@ fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { /// 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 +169,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() - }) } // --------------------------------------------------------------------------- @@ -418,7 +435,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 +546,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 +610,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 +648,65 @@ 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()); + let services = build_per_request_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/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 93b0f9db9..b300e5b37 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -681,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 a2d743868..ac11fbf12 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -112,6 +112,7 @@ 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::ensure_provider_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::is_navigation_request; @@ -177,11 +178,25 @@ pub(crate) fn load_settings_from_config_store() -> Result Result, Report> { warn_if_certificate_check_disabled(&settings); + // Composition root: reject a provider selection this adapter can never + // supply, once, before any request is served. This adapter 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. + ensure_provider_available(&settings.ec, None)?; + let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index f24b5b717..294150959 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::ensure_provider_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -66,11 +67,18 @@ 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 initialise. 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. This adapter 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. + ensure_provider_available(&settings.ec, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -337,13 +345,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 +373,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 { @@ -567,8 +584,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, @@ -598,7 +620,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(), @@ -721,7 +749,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 +888,65 @@ fn build_router(state: &Arc) -> RouterService { #[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()); + 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/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 2389ccebc..4b315ee14 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -976,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/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index ec6eeef3c..b295921d4 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -327,6 +327,29 @@ pub fn build_provider( Ok(provider) } +/// 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_provider(ec, injected)?; + Ok(()) +} + /// Adapts an injected, shared [`EdgeCookieProvider`] to the owned `Box` that /// [`build_provider`] returns. /// @@ -514,4 +537,31 @@ mod tests { "the error should name the selected provider, got: {err}" ); } + + #[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("acme".to_owned()), + ..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("none".to_owned()), + ..Ec::default() + }; + ensure_provider_available(&explicit_none, None) + .expect("should allow the explicit `none` selection"); + } } From b2bb9446021720b0e142db87ad84f88796af4585 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:16:20 +0100 Subject: [PATCH 05/36] Hold the deprecated EC passphrase to the same rules as the new block `Settings::finalize_deserialized` runs derive validation before `Ec::migrate_legacy_ec_layout`, and the deprecated `[ec] passphrase` field carries no `#[validate]` attribute of its own, so the advertised 32-byte minimum was only enforced on the new `[ec.providers.hmac]` location. A configuration still on the old form could start with `passphrase = "short"`, or with an empty value, and mint identifiers from keying material the new location rejects. The migration now calls `Ec::validate_passphrase` on the value it is about to move, before it logs the deprecation warning and writes the `[ec.providers.hmac]` block, and reports a configuration error naming the minimum length and the new location. Tests: `a_legacy_passphrase_is_held_to_the_passphrase_rules` drives `Settings::from_toml` with the `[ec]` section rewritten to the deprecated form and proves a short value and an empty value are both rejected, and that a passphrase of adequate length still migrates to `provider = "hmac"` with the passphrase in the hmac block. Removing the new check makes that test fail, so it tests the fix rather than the surrounding code. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:658 (wrench) --- crates/trusted-server-core/src/settings.rs | 80 +++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 851690bc8..1fa5f79dc 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -686,10 +686,17 @@ impl Ec { /// new location. A configuration carrying both forms is rejected so a /// half-edited file fails loudly instead of one form silently winning. /// + /// The deprecated key is held to the same passphrase rules as the new + /// `[ec.providers.hmac]` block. Derive validation runs before this + /// migration and the deprecated field carries no `#[validate]` attribute of + /// its own, so without the check here a short or empty passphrase in the old + /// location would start a deployment that the new location rejects. + /// /// # Errors /// /// Returns [`TrustedServerError::Configuration`] when both the deprecated - /// key and any part of the provider configuration are present. + /// key and any part of the provider configuration are present, or when the + /// deprecated passphrase fails [`Self::validate_passphrase`]. pub fn migrate_legacy_ec_layout(&mut self) -> Result<(), Report> { let Some(passphrase) = self.passphrase.take() else { return Ok(()); @@ -702,6 +709,15 @@ impl Ec { .to_owned(), })); } + Self::validate_passphrase(&passphrase).map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!( + "[ec] passphrase (deprecated) is invalid ({err}): use a random secret \ + of at least {} bytes, placed in [ec.providers.hmac]", + Self::MIN_PASSPHRASE_LENGTH, + ), + }) + })?; log::warn!( "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ set [ec] provider = \"hmac\"" @@ -5058,6 +5074,68 @@ mod tests { ); } + /// The crate test configuration with its `[ec]` section rewritten to the + /// deprecated single-passphrase form. + fn legacy_ec_settings_str(passphrase: &str) -> String { + let base = crate_test_settings_str(); + let (before, rest) = base + .split_once("[ec]") + .expect("should find the [ec] section in the test settings"); + let (_, after) = rest + .split_once("[request_signing]") + .expect("should find the [request_signing] section in the test settings"); + let legacy = + format!("{before}[ec]\npassphrase = \"{passphrase}\"\n\n[request_signing]{after}"); + assert!( + !legacy.contains("[ec.providers.hmac]"), + "the legacy configuration should carry no provider block" + ); + legacy + } + + #[test] + fn a_legacy_passphrase_is_held_to_the_passphrase_rules() { + // Derive validation runs before the migration and the deprecated field + // carries no `#[validate]` attribute, so the migration itself has to + // apply the passphrase rules. Without that, a value the new + // `[ec.providers.hmac]` block rejects would still start a deployment + // from the old location. + let short = Settings::from_toml(&legacy_ec_settings_str("short")) + .expect_err("a short legacy passphrase should be rejected"); + assert!( + format!("{short:?}").contains("passphrase (deprecated) is invalid"), + "should name the deprecated passphrase as the fault: {short:?}" + ); + + let empty = Settings::from_toml(&legacy_ec_settings_str("")) + .expect_err("an empty legacy passphrase should be rejected"); + assert!( + format!("{empty:?}").contains("passphrase (deprecated) is invalid"), + "should name the deprecated passphrase as the fault: {empty:?}" + ); + + let settings = + Settings::from_toml(&legacy_ec_settings_str("test-secret-key-32-bytes-minimum")) + .expect("a legacy passphrase of adequate length should still start"); + assert_eq!( + settings.ec.provider.as_deref(), + Some("hmac"), + "an adequate legacy passphrase should still select the hmac provider" + ); + assert_eq!( + settings + .ec + .providers + .hmac + .as_ref() + .expect("should configure the hmac block") + .passphrase + .expose(), + "test-secret-key-32-bytes-minimum", + "an adequate legacy passphrase should still move into the hmac block" + ); + } + #[test] fn cache_asset_rule_validation_rejects_invalid_config() { let duplicate_ids = format!( From 472218b3e1a4d0fbbfe7285b4eac9daabb9c01cd Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:20:07 +0100 Subject: [PATCH 06/36] Reject unknown keys in the built-in HMAC provider block The provider spec (section 6) says `deny_unknown_fields` is set on both built-in provider config structs, but `HmacProviderConfig` carried no such attribute, so `[ec.providers.hmac] typo_key = "x"` was accepted silently. An operator who mistypes a key gets a deployment that starts and quietly uses the default for the setting they meant to change. `HmacProviderConfig` now sets `#[serde(deny_unknown_fields)]`, matching `Ec` itself and the rest of the settings tree. The struct is a plain field of `EcProviders` rather than a flattened one, so the attribute does not collide with the `#[serde(flatten)]` vendor map alongside it. Tests: `an_unknown_key_in_the_hmac_provider_block_is_rejected` adds an unknown key to the block in the crate test configuration and proves `Settings::from_toml` fails and names the key. Removing the attribute makes that test fail. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/settings.rs:726 (wrench) --- crates/trusted-server-core/src/settings.rs | 26 +++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 1fa5f79dc..0d5c4792e 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -790,8 +790,11 @@ impl EcProviders { /// Configuration for the built-in HMAC Edge Cookie provider. /// -/// Mapped from the `[ec.providers.hmac]` TOML block. +/// Mapped from the `[ec.providers.hmac]` TOML block. Unknown keys are +/// rejected, so a mistyped setting fails at startup instead of being accepted +/// silently and leaving the intended setting at its default. #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] pub struct HmacProviderConfig { /// Publisher passphrase used as the HMAC key for EC generation. #[validate(custom(function = Ec::validate_passphrase))] @@ -5233,6 +5236,27 @@ mod tests { ); } + #[test] + fn an_unknown_key_in_the_hmac_provider_block_is_rejected() { + // A mistyped key in a provider block used to be dropped silently, which + // leaves the setting the operator meant to change at its default. + let toml_str = crate_test_settings_str().replace( + "passphrase = \"test-secret-key-32-bytes-minimum\"", + "passphrase = \"test-secret-key-32-bytes-minimum\"\n typo_key = \"x\"", + ); + assert!( + toml_str.contains("typo_key"), + "the test configuration should carry the unknown key" + ); + + let err = Settings::from_toml(&toml_str) + .expect_err("an unknown key in [ec.providers.hmac] should be rejected"); + assert!( + format!("{err:?}").contains("typo_key"), + "should name the unknown key: {err:?}" + ); + } + #[test] fn provider_none_is_explicit_stateless() { let ec = Ec { From c4d2f1d7a1af035ac7563f6fd4ffa5522e2f711f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:23:03 +0100 Subject: [PATCH 07/36] Stop rather than run stateless when the hmac block is missing `build_provider`'s `"hmac"` arm mapped over `ec.providers.hmac`, so a deployment that selected `provider = "hmac"` with no `[ec.providers.hmac]` block got `Ok(None)` and ran stateless under a selector that says it has an identity provider. Every other unbuildable selection in the same match already errors. The arm now returns `TrustedServerError::EdgeCookie` naming the missing block, which the startup check `ensure_provider_available` turns into a failed application state on every adapter. `Ec::validate_provider_selection` rejects that pair before settings reach the composition root, so nothing routes through the new arm today. It is the drift guard for the case where the two checks stop agreeing, which is exactly the shape of the defect being fixed, so it is worth keeping rather than leaving the silent branch in place. Tests: `selecting_hmac_without_its_block_fails_loudly` builds the `Ec` programmatically, bypassing settings validation to reach the seam, and proves the error names the missing block. The doc comment's `# Errors` section is corrected in the same commit, since it still claimed no built-in construction can fail. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:304 (refactor) --- crates/trusted-server-core/src/ec/provider.rs | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index b295921d4..452f20890 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -283,10 +283,11 @@ impl EdgeCookieProvider for HmacProvider { /// /// # Errors /// -/// None of the built-in constructions fail today. The `Result` is the seam for -/// a provider whose construction can fail (for example one requiring a host -/// service the deployment does not supply), so such a misconfiguration fails -/// loudly rather than minting a degraded identifier. +/// Returns [`TrustedServerError::EdgeCookie`] when the selected provider cannot +/// be built: `"hmac"` without an `[ec.providers.hmac]` block, or a vendor key +/// 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>, @@ -297,11 +298,20 @@ pub fn build_provider( let provider: Option> = match key { // Explicit statelessness: the same meaning as omitting the selector. "none" => None, - "hmac" => ec - .providers - .hmac - .as_ref() - .map(|config| Box::new(HmacProvider::new(config.passphrase.clone())) as _), + // Settings validation rejects `hmac` with no block before this runs, so + // reaching here means the two checks have drifted apart. Stopping is + // the only safe answer: returning `Ok(None)` would run the deployment + // stateless under a selector that says it has an identity provider. + "hmac" => { + 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(), + }) + })?; + Some(Box::new(HmacProvider::new(config.passphrase.clone())) as _) + } // Any other key names a vendor or host provider the adapter injects // through [`RuntimeServices`](crate::platform::RuntimeServices), the same // seam the device and geo providers use, so core never names a vendor. @@ -538,6 +548,25 @@ mod tests { ); } + #[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("hmac".to_owned()), + ..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_startup_check_rejects_an_uninjected_provider_and_allows_statelessness() { // A selection the adapter cannot supply is knowable without a request, From 93cd1e85dd3bc886c30be2611159813d49b469ec Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:26:03 +0100 Subject: [PATCH 08/36] Restore the missing line continuation in the mint rejection message The error raised when a provider mints an identifier outside the identifier bounds was written across two source lines without the trailing backslash that joins them, so the 22 spaces of source indentation became part of the literal and the logged message read "...bytes, or outside the cookie-safe alphabet". The continuation is restored, so the message reads as one sentence. The whole of ec/mod.rs was scanned for the same fault, matching every string literal and stripping real continuations before looking for runs of more than one space or a newline inside a literal. This message was the only one. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:444 (nitpick) --- crates/trusted-server-core/src/ec/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index c9417a9fc..c29de2f94 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -441,7 +441,8 @@ impl EcContext { 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", + "Provider `{}` produced an identifier that is empty, over {} bytes, or \ + outside the cookie-safe alphabet", ec_provider.id(), cookies::MAX_EC_ID_LEN, ), From a265c968e279761c9a7c893e72223fe8d2cf56a1 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:29:30 +0100 Subject: [PATCH 09/36] Give EdgeCookieProvider its own doc comment back The paragraph written for the `EdgeCookieProvider` trait sat at the top of `ProviderCode`'s doc block, so rustdoc rendered it as part of that struct's documentation and the trait itself had no doc comment at all. A vendor implementer opening the trait saw nothing, and a reader of `ProviderCode` saw two subjects run together. The paragraph moves onto the trait and `ProviderCode` keeps only the registry text that belongs to it. The moved sentence was also stale: it said a provider returns `Ok(None)` from `generate`, but `generate` returns a `GeneratedEdgeCookie` and signals "no identifier this request" through its `id` field. The sentence now describes the actual return, with an intra-doc link to the field. `cargo doc --no-deps` reports no warning against either item. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:177 (nitpick) --- crates/trusted-server-core/src/ec/provider.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 452f20890..dfca75c7b 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -55,15 +55,6 @@ pub struct GeneratedEdgeCookie { pub response_headers: Vec<(http::HeaderName, http::HeaderValue)>, } -/// A strategy for deriving an Edge Cookie identifier. -/// -/// Implementations are selected by configuration. A provider derives the -/// identifier at the edge in [`generate`](Self::generate), and the page -/// response sets the `ts-ec` cookie. -/// -/// A provider returns `Ok(None)` from [`generate`](Self::generate) when it -/// cannot derive an identifier at the edge, so the request proceeds without an -/// Edge Cookie rather than failing. /// The registered short code that namespaces one Edge Cookie provider's /// identifiers. /// @@ -174,6 +165,15 @@ pub fn provider_kv_key(provider: &dyn EdgeCookieProvider, full: &str) -> String } } +/// 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. From 004581c0b2b37577627ec3fee8c342fbf7d6c5e3 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:33:09 +0100 Subject: [PATCH 10/36] Delete the unused ec::get_ec_id helper `ec::get_ec_id` had no callers anywhere in the workspace, and this branch loosened its filter to accept any well-formed `{code}~` value with no ownership check against the selected provider. A future caller picking it up would adopt another provider's identifiers, which `EcContext` deliberately treats as absent. The no-callers claim was checked across every crate in the workspace (the four adapters, the CLI, core, the integration tests, openrtb) plus benches, tests and docs. The only matches are for a different, crate-private `edge_cookie::get_ec_id`, which reads the `x-ts-ec` header as well as the cookie and is what `proxy.rs` and the testlight integration call. Deleted rather than realigned, for two reasons. The workspace sets `publish = false`, so `trusted-server-core` is not distributed and nothing outside this repository depends on the symbol. And aligning the filter would mean calling `provider_owns_id`, which needs a `&dyn EdgeCookieProvider` that a function taking only `&Request` cannot obtain, so it would have meant changing the signature of a function with no callers. `EcContext::read_from_request` already performs the provider-aware read that production uses. `parse_ec_from_request`, `is_valid_ec_id` and `log_id` all keep other callers in the module, so nothing else becomes dead. The core README line that advertised the helper is removed in the same commit. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:137 (nitpick) --- crates/trusted-server-core/README.md | 2 +- crates/trusted-server-core/src/ec/mod.rs | 26 ------------------------ 2 files changed, 1 insertion(+), 27 deletions(-) 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/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index c29de2f94..860dfe820 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -120,32 +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)?; - // Accept the coded form (any provider's `{code}~value` within the global - // identifier bounds) and the legacy bare HMAC form. Provider-aware - // ownership lives in `EcContext`; this helper only reads the string. - let ec_id = parsed - .cookie_ec - .filter(|v| match provider::split_provider_code(v) { - (Some(_), value) => !value.is_empty() && cookies::ec_id_has_only_allowed_chars(v), - (None, value) => is_valid_ec_id(value), - }); - if let Some(ref id) = ec_id { - log::trace!("Existing EC ID found: {}", log_id(id)); - } - Ok(ec_id) -} - /// Captures the EC state for a single request lifecycle. /// /// Created via [`read_from_request`](Self::read_from_request) during From d6041f0921ff8642b5207bb24b42560d72bd0192 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:37:21 +0100 Subject: [PATCH 11/36] Correct the provider module docs about when evidence arrives The `ec/provider.rs` module doc said a provider's constructor takes the services it needs, naming `RequestInfo` as the example, and its opening sentence was garbled where two half-sentences had been spliced together. `RequestInfo` is not a constructor argument. It is borrowed per call as the `request_info` parameter of `EdgeCookieProvider::generate`, so the first thing a vendor implementer read contradicted the trait they were about to implement. `evidence.rs` carried the same claim in its own words, that a constructor takes services as `Arc` supplied per request. Nothing in the workspace passes `RequestInfo` that way. Every use site is a `&dyn RequestInfo` argument. Both module docs now describe the real shape, which is construction once at startup from configuration or adapter injection, then borrowed request evidence on every call with nothing retained. The `evidence.rs` title changes to match, and its pointer to the borrowed view `BorrowedRequestInfo` is named alongside `OwnedRequestInfo`. Documentation only, no behavior change. `cargo doc --no-deps` reports no warning against either module. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:4 (nitpick) --- crates/trusted-server-core/src/ec/provider.rs | 27 ++++++++++++------- crates/trusted-server-core/src/evidence.rs | 20 +++++++------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index dfca75c7b..29daa8001 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -1,15 +1,24 @@ //! Edge Cookie identity providers. //! -//! An [`EdgeCookieProvider`] derives an Edge Cookie identifier. Providers are -//! wired by dependency injection: a provider's constructor takes the services it -//! needs (for example [`RequestInfo`] for the client IP) -//! (the adapter, through [`build_provider`]) supplies instances per request. A -//! provider that needs a service the host does not supply cannot be built, so -//! the request stops rather than silently degrading. +//! 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 happens once, while +//! application state is built, and reads no request data, so a selection this +//! deployment cannot satisfy fails at startup rather than leaving it running +//! without an identity. //! -//! The provider is selected by configuration, with no default. [`HmacProvider`] -//! is the built-in server-side implementation that derives the identifier from -//! the client IP using HMAC, the behavior Trusted Server has always shipped. +//! 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, so no per-request snapshot is stored or cloned. +//! +//! [`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; diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index 78e0d21ca..82eb408ea 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -1,14 +1,16 @@ -//! Service interfaces injected into providers. +//! Request evidence passed to providers. //! -//! Trusted Server wires providers by dependency injection. A provider's -//! constructor takes the services it needs as `Arc`, and the adapter -//! (the composition root) supplies instances per request. A provider that needs -//! a service the host does not supply cannot be built, so the request stops -//! rather than silently degrading. +//! A provider is constructed once, from its own configuration block or by the +//! adapter that injects it, and is handed the current request's evidence as a +//! borrowed `&dyn` view on every call, for example the `request_info` argument +//! of [`generate`](crate::ec::provider::EdgeCookieProvider::generate). Nothing +//! per-request is stored on the provider, so the same provider serves every +//! request. //! -//! These traits are the service interfaces. Request-scoped data outlives the -//! live request only when snapshotted, so an implementation owns its data where -//! needed ([`OwnedRequestInfo`] is the built-in owned snapshot). +//! These traits are those views. Request-scoped data outlives the live request +//! only when snapshotted, so an implementation owns its data where needed. +//! [`BorrowedRequestInfo`] is the borrowed view core builds on the request +//! path, and [`OwnedRequestInfo`] is the built-in owned snapshot. use http::HeaderMap; From 885e3ce703846afefa913afb03f0e19066ef9736 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 00:53:26 +0100 Subject: [PATCH 12/36] Replace the scattered EC provider key strings with a typed selector The keys `"hmac"` and `"none"` were spelled as bare string literals at four places: `Ec::validate_provider_selection`, `build_provider`, `provider_owns_id`'s `provider.id() == "hmac"` check, and a private `HMAC_PROVIDER_CODE` in `ec/generation.rs`. Nothing tied them together, so a fifth built-in provider would add a fifth spelling and a typo in any one of them would compile. `EcProviderSelection { None, Hmac, Vendor(String) }` now holds the vocabulary in `ec/provider.rs`, with `NONE_KEY` and `HMAC_KEY` as the only places those two words are written. Vendor keys are open-ended, so the catch-all `Vendor` variant takes any other key and `#[serde(from = "String", into = "String")]` gives the enum an infallible conversion in each direction rather than a hand-written visitor. `HMAC_PROVIDER_CODE` moves next to it as a `ProviderCode` const, built from `HMAC_KEY`, and `generation.rs` uses that instead of its own copy. `HmacProvider::id` and `HmacProvider::code` return the same two constants. `Ec::provider` becomes `Option`, so the two validation paths and `build_provider` match on variants rather than comparing strings, and `Option` still distinguishes an absent selector from an explicit `"none"` exactly as before. The configuration surface is unchanged. The selector reads and writes the same string, so an existing `trusted-server.toml` parses to the same choice and a config push writes the same key back. Tests: `the_selector_round_trips_through_serialization` parses `none`, `hmac` and an arbitrary vendor key from TOML, checks each maps to its variant, and checks each serializes back to the same string. `each_selection_builds_what_its_string_key_built_before` proves the three selections still build what they built before, which is nothing for `none`, the built-in provider with the built-in code for `hmac`, and the adapter-injected provider of that id for a vendor key. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/provider.rs (refactor) --- .../trusted-server-core/src/ec/generation.rs | 10 +- crates/trusted-server-core/src/ec/mod.rs | 12 +- crates/trusted-server-core/src/ec/provider.rs | 223 ++++++++++++++++-- crates/trusted-server-core/src/settings.rs | 70 +++--- 4 files changed, 256 insertions(+), 59 deletions(-) diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs index 713e50474..99198c69b 100644 --- a/crates/trusted-server-core/src/ec/generation.rs +++ b/crates/trusted-server-core/src/ec/generation.rs @@ -10,13 +10,9 @@ use hmac::{Hmac, Mac}; use rand::Rng; use sha2::Sha256; -use crate::ec::provider::{PROVIDER_CODE_SEPARATOR, split_provider_code}; +use crate::ec::provider::{HMAC_PROVIDER_CODE, PROVIDER_CODE_SEPARATOR, split_provider_code}; use crate::error::TrustedServerError; -/// The registry code of the built-in HMAC provider, whose identifier shape -/// this module defines. -const HMAC_PROVIDER_CODE: &str = "hmac"; - type HmacSha256 = Hmac; const ALPHANUMERIC_CHARSET: &[u8] = @@ -156,7 +152,7 @@ pub fn ec_hash(ec_id: &str) -> &str { #[must_use] pub fn normalize_ec_id_for_kv(ec_id: &str) -> String { let (code, bare) = match split_provider_code(ec_id) { - (Some(code), bare) if code == HMAC_PROVIDER_CODE => (Some(code), bare), + (Some(code), bare) if code == HMAC_PROVIDER_CODE.as_str() => (Some(code), bare), (Some(_), _) => return ec_id.to_owned(), (None, bare) => (None, bare), }; @@ -203,7 +199,7 @@ pub fn is_valid_ec_hash(value: &str) -> bool { #[must_use] pub fn is_valid_ec_id(value: &str) -> bool { let bare = match split_provider_code(value) { - (Some(code), bare) if code == HMAC_PROVIDER_CODE => bare, + (Some(code), bare) if code == HMAC_PROVIDER_CODE.as_str() => bare, (Some(_), _) => return false, (None, bare) => bare, }; diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 860dfe820..c9d9ab6d1 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -703,7 +703,7 @@ pub(crate) fn current_timestamp() -> u64 { #[cfg(test)] mod tests { use super::*; - use crate::ec::provider::ProviderCode; + 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; @@ -838,7 +838,7 @@ mod tests { const CODED_ID: &str = "t0op~AbC123opaqueEnvelopeValueXYZ"; let mut settings = create_test_settings(); - settings.ec.provider = Some("opaque".to_owned()); + settings.ec.provider = Some(EcProviderSelection::from("opaque")); let cookie = format!("ts-ec={CODED_ID}"); let req = create_test_request(&[("cookie", &cookie)]); @@ -924,7 +924,7 @@ mod tests { let provider = Arc::new(EvidenceCapturingProvider::default()); let mut settings = create_test_settings(); - settings.ec.provider = Some("evidence".to_owned()); + 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. @@ -1001,7 +1001,7 @@ mod tests { const OPAQUE: &str = "t0so~Opaque_EC_Value_MixedCase_123"; let mut settings = create_test_settings(); - settings.ec.provider = Some("server-opaque".to_owned()); + 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"); @@ -1071,7 +1071,7 @@ mod tests { use crate::platform::test_support::noop_services_with_ec_provider; let mut settings = create_test_settings(); - settings.ec.provider = Some("illegal".to_owned()); + 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(); @@ -1131,7 +1131,7 @@ mod tests { use crate::platform::test_support::noop_services_with_ec_provider; let mut settings = create_test_settings(); - settings.ec.provider = Some("canonical".to_owned()); + 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(&[]); diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 29daa8001..b7b5a6931 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -23,6 +23,7 @@ use std::sync::Arc; use error_stack::Report; +use serde::{Deserialize, Serialize}; use crate::consent::ConsentContext; use crate::error::TrustedServerError; @@ -32,6 +33,92 @@ use crate::settings::Ec; 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. Vendor keys are +/// open-ended (a vendor crate names its own), so any key that is not a +/// built-in becomes [`Vendor`](Self::Vendor) rather than a parse failure, and +/// whether the deployment can actually supply it is decided by +/// [`build_provider`]. +/// +/// This is the one place the provider keys are 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 minted and no provider block may be + /// configured. + None, + + /// The built-in HMAC provider, spelled `"hmac"`, configured by + /// `[ec.providers.hmac]`. + Hmac, + + /// A vendor or host provider the adapter injects, named by its own key and + /// configured by the matching `[ec.providers.]` block. + Vendor(String), +} + +impl EcProviderSelection { + /// The configuration spelling of explicit statelessness. + pub const NONE_KEY: &'static str = "none"; + + /// The configuration spelling of the built-in HMAC provider, which is also + /// [`HmacProvider::id`]'s return value and [`HMAC_PROVIDER_CODE`]'s text. + pub const HMAC_KEY: &'static str = "hmac"; + + /// The configuration key this selection is written as. + #[must_use] + pub fn key(&self) -> &str { + match self { + Self::None => Self::NONE_KEY, + Self::Hmac => Self::HMAC_KEY, + Self::Vendor(key) => key, + } + } +} + +impl From<&str> for EcProviderSelection { + fn from(key: &str) -> Self { + match key { + EcProviderSelection::NONE_KEY => Self::None, + EcProviderSelection::HMAC_KEY => Self::Hmac, + other => Self::Vendor(other.to_owned()), + } + } +} + +impl From for EcProviderSelection { + fn from(key: String) -> Self { + match key.as_str() { + EcProviderSelection::NONE_KEY => Self::None, + EcProviderSelection::HMAC_KEY => Self::Hmac, + _ => Self::Vendor(key), + } + } +} + +impl From for String { + fn from(selection: EcProviderSelection) -> Self { + match selection { + EcProviderSelection::None => EcProviderSelection::NONE_KEY.to_owned(), + EcProviderSelection::Hmac => EcProviderSelection::HMAC_KEY.to_owned(), + EcProviderSelection::Vendor(key) => key, + } + } +} + +/// The registry code of the built-in HMAC provider. +/// +/// The same text as [`EcProviderSelection::HMAC_KEY`], but a different role: +/// this is the `{code}~` namespace stamped on every identifier the built-in +/// provider mints, and it is what [`generation`] matches when it decides +/// whether an enveloped identifier is one of its own. +pub const HMAC_PROVIDER_CODE: ProviderCode = ProviderCode::new(EcProviderSelection::HMAC_KEY); + /// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. /// /// Request data (client IP, User-Agent, headers, host signals) reaches a @@ -148,7 +235,9 @@ pub fn split_provider_code(full: &str) -> (Option<&str>, &str) { pub fn provider_owns_id(provider: &dyn EdgeCookieProvider, full: &str) -> bool { match split_provider_code(full) { (Some(code), value) => code == provider.code().as_str() && provider.accepts_id(value), - (None, value) => provider.id() == "hmac" && provider.accepts_id(value), + (None, value) => { + provider.id() == EcProviderSelection::HMAC_KEY && provider.accepts_id(value) + } } } @@ -261,11 +350,11 @@ impl HmacProvider { impl EdgeCookieProvider for HmacProvider { fn id(&self) -> &'static str { - "hmac" + EcProviderSelection::HMAC_KEY } fn code(&self) -> ProviderCode { - ProviderCode::new("hmac") + HMAC_PROVIDER_CODE } fn generate( @@ -301,17 +390,17 @@ pub fn build_provider( ec: &Ec, injected: Option>, ) -> Result>, Report> { - let Some(key) = ec.provider.as_deref() else { + let Some(selection) = ec.provider.as_ref() else { return Ok(None); }; - let provider: Option> = match key { + let provider: Option> = match selection { // Explicit statelessness: the same meaning as omitting the selector. - "none" => None, + EcProviderSelection::None => None, // Settings validation rejects `hmac` with no block before this runs, so // reaching here means the two checks have drifted apart. Stopping is // the only safe answer: returning `Ok(None)` would run the deployment // stateless under a selector that says it has an identity provider. - "hmac" => { + EcProviderSelection::Hmac => { let config = ec.providers.hmac.as_ref().ok_or_else(|| { Report::new(TrustedServerError::EdgeCookie { message: "Edge Cookie provider `hmac` is selected but has no \ @@ -321,21 +410,21 @@ pub fn build_provider( })?; Some(Box::new(HmacProvider::new(config.passphrase.clone())) as _) } - // Any other key names a vendor or host provider the adapter injects + // A vendor key names a vendor or host provider the adapter injects // through [`RuntimeServices`](crate::platform::RuntimeServices), the same // seam the device and geo providers use, so core never names a vendor. // The injected provider is used when its own id matches the selected key, // and its `[ec.providers.]` block is read by the adapter that built // it. A selected key with no matching injected provider is a deployment // error: fail loudly rather than silently running stateless. - other => { + EcProviderSelection::Vendor(key) => { let provider = injected - .filter(|provider| provider.id() == other) + .filter(|provider| provider.id() == key) .map(|provider| Box::new(SharedProvider(provider)) as _); if provider.is_none() { return Err(Report::new(TrustedServerError::EdgeCookie { message: format!( - "Edge Cookie provider `{other}` is selected but this deployment's \ + "Edge Cookie provider `{key}` is selected but this deployment's \ adapter does not provide it" ), })); @@ -408,6 +497,7 @@ impl EdgeCookieProvider for SharedProvider { #[cfg(test)] mod tests { use super::*; + use crate::settings::{EcProviders, HmacProviderConfig}; #[test] fn split_provider_code_separates_coded_and_legacy_forms() { @@ -438,6 +528,109 @@ mod tests { ); } + /// 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 { + ProviderCode::new("t0ac") + } + + fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + } + + #[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), + (EcProviderSelection::HMAC_KEY, EcProviderSelection::Hmac), + ("acme", EcProviderSelection::Vendor("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" + ); + + 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}" + ); + } + } + + #[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::Hmac), + 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(), + EcProviderSelection::HMAC_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::Vendor("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()); @@ -545,7 +738,7 @@ mod tests { #[test] fn a_selected_but_uninjected_vendor_provider_fails_loudly() { let ec = Ec { - provider: Some("acme".to_owned()), + provider: Some(EcProviderSelection::from("acme")), ..Ec::default() }; @@ -564,7 +757,7 @@ mod tests { // 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("hmac".to_owned()), + provider: Some(EcProviderSelection::Hmac), ..Ec::default() }; @@ -581,7 +774,7 @@ mod tests { // 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("acme".to_owned()), + provider: Some(EcProviderSelection::from("acme")), ..Ec::default() }; let err = ensure_provider_available(&selected, None) @@ -596,7 +789,7 @@ mod tests { ensure_provider_available(&Ec::default(), None) .expect("should allow a deployment that selects no provider"); let explicit_none = Ec { - provider: Some("none".to_owned()), + provider: Some(EcProviderSelection::None), ..Ec::default() }; ensure_provider_available(&explicit_none, None) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 0d5c4792e..a39a8ff87 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -20,6 +20,7 @@ use crate::cache_policy::{CachePolicy, CacheVisibility}; use crate::consent_config::ConsentConfig; use crate::constants::INTERNAL_HEADERS; use crate::creative_opportunities::CreativeOpportunitiesConfig; +use crate::ec::provider::EcProviderSelection; use crate::error::TrustedServerError; use crate::host_header::validate_host_header_override_value; use crate::platform::PlatformImageOptimizerRegion; @@ -486,8 +487,12 @@ pub struct Ec { /// explicit `"none"` spells the same choice. Selecting a provider whose /// block is missing is rejected at startup by /// [`validate_provider_selection`](Self::validate_provider_selection). + /// + /// Typed as [`EcProviderSelection`], which reads and writes the same + /// string, so every check that asks which provider is selected matches on + /// one vocabulary rather than comparing string literals. #[serde(default)] - pub provider: Option, + pub provider: Option, /// Deprecated location of the HMAC passphrase, read so a configuration /// written for the previous release still starts. @@ -610,7 +615,7 @@ impl Ec { /// Returns [`TrustedServerError::Configuration`] when the selected provider /// key is unknown or its `[ec.providers.]` block is absent. pub fn validate_provider_selection(&self) -> Result<(), Report> { - let Some(key) = self.provider.as_deref() else { + let Some(selection) = self.provider.as_ref() else { if !self.providers.is_empty() { return Err(Report::new(TrustedServerError::Configuration { message: "[ec.providers.*] blocks are configured but no [ec] provider is \ @@ -622,27 +627,30 @@ impl Ec { return Ok(()); }; - // `"none"` is explicit statelessness: the same meaning as omitting - // the selector, spelled out. It is subject to the same rule that no - // provider blocks may be left configured. - if key == "none" { - if !self.providers.is_empty() { - return Err(Report::new(TrustedServerError::Configuration { - message: "[ec] provider = \"none\" selects stateless operation, but \ - [ec.providers.*] blocks are configured. Remove the blocks, or \ - select the provider they configure" - .to_owned(), - })); + let (key, configured) = match selection { + // `"none"` is explicit statelessness: the same meaning as omitting + // the selector, spelled out. It is subject to the same rule that no + // provider blocks may be left configured. + EcProviderSelection::None => { + if !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider = \"none\" selects stateless operation, but \ + [ec.providers.*] blocks are configured. Remove the blocks, or \ + select the provider they configure" + .to_owned(), + })); + } + return Ok(()); + } + EcProviderSelection::Hmac => { + (EcProviderSelection::HMAC_KEY, self.providers.hmac.is_some()) } - return Ok(()); - } - - let configured = match key { - "hmac" => self.providers.hmac.is_some(), // A vendor or host provider the adapter injects is configured when // its `[ec.providers.]` block is present. The adapter validates // the block's own contents when it builds the provider. - other => self.providers.has_vendor(other), + EcProviderSelection::Vendor(vendor_key) => { + (vendor_key.as_str(), self.providers.has_vendor(vendor_key)) + } }; if !configured { @@ -657,8 +665,8 @@ impl Ec { // block is almost always a mistake (a mistyped selector or a stale // block), and accepting it silently invites configuration drift. let mut unreferenced: Vec = Vec::new(); - if self.providers.hmac.is_some() && key != "hmac" { - unreferenced.push("hmac".to_owned()); + if self.providers.hmac.is_some() && !matches!(selection, EcProviderSelection::Hmac) { + unreferenced.push(EcProviderSelection::HMAC_KEY.to_owned()); } for vendor_key in self.providers.vendor_keys() { if vendor_key != key { @@ -722,7 +730,7 @@ impl Ec { "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ set [ec] provider = \"hmac\"" ); - self.provider = Some("hmac".to_owned()); + self.provider = Some(EcProviderSelection::Hmac); self.providers.hmac = Some(HmacProviderConfig { passphrase }); Ok(()) } @@ -4608,8 +4616,8 @@ mod tests { ); assert_eq!(settings.publisher.origin_host_header_override, None); assert_eq!( - settings.ec.provider.as_deref(), - Some("hmac"), + settings.ec.provider.as_ref(), + Some(&EcProviderSelection::Hmac), "test settings should select the hmac EC provider" ); let Some(hmac) = &settings.ec.providers.hmac else { @@ -5057,8 +5065,8 @@ mod tests { ec.migrate_legacy_ec_layout() .expect("should migrate the deprecated form"); assert_eq!( - ec.provider.as_deref(), - Some("hmac"), + ec.provider.as_ref(), + Some(&EcProviderSelection::Hmac), "the deprecated passphrase should select the hmac provider" ); assert_eq!( @@ -5121,8 +5129,8 @@ mod tests { Settings::from_toml(&legacy_ec_settings_str("test-secret-key-32-bytes-minimum")) .expect("a legacy passphrase of adequate length should still start"); assert_eq!( - settings.ec.provider.as_deref(), - Some("hmac"), + settings.ec.provider.as_ref(), + Some(&EcProviderSelection::Hmac), "an adequate legacy passphrase should still select the hmac provider" ); assert_eq!( @@ -5220,7 +5228,7 @@ mod tests { fn legacy_passphrase_alongside_provider_config_is_rejected() { let mut ec = Ec { passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), - provider: Some("hmac".to_owned()), + provider: Some(EcProviderSelection::Hmac), ..Ec::default() }; let err = ec @@ -5260,7 +5268,7 @@ mod tests { #[test] fn provider_none_is_explicit_stateless() { let ec = Ec { - provider: Some("none".to_owned()), + provider: Some(EcProviderSelection::None), ..Ec::default() }; ec.validate_provider_selection() @@ -5270,7 +5278,7 @@ mod tests { #[test] fn provider_none_with_configured_blocks_is_rejected() { let ec = Ec { - provider: Some("none".to_owned()), + provider: Some(EcProviderSelection::None), providers: EcProviders { hmac: Some(HmacProviderConfig { passphrase: Redacted::new("test-secret-key-32-bytes-minimum".to_owned()), From 69649aa2a57e7bc19afe94e9fbb7b265d1622ad4 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 01:18:29 +0100 Subject: [PATCH 13/36] Reserve core's own response surface against provider effects A provider's response headers were inserted into the outbound response without any check on what they set. A provider could return `Set-Cookie: ts-ec=...`, including on a request where it minted no identifier at all, and so write the managed identity cookie without going through core's identifier validation or its requirement that a minted identifier have an identity-graph row. It could also overwrite an `x-ts-*` header or a framing header. Core now defends by reserving its own namespace rather than banning `Set-Cookie`, because providers legitimately need cookies of their own. `reserved_response_effect` in `ec/provider.rs` classifies one header and rejects three things: a `Set-Cookie` naming a cookie in the `ts-` prefix core manages (`ts-ec`, `ts-eids`, `ts-tester`), a header in the `x-ts-` namespace core emits and strips, and a message framing or hop-by-hop header (RFC 7230 6.1 plus `content-length`, the same set each adapter's `is_hop_by_hop_response_header` uses). Everything else, a provider's own cookie included, passes through unchanged. The cookie name is read from the raw header bytes so a value that is not valid UTF-8 cannot smuggle a managed name past the check. A rejected effect fails the request rather than being dropped with a log. The check sits in `EcContext::generate_with_provider`, the only place provider headers are captured, next to the identifier-bounds check that already fails the request when a provider mints outside the cookie-safe alphabet. Both are the same kind of fault, a provider breaking its contract, and this branch has already decided that identity problems stop the request rather than serving without identity. Finalization cannot fail a request in any case, since it returns no result. Tests cover the classifier directly (managed cookie, reserved header, framing header, a non-UTF-8 `Set-Cookie`, and the allowed cases), and cover both halves through the organic generate path: a provider setting `ts-ec` with no identifier fails the request, and a provider setting its own `acme-evidence` cookie mints normally and has that cookie reach the response alongside core's own `ts-ec`. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/finalize.rs:57 (P2) --- crates/trusted-server-core/src/ec/finalize.rs | 6 +- crates/trusted-server-core/src/ec/mod.rs | 177 +++++++++++++++- crates/trusted-server-core/src/ec/provider.rs | 191 ++++++++++++++++++ 3 files changed, 371 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index d09d8097a..b27b700a3 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -52,7 +52,11 @@ pub fn ec_finalize_response( ) { // 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. + // 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. for (name, value) in ec_context.response_headers() { response.headers_mut().insert(name, value.clone()); } diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index c9d9ab6d1..c530b0476 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -371,8 +371,10 @@ impl EcContext { /// # Errors /// /// Returns [`TrustedServerError::EdgeCookie`] when the client IP is - /// unavailable, the provider fails to derive an identifier, or persisting a - /// generated identifier to the KV identity graph fails. + /// unavailable, the provider fails to derive an identifier, 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, @@ -393,6 +395,25 @@ impl EcContext { ) .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. @@ -1092,6 +1113,158 @@ mod tests { ); } + /// 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 { + ProviderCode::new("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 mints 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. #[derive(Debug)] diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index b7b5a6931..bc3f0a4a9 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -148,9 +148,117 @@ pub struct GeneratedEdgeCookie { /// 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 or are hop-by-hop. +/// +/// The hop-by-hop set is RFC 7230 §6.1, matching each adapter's +/// `is_hop_by_hop_response_header`, plus `content-length`, which frames the +/// body the adapter is about to write. A provider that set any of these would +/// be rewriting the response envelope rather than adding evidence to it. +const FRAMING_OR_HOP_BY_HOP_HEADERS: &[&str] = &[ + "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 message framing or hop-by-hop header. + #[display("sets a message framing or hop-by-hop header")] + 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 minting 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 minted 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 +} + /// The registered short code that namespaces one Edge Cookie provider's /// identifiers. /// @@ -528,6 +636,89 @@ mod tests { ); } + 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), + ] { + 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; From 53d632e4b111decd5a3ae0e1e75c8cfe693f7be1 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 01:38:42 +0100 Subject: [PATCH 14/36] Dispatch partner-path identifier checks by provider code `is_valid_ec_id` is the built-in HMAC grammar and rejects every other provider code, yet pull sync, batch sync, and the admin lookup all called it directly. A deployment running a non-HMAC provider therefore minted and read identifiers on the organic path that these three paths skipped or rejected. PR #1044's `hs00~` host-signal provider makes that concrete. The check is now split in two, in `AcceptedProviders` in `ec/provider.rs`. The global cookie bounds, the length cap and the cookie-safe alphabet in `ec_id_has_only_allowed_chars`, apply to every identifier whoever minted 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. Dispatch is on the code alone, before any provider inspects 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. KV normalization goes the same way through `canonical_kv_key`, so a row is always keyed by the owning provider's canonical form. A code no configured provider reads is rejected. The set of accepted providers is the deployment's active provider. `legacy_providers`, the design's list of readers that never mint, is not implemented on this branch (the key is rejected as unknown, see section 6.1 of the pluggable-providers design), so `AcceptedProviders::active` fills the reader list with the one active provider. The list is the seam: configured legacy readers are pushed alongside it and neither `accepts` nor `canonical_kv_key` changes. With no provider selected at all the deployment is stateless, and the built-in grammar stays the fallback, matching what `EcContext::accepts_id` has always done. Wiring: `EcContext::accepts_id` now goes through `AcceptedProviders`, so pull sync validates through it; `handle_batch_sync` and `handle_admin_ec_lookup` take the selected provider, which the Fastly adapter builds at both call sites. Tests cover a non-HMAC identifier accepted in pull sync, batch sync, and the admin lookup; a code neither active nor configured rejected in batch sync and the admin lookup, including one in the built-in HMAC shape; KV normalization dispatched to the owning provider (the built-in lowercases its hash segment, an opaque provider keys verbatim); and the global bounds rejecting before any provider is consulted. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/generation.rs:207 (P2) --- .../trusted-server-adapter-fastly/src/app.rs | 14 +- crates/trusted-server-core/src/ec/admin.rs | 104 +++++++-- .../trusted-server-core/src/ec/batch_sync.rs | 207 ++++++++++++++++-- .../trusted-server-core/src/ec/generation.rs | 17 +- crates/trusted-server-core/src/ec/mod.rs | 20 +- crates/trusted-server-core/src/ec/provider.rs | 145 ++++++++++++ .../trusted-server-core/src/ec/pull_sync.rs | 92 +++++++- 7 files changed, 540 insertions(+), 59 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index ac11fbf12..2c30abf08 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -112,6 +112,7 @@ 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::build_provider; use trusted_server_core::ec::provider::ensure_provider_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; @@ -567,7 +568,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 = build_provider(&state.settings.ec, services.ec_provider())?; + 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"), @@ -731,7 +737,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 + // minted, so validation and KV normalization are dispatched through + // that provider rather than the built-in HMAC grammar. + let provider = build_provider(&state.settings.ec, services.ec_provider())?; + handle_batch_sync(&kv, &partner_registry, &limiter, provider.as_deref(), req) }); let mut response = result.unwrap_or_else(|e| http_error(&e)); diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index 6cf4b3921..bb15b6892 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,10 @@ fn requested_ec_id(req: &Request) -> Result &'static str { + "opaque" + } + + fn code(&self) -> super::super::provider::ProviderCode { + super::super::provider::ProviderCode::new("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) + 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 57b4f9ed9..e75c02ddb 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 minted 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 { + ProviderCode::new("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 minted. 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 minted 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, @@ -632,7 +789,13 @@ mod tests { let ec_id = format!("hmac~{}.ABC123", "a".repeat(64)); let mappings = vec![mapping(&ec_id, "uid-1", 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 accept a coded HMAC identifier"); assert!( diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs index 99198c69b..88eb3ee67 100644 --- a/crates/trusted-server-core/src/ec/generation.rs +++ b/crates/trusted-server-core/src/ec/generation.rs @@ -190,12 +190,17 @@ pub fn is_valid_ec_hash(value: &str) -> bool { /// the random suffix allows mixed-case alphanumeric characters by /// construction. /// -/// A minted identifier carries the provider-code envelope, `hmac~` before -/// the bare value, and that is the form the partner-facing paths (pull sync, -/// batch sync, the admin lookup) receive, so both the enveloped and the -/// legacy bare form are accepted. An identifier under any other provider's -/// code is not an HMAC identifier and is rejected here: those paths accept -/// only the built-in provider's identifiers today. +/// A minted identifier 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 bare = match split_provider_code(value) { diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index c530b0476..37e66acea 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -492,19 +492,27 @@ 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. Falls back to the built-in shape when no provider is - /// configured. + /// 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.selected_provider.as_ref().map_or_else( - || is_valid_ec_id(value), - |provider| provider::provider_owns_id(provider.as_ref(), value), - ) + self.accepted_providers().accepts(value) } /// Returns whether the `ts-ec` cookie was present on the incoming request. diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index bc3f0a4a9..468cdcd17 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -31,6 +31,7 @@ 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. @@ -371,6 +372,93 @@ pub fn provider_kv_key(provider: &dyn EdgeCookieProvider, full: &str) -> String } } +/// 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 minted 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. +/// +/// The set holds the deployment's active provider. The design's +/// `legacy_providers` reader list, the providers that never mint 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() == EcProviderSelection::HMAC_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 @@ -741,6 +829,63 @@ mod tests { } } + #[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 minted 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 diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 1caa498c0..e55baeb40 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,15 @@ pub fn build_pull_sync_context(ec_context: &EcContext) -> Option &'static str { + "opaque" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::ec::provider::ProviderCode::new("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 minted. + // 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 { From 941297f9d54398cfb86005c4f79869f43a906c47 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 01:44:59 +0100 Subject: [PATCH 15/36] Let each provider decide whether it needs the client IP `EcContext::generate_if_needed` failed the request whenever the host could not determine a client IP, before the selected provider was asked anything. `RequestInfo::client_ip` already defines the empty string as the unavailable state and providers are meant to read only the evidence they need, so the generic check blocked every header-, cookie-, query- and client-derived provider that can work without an IP. The requirement moves into `HmacProvider`, whose only input is the client IP. With none it fails rather than hashing the empty string into an identifier every visitor on that host would share. That failure propagates out of `generate_if_needed` exactly as the old check did, so a provider that genuinely needs the IP and cannot get it still fails the request rather than quietly minting nothing, matching this branch's decision to stop rather than serve without identity. Providers that read other evidence now receive the documented empty value and run. `HmacProvider` is the only provider on this branch that reads the client IP; the injected vendor seam leaves the decision to each vendor crate. Tests cover both answers: a provider deriving identity from the request query and cookies mints on a host with no client IP, and the built-in HMAC provider refuses on the same host with no identifier committed. A `noop_services_with_ec_provider_without_client_ip` test helper models that host. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/mod.rs:376 (P2) --- crates/trusted-server-core/src/ec/mod.rs | 97 ++++++++++++++++--- crates/trusted-server-core/src/ec/provider.rs | 17 +++- .../src/platform/test_support.rs | 27 +++++- 3 files changed, 124 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 37e66acea..28c3e3ae3 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -316,8 +316,9 @@ impl EcContext { /// /// # Errors /// - /// Returns an error if the client IP is unavailable and generation is - /// needed, or if HMAC generation fails. + /// Returns an error if the selected provider fails to derive an identifier, + /// which includes a provider that needs the client IP being run on a host + /// that cannot supply one. pub fn generate_if_needed( &mut self, settings: &Settings, @@ -343,16 +344,14 @@ impl EcContext { return Ok(()); } - // EC generation needs the client IP; checked after the cheap skip - // guards so a stateless deployment on a host with no client IP does not - // log spurious errors. The provider reads it borrowed at generate time - // (see [`generate_with_provider`]), so nothing is cloned here. - if self.client_ip.is_none() { - return Err(Report::new(TrustedServerError::EdgeCookie { - message: "Client IP required for EC generation but unavailable".to_owned(), - })); - } - + // 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, which fails the request + // rather than serving without identity. self.generate_with_provider(ec_provider.as_ref(), settings, kv) } @@ -370,8 +369,9 @@ impl EcContext { /// /// # Errors /// - /// Returns [`TrustedServerError::EdgeCookie`] when the client IP is - /// unavailable, the provider fails to derive an identifier, the provider + /// 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. @@ -1065,6 +1065,75 @@ mod tests { ); } + #[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 mint"); + 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 the request fails rather than being served without. + 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 mints an identifier outside the cookie-safe alphabet, /// to prove core rejects it at mint rather than rewriting it. #[derive(Debug)] diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 468cdcd17..80dfb3d24 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -531,6 +531,14 @@ pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { /// 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 reaches the caller, so the request fails rather +/// than being served without identity. A provider that reads other evidence +/// makes its own decision and is unaffected. #[derive(Debug, Clone)] pub struct HmacProvider { passphrase: Redacted, @@ -558,7 +566,14 @@ impl EdgeCookieProvider for HmacProvider { request_info: &dyn RequestInfo, _input: &IdentityInput<'_>, ) -> Result> { - let id = generation::generate_ec_id(self.passphrase.expose(), request_info.client_ip())?; + 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(), diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index cecb902fa..cdfd3b96d 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -693,6 +693,30 @@ pub(crate) fn noop_services() -> RuntimeServices { /// reaches core through. pub(crate) fn noop_services_with_ec_provider( ec_provider: Arc, +) -> RuntimeServices { + // A fixed client IP, so a provider that reads one (the built-in HMAC + // provider does) can run. + noop_services_with_ec_provider_and_ip( + ec_provider, + Some("203.0.113.10".parse().expect("should parse test client IP")), + ) +} + +/// Build a [`RuntimeServices`] with an injected Edge Cookie provider and no +/// client IP, modeling a host that cannot determine one. +/// +/// Whether that matters is the provider's decision, so this exists to test both +/// answers: a provider reading other evidence still mints, and one that needs +/// the IP refuses. +pub(crate) fn noop_services_with_ec_provider_without_client_ip( + ec_provider: Arc, +) -> RuntimeServices { + noop_services_with_ec_provider_and_ip(ec_provider, None) +} + +fn noop_services_with_ec_provider_and_ip( + ec_provider: Arc, + client_ip: Option, ) -> RuntimeServices { RuntimeServices::builder() .config_store(Arc::new(NoopConfigStore)) @@ -701,9 +725,8 @@ pub(crate) fn noop_services_with_ec_provider( .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) .geo(Arc::new(NoopGeo)) - // A fixed client IP so the generate path (which requires one) can run. .client_info(ClientInfo { - client_ip: Some("203.0.113.10".parse().expect("should parse test client IP")), + client_ip, ..ClientInfo::default() }) .ec_provider(ec_provider) From e3171906fb05f54086235e5db7d4cf98bf71e32d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 01:52:04 +0100 Subject: [PATCH 16/36] State a real retirement condition for the legacy bare-identifier reader The comment on `provider_owns_id` promised bare-HMAC compatibility for one release cycle, which the code cannot honor. A returning visitor's bare cookie is never rewritten into the coded form, so the promise was shorter than the cookie's own life. The comment now states the condition the quantities actually support, read off the code rather than estimated. Neither the cookie nor its identity-graph row is refreshed on an ordinary page view (see `ec_finalize_response`), so each has one fixed lifetime from the moment it was written: `COOKIE_MAX_AGE` in `ec/cookies.rs` and `ENTRY_TTL` in `ec/kv.rs`, both one year and neither operator-configurable. The earliest safe retirement is one year after the last release that could still mint a bare identifier has stopped running anywhere, plus the deployment's own rollout skew. The comment also says plainly that the second half of the condition cannot be checked: nothing counts or logs a bare-form read-back, so there is no observed legacy-reader traffic to look at and elapsed time alone proves nothing. No metric is named that is not emitted. The reader stays, at the cost of one string comparison per read-back, and the one-release wording is gone from the comment and from the provider code registry. A new test in `ec/cookies.rs` pins `COOKIE_MAX_AGE` to one year, next to the existing `ENTRY_TTL` assertion in `ec/kv.rs`, so the two figures the retirement condition is written in terms of cannot drift unnoticed. Addresses: Christian Pavilonis review of PR 1043, crates/trusted-server-core/src/ec/provider.rs:145 (non-blocking) --- crates/trusted-server-core/src/ec/cookies.rs | 13 ++++++++++ crates/trusted-server-core/src/ec/provider.rs | 26 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index 1b3da4785..d25e63641 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -130,6 +130,19 @@ pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) 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!( diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 80dfb3d24..d833eaea8 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -338,8 +338,30 @@ pub fn split_provider_code(full: &str) -> (Option<&str>, &str) { /// carries, with the value part accepted by that provider's /// [`accepts_id`](EdgeCookieProvider::accepts_id). A legacy bare identifier /// (no code prefix) belongs only to the built-in HMAC provider, which -/// dual-reads its pre-envelope form for one release cycle so deployed cookies -/// keep working across the migration. +/// 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 neither +/// the cookie nor its identity-graph row is refreshed on an ordinary page view +/// (see `ec_finalize_response` in [`finalize`](super::finalize)), so each has +/// one fixed lifetime running from the moment it was written: `COOKIE_MAX_AGE` +/// in [`cookies`](super::cookies) and `ENTRY_TTL` in [`kv`](super::kv), both +/// one year, neither operator-configurable. The earliest safe retirement is +/// therefore one year (the longer of the two, and today they are equal) after +/// the last release that could still mint a bare identifier has stopped +/// running anywhere, 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) { From 343ac3ea29a80f96ad6608ec7a9076103c82efb8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 02:14:39 +0100 Subject: [PATCH 17/36] Key identity-graph reads and write-backs by the canonical form The lifecycle contract routes every identity-graph row through the owning provider's canonical form, and generation already did: it keys the row it creates with `provider_kv_key`. Three read and write-back paths did not. `handle_identify` read with the raw cookie value, the withdrawal tombstones in `ec_finalize_response` were written under the raw value, and EID ingestion keyed its upsert by the raw value too. Raw and canonical are the same string for the built-in HMAC provider, so nothing misbehaved. For the first provider whose canonical form differs from the cookie value, which is exactly the case the mint test on this branch already pins, identify missed the row generation had written, an ingested EID was dropped because the upsert found no row under the raw value, and a withdrawal tombstone landed on a key no live row used, so the revocation never took effect. The key is now derived in one place, `EcContext::kv_key_for`, reached by `ec_kv_key` for the active identifier and `cookie_ec_kv_key` for the `ts-ec` cookie the request carried. Both go through `AcceptedProviders`, so the owning provider is picked by the identifier's `{code}~` prefix and supplies the canonical form of its own value part. Identify, the tombstones, and both EID ingestion call sites use them. `AcceptedProviders` came from the partner-path dispatch commit and fixed none of these three; it changed pull sync, batch sync, and the admin lookup only. What it did give this fix is `canonical_kv_key`, the code-dispatched derivation these paths now share, and it also subsumes the shape filter `withdrawal_ec_ids` applied by hand: a key exists exactly when some provider this deployment reads owns the identifier, so `withdrawal_kv_keys` filters by deriving. The mint test now asserts `ec_kv_key` returns the key generation actually wrote, so the read side and the write side cannot drift apart. Tests: identify finds the row generation keyed by the canonical form and still echoes the cookie value to the partner; a withdrawal tombstones the canonical row and writes nothing under the raw cookie value; an ingested EID joins the canonical row. Each was run against the unfixed code first and each failed there. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/identify.rs:89 (wrench) --- crates/trusted-server-core/src/ec/finalize.rs | 206 +++++++++++++++--- crates/trusted-server-core/src/ec/identify.rs | 137 +++++++++--- crates/trusted-server-core/src/ec/mod.rs | 64 +++++- 3 files changed, 345 insertions(+), 62 deletions(-) diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index b27b700a3..908cb5959 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -78,16 +78,16 @@ pub fn ec_finalize_response( 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), ); } }); @@ -99,8 +99,12 @@ pub fn ec_finalize_response( // Returning user: EC is permitted and came from the request. if ec_context.ec_was_present() && !ec_context.ec_generated() && ec_permitted { - if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) { - ingest_eid_cookies(eids_cookie, sharedid_cookie, ec_id, graph, registry); + // 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 @@ -112,12 +116,14 @@ 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); } } @@ -167,30 +173,35 @@ pub fn clear_ec_on_response(settings: &Settings, response: &mut Response HashSet { - let mut hashes = HashSet::new(); +/// 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_ec_id) = ec_context.existing_cookie_ec_id() - && ec_context.accepts_id(cookie_ec_id) - { - hashes.insert(cookie_ec_id.to_owned()); + 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() - && ec_context.accepts_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); } } @@ -270,6 +281,50 @@ mod tests { ) } + /// The identifier [`CanonicalizingProvider`] mints, 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 mint 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)) } @@ -292,7 +347,7 @@ 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, @@ -303,7 +358,7 @@ mod tests { 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!( @@ -313,7 +368,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), @@ -324,14 +379,14 @@ mod tests { 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( @@ -343,7 +398,7 @@ mod tests { 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"); @@ -351,7 +406,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), @@ -362,7 +417,7 @@ mod tests { 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"); @@ -718,4 +773,93 @@ mod tests { "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" + ); + } } diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index eeadaa290..b9b423956 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -86,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; + } } } @@ -350,6 +363,17 @@ mod tests { ); } + /// The identifier [`CanonicalizingProvider`] mints, 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 mint 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 { source: ConsentSource::Cookie, @@ -753,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/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 28c3e3ae3..70093d4f6 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -515,6 +515,41 @@ impl EcContext { self.accepted_providers().accepts(value) } + /// The identity-graph key for `value` under the providers this deployment + /// reads. + /// + /// The one place core turns an identifier into a row key. Every read and + /// write of an identity-graph row goes through this, so a provider whose + /// canonical form differs from the cookie value still finds the row it + /// minted. 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 mint. + /// + /// `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 and a cookie the active provider no + /// longer mints both leave [`ec_kv_key`](Self::ec_kv_key) empty while a + /// live row still exists. + #[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 { @@ -613,6 +648,22 @@ impl EcContext { self.ec_value.as_deref().map(generation::ec_hash) } + /// 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. /// @@ -1344,8 +1395,11 @@ mod tests { /// 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)] - struct CanonicalizingProvider; + pub(crate) struct CanonicalizingProvider; impl EdgeCookieProvider for CanonicalizingProvider { fn id(&self) -> &'static str { @@ -1403,6 +1457,14 @@ mod tests { .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] From 8684c69548dcf301c3a5441c2201016207594488 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 02:30:31 +0100 Subject: [PATCH 18/36] Egress only an Edge Cookie identifier the provider recognizes Section 3's Recognize row says a value the selected provider does not recognize "is never used or egressed". Three paths egressed one anyway. `append_ec_id` put the raw `ts-ec` cookie or `x-ts-ec` header on the outbound origin URL, `handle_first_party_click` put it on the click target's redirect URL, and the testlight integration put it in the proxied body as `user.id`. All three read through `edge_cookie::get_ec_id`, which checks the cookie-safe alphabet and the length cap and nothing else, so a value carrying another deployment's provider code (`zz00~...`), and any cookie at all in a deployment with no provider selected, was handed on. The code changes rather than the claim. `edge_cookie::recognized_ec_id` reads the value and then asks the selected provider whether it owns it, through `provider_owns_id`, which is the same test `EcContext` applies when it reads the cookie back, so the egress paths and the EC lifecycle agree on what this deployment issued. All three call sites use it. Behavior change: a deployment with no Edge Cookie provider selected now forwards no `ts-ec` value at all, on any of the three paths. It previously forwarded whatever the browser sent. An operator running stateless and relying on the raw cookie reaching the origin, the click target, or the testlight upstream will see that value stop arriving, and testlight, which requires an identifier, will fail the request rather than proxy it. The fix for such a deployment is to select a provider, which is what makes the value this deployment's to hand on. The testlight call site is in scope on the evidence rather than by assumption: its value is written into the request body as `user.id` by `rewrite_request_body` and that body is POSTed to the operator-configured endpoint, so the identifier leaves the edge even though the integration sets `forward_ec_id = false` (which suppresses only the query-parameter copy on the same request). The spec's Recognize row now names the three egress paths in the column that says where core applies recognition, and records that a stateless deployment recognizes nothing and so egresses nothing. The claim is left as strong as it was. Tests: each of the three paths, with a foreign-coded value and with a stateless deployment, plus a positive control on each that the deployment's own identifier still gets through. The testlight cases assert no upstream call is made at all. `click_appends_ec_id_when_present` used `ec-123`, which no provider issues, and now uses an identifier the built-in HMAC provider owns. Every new test was run against the unfixed code and failed there. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/proxy.rs:1263 (question) --- crates/trusted-server-core/src/edge_cookie.rs | 55 ++++- .../src/integrations/testlight.rs | 99 ++++++++- crates/trusted-server-core/src/proxy.rs | 198 ++++++++++++++++-- 3 files changed, 331 insertions(+), 21 deletions(-) diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index e77f1a82c..f742f8997 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -13,13 +13,12 @@ use crate::ec::cookies::ec_id_has_only_allowed_chars; #[cfg(test)] use crate::ec::generation::normalize_ip; #[cfg(test)] -use crate::ec::provider::{IdentityInput, build_provider}; +use crate::ec::provider::IdentityInput; +use crate::ec::provider::{build_provider, provider_owns_id}; use crate::error::TrustedServerError; #[cfg(test)] use crate::evidence::BorrowedRequestInfo; -#[cfg(test)] use crate::platform::RuntimeServices; -#[cfg(test)] use crate::settings::Settings; /// Generates a fresh EC ID using the configured Edge Cookie provider. @@ -119,6 +118,56 @@ pub fn get_ec_id(req: &Request) -> Result, Report, +) -> Result, Report> { + let Some(ec_id) = get_ec_id(req)? else { + return Ok(None); + }; + + let Some(provider) = build_provider(&settings.ec, services.ec_provider())? 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: diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index 80b2c4dfa..f6f54ee4a 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use validator::Validate; -use crate::edge_cookie::get_ec_id; +use crate::edge_cookie::recognized_ec_id; use crate::error::TrustedServerError; use crate::integrations::{ AttributeRewriteAction, INTEGRATION_MAX_BODY_BYTES, IntegrationAttributeContext, @@ -184,13 +184,16 @@ impl IntegrationProxy for TestlightIntegration { .await?; let req = http::Request::from_parts(parts, EdgeBody::empty()); - // Read EC ID from the ts-ec cookie forwarded by the client. - // The registry strips x-ts-ec before dispatching, so only the cookie is available here. - let ec_id = get_ec_id(&req) + // Read the EC ID from the ts-ec cookie forwarded by the client. The + // registry strips x-ts-ec before dispatching, so only the cookie is + // available here. The value goes into the proxied body as `user.id` and + // leaves the edge, so only one the selected provider recognizes is + // accepted, and a stateless deployment supplies none. + let ec_id = recognized_ec_id(settings, services, &req) .change_context(Self::error("Failed to read EC ID"))? .ok_or_else(|| { Report::new(Self::error( - "EC ID not found in ts-ec cookie — the client must carry a valid EC cookie", + "No EC ID this deployment's Edge Cookie provider recognizes was found \n in the ts-ec cookie", )) })?; @@ -467,4 +470,90 @@ mod tests { ); }); } + + /// A well-formed identifier carrying a provider code no deployment here + /// reads, the shape a partner or another deployment would hand back. + const FOREIGN_CODED_EC_ID: &str = "zz00~someone-elses-identifier"; + + fn testlight_auction_request(ec_id: &str) -> http::Request { + let mut req = http::Request::builder() + .method(Method::POST) + .uri("https://edge.example.com/integrations/testlight/auction") + .body(EdgeBody::from(br#"{"imp":[{"id":"slot-1"}]}"#.to_vec())) + .expect("should build request"); + req.headers_mut().insert( + crate::constants::HEADER_X_TS_EC.clone(), + http::HeaderValue::from_str(ec_id).expect("should build EC header value"), + ); + req + } + + fn testlight_integration() -> Arc { + TestlightIntegration::new(TestlightConfig { + enabled: true, + endpoint: "https://example.com/openrtb".to_string(), + timeout_ms: 1000, + shim_src: tsjs::tsjs_unified_script_src(), + rewrite_scripts: true, + }) + } + + #[test] + fn handle_refuses_to_egress_an_ec_id_the_provider_does_not_recognize() { + futures::executor::block_on(async { + // The identifier ends up in the proxied body as `user.id` and leaves + // the edge, so a value this deployment did not issue must stop here + // rather than be handed to the upstream endpoint. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, br#"{"ok":true}"#.to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + + testlight_integration() + .handle( + &settings, + &services, + testlight_auction_request(FOREIGN_CODED_EC_ID), + ) + .await + .expect_err("a foreign provider code should not be proxied upstream"); + + assert!( + stub.recorded_backend_names().is_empty(), + "no upstream call should be made with an unrecognized identifier" + ); + }); + } + + #[test] + fn handle_refuses_to_egress_any_ec_id_in_a_stateless_deployment() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, br#"{"ok":true}"#.to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + // The value is one the built-in provider would recognize, so only + // the absence of a selected provider can withhold it. + let mut settings = create_test_settings(); + settings.ec.provider = None; + settings.ec.providers.hmac = None; + + testlight_integration() + .handle( + &settings, + &services, + testlight_auction_request(VALID_SYNTHETIC_ID), + ) + .await + .expect_err("a deployment that mints no identifier should proxy none"); + + assert!( + stub.recorded_backend_names().is_empty(), + "no upstream call should be made without a recognized identifier" + ); + }); + } } diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 59017cf97..64ad90f35 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -23,7 +23,7 @@ use crate::constants::{ HEADER_USER_AGENT, HEADER_X_FORWARDED_FOR, }; use crate::creative::{CreativeCssProcessor, CreativeHtmlProcessor}; -use crate::edge_cookie::get_ec_id; +use crate::edge_cookie::recognized_ec_id; use crate::error::TrustedServerError; use crate::platform::{ DEFAULT_FIRST_BYTE_TIMEOUT, PlatformBackendSpec, PlatformHttpRequest, PlatformResponse, @@ -788,7 +788,7 @@ pub async fn proxy_request( })?; if forward_ec_id { - append_ec_id(&req, &mut target_url_parsed); + append_ec_id(settings, services, &req, &mut target_url_parsed); } proxy_with_redirects( @@ -1260,8 +1260,19 @@ fn upsert_ec_query_param(url: &mut url::Url, ec_id: &str) { url.set_query(Some(&serializer.finish())); } -fn append_ec_id(req: &Request, target_url_parsed: &mut url::Url) { - let ec_id_param = match get_ec_id(req) { +/// Forwards the request's Edge Cookie identifier to the outbound target URL. +/// +/// Only an identifier the selected provider recognizes is forwarded. A value +/// carrying another deployment's provider code, and any value at all in a +/// stateless deployment, is withheld, so nothing this deployment did not issue +/// reaches the origin. +fn append_ec_id( + settings: &Settings, + services: &RuntimeServices, + req: &Request, + target_url_parsed: &mut url::Url, +) { + let ec_id_param = match recognized_ec_id(settings, services, req) { Ok(id) => id, Err(e) => { log::warn!("failed to extract EC ID for forwarding: {:?}", e); @@ -1597,7 +1608,7 @@ pub async fn handle_first_party_proxy( /// Returns an error if the signed target cannot be reconstructed or validation fails. pub async fn handle_first_party_click( settings: &Settings, - _services: &RuntimeServices, + services: &RuntimeServices, req: Request, ) -> Result, Report> { let SignedTarget { @@ -1606,7 +1617,10 @@ pub async fn handle_first_party_click( had_params, } = reconstruct_and_validate_signed_target(settings, &req.uri().to_string())?; - let ec_id = match get_ec_id(&req) { + // The redirect target is a third party's URL, so only an identifier the + // selected provider recognizes is added to it. A stateless deployment adds + // nothing. + let ec_id = match recognized_ec_id(settings, services, &req) { Ok(id) => id, Err(e) => { log::warn!("failed to extract EC ID for forwarding: {:?}", e); @@ -2220,17 +2234,18 @@ mod tests { use super::{ AssetProxyCachePolicy, IMAGE_FALLBACK_CONTENT_TYPE, ProxyRequestConfig, - SUPPORTED_ENCODINGS, asset_origin_host_header, asset_path_skips_image_optimizer, - build_asset_proxy_target_url, clear_s3_credentials_cache_for_tests, - handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, - handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, is_host_allowed, - is_host_permitted, proxy_request, rebuild_response_with_body, + SUPPORTED_ENCODINGS, append_ec_id, asset_origin_host_header, + asset_path_skips_image_optimizer, build_asset_proxy_target_url, + clear_s3_credentials_cache_for_tests, handle_asset_proxy_request, handle_first_party_click, + handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, + is_host_allowed, is_host_permitted, proxy_request, rebuild_response_with_body, reconstruct_and_validate_signed_target, stream_asset_body, }; use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::{HEADER_ACCEPT, HEADER_X_FORWARDED_FOR}; use crate::creative; use crate::error::{IntoHttpResponse, TrustedServerError}; + use crate::platform::RuntimeServices; use crate::platform::test_support::{ HashMapSecretStore, StubHttpClient, build_services_with_http_client, build_services_with_secret_and_http_client, noop_services, @@ -2249,6 +2264,7 @@ mod tests { use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::response_builder as edge_response_builder; use error_stack::Report; + use http::Request; use http::{HeaderValue, Method, Request as HttpRequest, Response, StatusCode, header}; #[test] @@ -2962,7 +2978,8 @@ mod tests { ); req.headers_mut().insert( crate::constants::HEADER_X_TS_EC, - HeaderValue::from_static("ec-123"), + HeaderValue::from_str(&recognized_hmac_ec_id()) + .expect("should build EC header value"), ); let resp = handle_first_party_click(&settings, &noop_services(), req) @@ -2980,11 +2997,166 @@ mod tests { .map(|(k, v)| (k.into_owned(), v.into_owned())) .collect(); assert_eq!(pairs.remove("foo").as_deref(), Some("1")); - assert_eq!(pairs.remove("ts-ec").as_deref(), Some("ec-123")); + assert_eq!( + pairs.remove("ts-ec").as_deref(), + Some(recognized_hmac_ec_id().as_str()) + ); assert!(pairs.is_empty()); }); } + /// An identifier the built-in HMAC provider, the provider + /// `create_test_settings` selects, recognizes as its own. + fn recognized_hmac_ec_id() -> String { + format!("hmac~{}", crate::test_support::tests::VALID_SYNTHETIC_ID) + } + + /// A well-formed identifier carrying a provider code no deployment here + /// reads, the shape a partner or another deployment would hand back. + const FOREIGN_CODED_EC_ID: &str = "zz00~someone-elses-identifier"; + + fn stateless_settings() -> Settings { + let mut settings = create_test_settings(); + settings.ec.provider = None; + settings.ec.providers.hmac = None; + settings + } + + fn signed_click_request(settings: &Settings, ec_id: &str) -> Request { + let tsurl = "https://cdn.example/a.png"; + let full = format!("{tsurl}?foo=1"); + let sig = crate::http_util::compute_encrypted_sha256_token(settings, &full); + let mut req = build_http_request( + Method::GET, + format!( + "https://edge.example/first-party/click?tsurl={}&foo=1&tstoken={}", + url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(), + sig + ), + ); + req.headers_mut().insert( + crate::constants::HEADER_X_TS_EC, + HeaderValue::from_str(ec_id).expect("should build EC header value"), + ); + req + } + + fn click_ts_ec_param( + settings: &Settings, + services: &RuntimeServices, + req: Request, + ) -> Option { + let resp = futures::executor::block_on(handle_first_party_click(settings, services, req)) + .expect("should redirect"); + let loc = resp + .headers() + .get(header::LOCATION) + .and_then(|h| h.to_str().ok()) + .expect("Location header should be present and valid") + .to_owned(); + url::Url::parse(&loc) + .expect("Location should be a valid URL") + .query_pairs() + .find(|(k, _)| k == "ts-ec") + .map(|(_, v)| v.into_owned()) + } + + #[test] + fn click_withholds_an_ec_id_the_provider_does_not_recognize() { + let settings = create_test_settings(); + let param = click_ts_ec_param( + &settings, + &noop_services(), + signed_click_request(&settings, FOREIGN_CODED_EC_ID), + ); + + assert_eq!( + param, None, + "a value carrying another deployment's provider code should not reach the click target" + ); + } + + #[test] + fn click_withholds_every_ec_id_in_a_stateless_deployment() { + let settings = stateless_settings(); + // The value is one the built-in provider would recognize, so only the + // absence of a selected provider can withhold it. + let param = click_ts_ec_param( + &settings, + &noop_services(), + signed_click_request(&settings, &recognized_hmac_ec_id()), + ); + + assert_eq!( + param, None, + "a deployment that mints no identifier should hand none to the click target" + ); + } + + #[test] + fn append_ec_id_forwards_only_what_the_provider_recognizes() { + let settings = create_test_settings(); + let services = noop_services(); + let recognized = recognized_hmac_ec_id(); + + let mut url = + url::Url::parse("https://origin.example/page?foo=1").expect("should parse origin URL"); + append_ec_id( + &settings, + &services, + &request_with_ec_cookie(&recognized), + &mut url, + ); + assert_eq!( + ts_ec_param(&url), + Some(recognized.clone()), + "the deployment's own identifier should still reach the origin" + ); + + let mut url = + url::Url::parse("https://origin.example/page?foo=1").expect("should parse origin URL"); + append_ec_id( + &settings, + &services, + &request_with_ec_cookie(FOREIGN_CODED_EC_ID), + &mut url, + ); + assert_eq!( + ts_ec_param(&url), + None, + "a foreign provider code should not reach the origin" + ); + + let mut url = + url::Url::parse("https://origin.example/page?foo=1").expect("should parse origin URL"); + append_ec_id( + &stateless_settings(), + &services, + &request_with_ec_cookie(&recognized), + &mut url, + ); + assert_eq!( + ts_ec_param(&url), + None, + "a stateless deployment should forward nothing to the origin" + ); + } + + fn request_with_ec_cookie(ec_id: &str) -> Request { + let mut req = build_http_request(Method::GET, "https://edge.example/page"); + req.headers_mut().insert( + http::header::COOKIE, + HeaderValue::from_str(&format!("ts-ec={ec_id}")).expect("should build cookie header"), + ); + req + } + + fn ts_ec_param(url: &url::Url) -> Option { + url.query_pairs() + .find(|(k, _)| k == "ts-ec") + .map(|(_, v)| v.into_owned()) + } + #[test] fn proxy_rebuild_adds_and_removes_params() { futures::executor::block_on(async { From 20bb082b352f2f499ef3d73a4f65e0795684c7b8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 02:33:02 +0100 Subject: [PATCH 19/36] Record the cluster-count gap the identifier envelope opens Section 3 said the pre-epic IP-cluster prefix listing "continues unchanged". The listing does, but the key space it lists over does not. A fresh mint is keyed `hmac~.`, so the prefix `evaluate_cluster` derives is `hmac~` for a coded row while a legacy bare row still lists under `` alone. Prefix matching is anchored at the start of the key, so two rows for the same client IP that straddle the envelope never count each other and `cluster_size` under-reports while both populations coexist. The decision is to accept the undercount rather than bridge it, and the spec now says so along with the bound and the reasoning, and the prefix derivation in `evaluate_cluster` carries the same note so the next reader of that line is not surprised by it. The "gates nothing" half of the reasoning was checked rather than assumed. Every read of `cluster_size` in the workspace is a store, a log line, or the optional field in the identify response. The single read that reaches a branch is the cache short circuit in `evaluate_cluster` itself, which tests whether a value is stored, not what it is, so `Some(1)` and `Some(100000)` take the same path. There are no matches at all in the TypeScript or the integration tests. Two settings look like they gate on it and do not: `cluster_trust_threshold` (whose doc comment says entries at or below it "are treated as individual users for identity resolution") and `cluster_recheck_secs` are parsed and defaulted but have no readers anywhere in the code. They are noted here because they are what would make a reader believe the count is a control. They are outside this change; the unimplemented threshold wants an issue of its own. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/ec/kv.rs:715 (thinking) --- crates/trusted-server-core/src/ec/kv.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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)?; From 84925ca5b241700298153fb4e7961b69e2c6b9c1 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 02:41:02 +0100 Subject: [PATCH 20/36] Consume the refused Report in the testlight egress tests The two egress tests added in the previous commit dropped the `Report` that `expect_err` returns on the floor. `Report` is `#[must_use]`, so building the library's test target warned, and clippy runs with `--all-targets -- -D warnings`, which would have failed the CI gate rather than only warning. --- crates/trusted-server-core/src/integrations/testlight.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index f6f54ee4a..e8de0aa00 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -511,7 +511,7 @@ mod tests { ); let settings = create_test_settings(); - testlight_integration() + let refused = testlight_integration() .handle( &settings, &services, @@ -519,6 +519,7 @@ mod tests { ) .await .expect_err("a foreign provider code should not be proxied upstream"); + drop(refused); assert!( stub.recorded_backend_names().is_empty(), @@ -541,7 +542,7 @@ mod tests { settings.ec.provider = None; settings.ec.providers.hmac = None; - testlight_integration() + let refused = testlight_integration() .handle( &settings, &services, @@ -549,6 +550,7 @@ mod tests { ) .await .expect_err("a deployment that mints no identifier should proxy none"); + drop(refused); assert!( stub.recorded_backend_names().is_empty(), From e45990bf08996ea9958f7142ba66490e23f330ad Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 02:45:40 +0100 Subject: [PATCH 21/36] Drop the request-evidence accessors that have no caller The spec's minimalism rule wants a production caller in the same change that introduces a method. `RequestInfo` arrived with seven accessors and only one of them, `client_ip`, is read by production code, in `HmacProvider::generate` at crates/trusted-server-core/src/ec/provider.rs. The other six had no non-test caller anywhere in the workspace on this branch as it stands: - `user_agent()` and `header_names()` had no caller at all, test or otherwise, beyond their two implementations. - `header()` and `query_param()` were called only by two test doubles in `ec/mod.rs`, both inside `#[cfg(test)]`, plus evidence.rs's own tests. - `path()` and `query()` were called only by evidence.rs's own tests, and `query()` by the default body of `query_param()`, which nothing called. Note the file is crates/trusted-server-core/src/evidence.rs; there is no `ec/evidence.rs`. The many `.path()`, `.query()` and `.header()` hits elsewhere in the workspace are `http::Uri`, `http::request::Builder` and the unrelated `http_util::RequestInfo` struct, which has `host` and `scheme` fields and none of these methods. All six are removed, along with everything that existed only to feed them: the `headers`, `path` and `query` fields and the `with_request_target` builder on both `OwnedRequestInfo` and `BorrowedRequestInfo`, the header snapshot argument of `OwnedRequestInfo::new`, `BorrowedRequestInfo::new` and the test-only `edge_cookie::generate_ec_id`, and the `request_headers` / `request_path` / `request_query` snapshot `EcContext` took at read time to fill them. Leaving state a provider can no longer read would be worse than the accessors themselves. Two test doubles went with them, `CookieCapturingProvider` and `EvidenceCapturingProvider`, along with the two tests that existed to prove the removed accessors carried cookies and query parameters. The third test that used `EvidenceCapturingProvider`, `a_provider_that_reads_no_client_ip_mints_when_the_host_has_none`, tests something else (a provider that needs no client IP still mints on a host that has none), so it stays, now with a `NoClientIpProvider` double that also asserts such a host passes the documented empty string rather than failing. The trait keeps its role as the seam. Its docs, the provider module docs and section 4 of the design now say that further evidence arrives as a defaulted accessor in the change that first reads it, rather than claiming a provider can already read headers, cookies, client hints and the URL. Probe: removing `client_ip` from the trait fails the library build at ec/provider.rs, where `HmacProvider::generate` reads it. Removing the other six failed nothing outside the tests deleted with them, which is the asymmetry this commit is about. Addresses: Aram Grigoryan review of PR 1043, crates/trusted-server-core/src/evidence.rs:27 (thinking) --- crates/trusted-server-core/src/ec/mod.rs | 271 +++++------------- crates/trusted-server-core/src/ec/provider.rs | 10 +- crates/trusted-server-core/src/edge_cookie.rs | 24 +- crates/trusted-server-core/src/evidence.rs | 254 +++------------- 4 files changed, 116 insertions(+), 443 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 70093d4f6..90dfc06a4 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -159,16 +159,6 @@ pub struct EcContext { /// instead of being dropped by the built-in shape check. `None` when no /// provider is configured. selected_provider: Option>, - /// A snapshot of the request evidence a provider reads at generation time: - /// the request headers (so a provider can read cookies and client hints), and - /// the URL path and query string (so it can read request parameters). - /// Captured once at construction, and only when a provider is configured, so - /// a deployment with no Edge Cookie provider clones nothing. A provider reads - /// these through [`RequestInfo`](crate::evidence::RequestInfo) at generate - /// time. - request_headers: http::HeaderMap, - request_path: String, - request_query: String, /// Response headers a provider asked to set, captured during /// [`EcContext::generate_if_needed`] and applied to the response by EC /// finalization. Empty for providers that set no headers. @@ -237,23 +227,6 @@ impl EcContext { 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() @@ -298,9 +271,6 @@ impl EcContext { geo_info: geo_info.cloned(), device_signals: None, selected_provider, - request_headers, - request_path, - request_query, response_headers: Vec::new(), }) } @@ -360,12 +330,11 @@ impl EcContext { /// Split out of [`generate_if_needed`](Self::generate_if_needed) so the /// provider is supplied explicitly: the configured path builds it from /// settings, and tests pass one in to observe the [`IdentityInput`] a - /// provider receives. The request evidence captured at read time (client - /// IP, headers, and the URL path and query) is passed borrowed through - /// [`RequestInfo`](crate::evidence::RequestInfo), so a provider can read - /// cookies and request parameters at generate time; the built-ins read - /// only the client IP. The skip guards (existing EC, consent gate) - /// stay in [`generate_if_needed`](Self::generate_if_needed). + /// provider receives. The request evidence captured at read time, the + /// normalized client IP, is passed borrowed through + /// [`RequestInfo`](crate::evidence::RequestInfo). The skip guards (existing + /// EC, consent gate) stay in + /// [`generate_if_needed`](Self::generate_if_needed). /// /// # Errors /// @@ -384,16 +353,11 @@ impl EcContext { 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); + // Pass the request evidence captured at read time, borrowed. That is the + // normalized client IP, the only evidence a provider in this workspace + // reads today. `RequestInfo` is the seam, so further evidence arrives as + // a defaulted accessor in the change that first reads it. + let request_info = BorrowedRequestInfo::new(self.client_ip.as_deref().unwrap_or_default()); 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 @@ -699,9 +663,6 @@ impl EcContext { 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(), } } @@ -726,9 +687,6 @@ impl EcContext { 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(), } } @@ -756,9 +714,6 @@ impl EcContext { 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(), } } @@ -784,7 +739,7 @@ pub(crate) fn current_timestamp() -> u64 { mod tests { use super::*; use crate::ec::provider::{EcProviderSelection, ProviderCode}; - use crate::evidence::{OwnedRequestInfo, RequestInfo}; + use crate::evidence::RequestInfo; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; @@ -803,67 +758,6 @@ mod tests { format!("{}.{suffix}", prefix_char.repeat(64)) } - /// A provider that records the `Cookie` header from the request info passed - /// to `generate`, so a test can prove request cookies reach a provider (a - /// client that stores values in cookies relies on this). - #[derive(Debug)] - struct CookieCapturingProvider { - seen_cookie: std::sync::Mutex>, - } - - impl EdgeCookieProvider for CookieCapturingProvider { - fn id(&self) -> &'static str { - "cookie-capturing" - } - - fn code(&self) -> ProviderCode { - ProviderCode::new("t0cc") - } - - fn generate( - &self, - request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - ) -> Result> { - let cookie = request_info.header("cookie").map(ToOwned::to_owned); - *self.seen_cookie.lock().expect("should lock seen cookie") = cookie; - Ok(GeneratedEdgeCookie::default()) - } - } - - #[test] - fn a_provider_reads_request_cookies_from_the_request_info() { - // RequestInfo contract: a provider given request info that carries - // headers can read request cookies through it (a client that stores - // values in cookies relies on this). The organic generate path passes - // no header snapshot; a caller that has headers supplies them. - let mut headers = http::HeaderMap::new(); - headers.insert( - "cookie", - "client-id=abc123; ts-ec=xyz" - .parse() - .expect("should build a valid cookie header"), - ); - let request_info = OwnedRequestInfo::new("203.0.113.7".to_owned(), headers); - let provider = CookieCapturingProvider { - seen_cookie: std::sync::Mutex::new(None), - }; - - provider - .generate(&request_info, &IdentityInput::default()) - .expect("generation should succeed"); - - assert_eq!( - provider - .seen_cookie - .lock() - .expect("should lock seen cookie") - .as_deref(), - Some("client-id=abc123; ts-ec=xyz"), - "the provider should read the request cookies from the request info" - ); - } - /// A provider whose identifiers are opaque and deliberately not the /// built-in HMAC shape (no dot, mixed case), modeling a vendor identifier /// such as a signed envelope. It accepts any of its own non-empty @@ -962,83 +856,6 @@ mod tests { ); } - /// A provider that records the request query parameter `id` and the `Cookie` - /// header it is given at generate time, proving request evidence (parameters - /// and cookies) reaches a provider through the organic generate path. - #[derive(Debug, Default)] - struct EvidenceCapturingProvider { - seen: std::sync::Mutex>, - } - - impl EdgeCookieProvider for EvidenceCapturingProvider { - fn id(&self) -> &'static str { - "evidence" - } - - fn code(&self) -> ProviderCode { - ProviderCode::new("t0ev") - } - - fn generate( - &self, - request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - ) -> Result> { - let query_id = request_info.query_param("id").unwrap_or_default(); - let cookie = request_info.header("cookie").unwrap_or_default().to_owned(); - *self.seen.lock().expect("should lock seen evidence") = Some((query_id, cookie)); - Ok(GeneratedEdgeCookie { - id: Some("evidence-ec".to_owned()), - response_headers: Vec::new(), - }) - } - - fn accepts_id(&self, value: &str) -> bool { - !value.is_empty() - } - } - - #[test] - fn generate_passes_request_parameters_and_cookies_to_the_provider() { - use crate::platform::test_support::noop_services_with_ec_provider; - - let provider = Arc::new(EvidenceCapturingProvider::default()); - let mut settings = create_test_settings(); - settings.ec.provider = Some(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 minted should be committed under its code" - ); - } - /// A provider that mints an opaque, mixed-case, non-HMAC identifier at the /// edge, so a test can prove such an identifier persists to the KV identity /// graph under its own value as the key. @@ -1116,23 +933,56 @@ mod tests { ); } + /// 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 { + ProviderCode::new("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()); + // one, not to core. A provider that derives identity from something + // else runs on a host that cannot determine a client IP, and receives + // the documented unavailable value, the empty string. + let provider = Arc::new(NoClientIpProvider::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"); + settings.ec.provider = Some(EcProviderSelection::from("no-client-ip")); + let req = create_test_request(&[]); let services = noop_services_with_ec_provider_without_client_ip(provider.clone()); let geo = non_regulated_geo(); @@ -1148,9 +998,18 @@ mod tests { .expect("a provider that reads no client IP should still mint"); assert_eq!( ec.ec_value(), - Some("t0ev~evidence-ec"), + Some("t0ni~no-ip-ec"), "the identifier should be committed with no client IP available" ); + assert_eq!( + provider + .seen_client_ip + .lock() + .expect("should lock the seen client IP") + .as_deref(), + Some(""), + "a host with no client IP should pass the empty string, not fail the call" + ); } #[test] diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index d833eaea8..4db672919 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -11,10 +11,12 @@ //! //! 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, so no per-request snapshot is stored or cloned. +//! which carries the normalized client IP, for the life of the call, alongside +//! an [`IdentityInput`] holding the request's gating context. A provider reads +//! what it needs and retains nothing, so no per-request snapshot is stored or +//! cloned. `RequestInfo` is the seam rather than a fixed parameter list, so a +//! provider needing further evidence gains a defaulted accessor for it in the +//! change that first reads it. //! //! [`HmacProvider`] is the built-in server-side implementation. It derives the //! identifier from the client IP using HMAC over the configured passphrase, the diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index f742f8997..67ffc0aba 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -26,9 +26,8 @@ use crate::settings::Settings; /// Routes through the pluggable provider model: the active `[ec] provider` /// selection decides the outcome. Returns `Ok(None)` when no provider is /// configured, so Trusted Server runs statelessly and mints no Edge Cookie. -/// `request_headers` lets a provider that derives identity from request -/// evidence read it; the built-in HMAC provider ignores it and uses only the -/// normalized client IP. +/// The built-in HMAC provider derives the identifier from the normalized client +/// IP alone. /// /// # Errors /// @@ -40,7 +39,6 @@ use crate::settings::Settings; pub fn generate_ec_id( settings: &Settings, services: &RuntimeServices, - 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 @@ -58,9 +56,9 @@ pub fn generate_ec_id( return Ok(None); }; - // The provider reads request data (for example the client IP) borrowed at - // call time, so nothing is cloned. - let request_info = BorrowedRequestInfo::new(&client_ip, request_headers); + // The provider reads request data (the client IP) borrowed at call time, so + // nothing is cloned. + let request_info = BorrowedRequestInfo::new(&client_ip); // 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 @@ -193,7 +191,7 @@ pub(crate) fn get_or_generate_ec_id_from_http_request( } // If no existing EC ID found, generate a fresh one through the provider. - let ec_id = generate_ec_id(settings, services, Some(req.headers()))?; + let ec_id = generate_ec_id(settings, services)?; if ec_id.is_some() { log::trace!("No existing EC ID found; generated a fresh EC ID"); } @@ -236,7 +234,7 @@ mod tests { 0x2001, 0x0db8, 0x85a3, 0x0000, 0x8a2e, 0x0370, 0x7334, 0x1234, )); - let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) .expect("should generate EC ID via edge_cookie") .expect("should configure the hmac provider in test settings"); let passphrase = settings @@ -302,7 +300,7 @@ mod tests { fn test_generate_ec_id() { let settings: Settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, &noop_services(), None) + let ec_id = generate_ec_id(&settings, &noop_services()) .expect("should generate EC ID") .expect("should configure the hmac provider in test settings"); log::debug!("Generated EC ID: {}", ec_id); @@ -318,7 +316,7 @@ mod tests { // No provider selected: Trusted Server runs statelessly. settings.ec.provider = None; - let id = generate_ec_id(&settings, &noop_services(), None) + let id = generate_ec_id(&settings, &noop_services()) .expect("generation should not error when no provider is configured"); assert!( id.is_none(), @@ -331,10 +329,10 @@ 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), None) + let id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip)) .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) + let id_without_ip = generate_ec_id(&settings, &noop_services()) .expect("should generate EC ID without client IP") .expect("should configure the hmac provider in test settings"); diff --git a/crates/trusted-server-core/src/evidence.rs b/crates/trusted-server-core/src/evidence.rs index 82eb408ea..fc198c004 100644 --- a/crates/trusted-server-core/src/evidence.rs +++ b/crates/trusted-server-core/src/evidence.rs @@ -11,104 +11,38 @@ //! only when snapshotted, so an implementation owns its data where needed. //! [`BorrowedRequestInfo`] is the borrowed view core builds on the request //! path, and [`OwnedRequestInfo`] is the built-in owned snapshot. - -use http::HeaderMap; +//! +//! [`RequestInfo`] carries the evidence a provider in this workspace actually +//! reads, which today is the normalized client IP. It is the seam rather than a +//! fixed parameter list, so an accessor for further evidence (the User-Agent, +//! request headers, the request target) is added as a defaulted method in the +//! change that first reads it, without breaking any existing implementation. /// Read-only access to the current request's basic information. /// -/// The request data any host can supply: the normalized client IP, the -/// User-Agent, and request headers. A provider receives it by reference at call -/// time (`generate`/`detect`), reads what it needs, and does not retain it. +/// A provider receives it by reference at call time (`generate`), reads what it +/// needs, and does not retain it. pub trait RequestInfo: Send + Sync + core::fmt::Debug { /// The normalized client IP, or `""` when the host cannot determine it. fn client_ip(&self) -> &str; - - /// The `User-Agent` header value, or `""` when absent. - fn user_agent(&self) -> &str; - - /// An arbitrary request header by name (case-insensitive), or `None`. - /// - /// Request cookies are read through this, from the `Cookie` header (a - /// provider that stores values in cookies parses them from it). - fn header(&self, name: &str) -> Option<&str>; - - /// The names of all request headers present, for a provider that enumerates - /// evidence (for example to forward client hints). The default is empty. - fn header_names(&self) -> Vec<&str> { - Vec::new() - } - - /// The request path (the URL path, without the query string), or `""` when - /// request info was built without a URL. - /// - /// A provider reads the request target through this together with - /// [`query`](Self::query); `RequestInfo` is the evidence abstraction, so more - /// request accessors can be added here (as defaulted methods) without - /// breaking existing implementations. - fn path(&self) -> &str { - "" - } - - /// The raw request query string (the part after `?`, without the leading - /// `?`), or `""` when the request carried none. - /// - /// A provider reads request parameters through this, or the - /// [`query_param`](Self::query_param) convenience. The default is empty, for - /// request info built without a URL. - fn query(&self) -> &str { - "" - } - - /// The first value of query parameter `name`, percent-decoded, or `None` - /// when the parameter is absent. - /// - /// Parses [`query`](Self::query) with `application/x-www-form-urlencoded` - /// rules, matching how the browser encodes query parameters. - fn query_param(&self, name: &str) -> Option { - url::form_urlencoded::parse(self.query().as_bytes()) - .find_map(|(key, value)| (&*key == name).then(|| value.into_owned())) - } } /// An owned [`RequestInfo`] built from a request snapshot. /// -/// Owns the client IP and a header snapshot, for a context that cannot borrow -/// the live request for the duration of the call. The request path uses -/// [`BorrowedRequestInfo`]; this owned variant serves tests and any future -/// host whose request data cannot be borrowed. +/// Owns its data, for a context that cannot borrow the live request for the +/// duration of the call. The request path uses [`BorrowedRequestInfo`]; this +/// owned variant serves tests and any future host whose request data cannot be +/// borrowed. #[derive(Debug, Default, Clone)] pub struct OwnedRequestInfo { client_ip: String, - headers: HeaderMap, - path: String, - query: String, } impl OwnedRequestInfo { - /// Builds owned request info from the client IP and a header snapshot. - /// - /// The request target ([`path`](RequestInfo::path) and - /// [`query`](RequestInfo::query)) is empty; attach it with - /// [`with_request_target`](Self::with_request_target) when the caller has the - /// URL. - #[must_use] - pub fn new(client_ip: String, headers: HeaderMap) -> Self { - Self { - client_ip, - headers, - path: String::new(), - query: String::new(), - } - } - - /// Attaches the request target (URL path and query string) to this snapshot, - /// so a provider can read request parameters through - /// [`query_param`](RequestInfo::query_param). + /// Builds owned request info from the normalized client IP. #[must_use] - pub fn with_request_target(mut self, path: String, query: String) -> Self { - self.path = path; - self.query = query; - self + pub fn new(client_ip: String) -> Self { + Self { client_ip } } } @@ -116,72 +50,24 @@ impl RequestInfo for OwnedRequestInfo { fn client_ip(&self) -> &str { &self.client_ip } - - fn user_agent(&self) -> &str { - self.headers - .get(http::header::USER_AGENT) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - } - - fn header(&self, name: &str) -> Option<&str> { - self.headers.get(name).and_then(|value| value.to_str().ok()) - } - - fn header_names(&self) -> Vec<&str> { - self.headers.keys().map(http::HeaderName::as_str).collect() - } - - fn path(&self) -> &str { - &self.path - } - - fn query(&self) -> &str { - &self.query - } } /// A borrowed [`RequestInfo`] over the live request, with no allocation. /// -/// The composition root builds one per request from the normalized client IP and -/// an optional borrow of the request headers, then passes it to a provider by -/// shared reference at call time (`generate`/`detect`). It borrows rather than -/// owns, so it must not outlive the request. A provider reads it during the call -/// and does not retain it, so no per-request `HeaderMap` clone is needed. +/// The composition root builds one per request from the normalized client IP, +/// then passes it to a provider by shared reference at call time (`generate`). +/// It borrows rather than owns, so it must not outlive the request. A provider +/// reads it during the call and does not retain it. #[derive(Debug)] pub struct BorrowedRequestInfo<'a> { client_ip: &'a str, - headers: Option<&'a HeaderMap>, - path: &'a str, - query: &'a str, } impl<'a> BorrowedRequestInfo<'a> { - /// Borrows request info from the client IP and optional request headers. - /// - /// Pass `None` for headers on a path that only needs the client IP. The - /// request target ([`path`](RequestInfo::path) and - /// [`query`](RequestInfo::query)) is empty; attach it with - /// [`with_request_target`](Self::with_request_target) when the caller has the - /// URL. - #[must_use] - pub fn new(client_ip: &'a str, headers: Option<&'a HeaderMap>) -> Self { - Self { - client_ip, - headers, - path: "", - query: "", - } - } - - /// Attaches the borrowed request target (URL path and query string), so a - /// provider can read request parameters through - /// [`query_param`](RequestInfo::query_param). + /// Borrows request info from the normalized client IP. #[must_use] - pub fn with_request_target(mut self, path: &'a str, query: &'a str) -> Self { - self.path = path; - self.query = query; - self + pub fn new(client_ip: &'a str) -> Self { + Self { client_ip } } } @@ -189,107 +75,35 @@ impl RequestInfo for BorrowedRequestInfo<'_> { fn client_ip(&self) -> &str { self.client_ip } - - fn user_agent(&self) -> &str { - self.headers - .and_then(|headers| headers.get(http::header::USER_AGENT)) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - } - - fn header(&self, name: &str) -> Option<&str> { - self.headers - .and_then(|headers| headers.get(name)) - .and_then(|value| value.to_str().ok()) - } - - fn header_names(&self) -> Vec<&str> { - self.headers - .map(|headers| headers.keys().map(http::HeaderName::as_str).collect()) - .unwrap_or_default() - } - - fn path(&self) -> &str { - self.path - } - - fn query(&self) -> &str { - self.query - } } #[cfg(test)] mod tests { use super::*; - fn headers_with_cookie() -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert( - "cookie", - "client-id=abc123; ts-ec=xyz" - .parse() - .expect("should parse cookie header"), - ); - headers - } - #[test] - fn query_param_decodes_and_selects_the_first_value() { - let info = OwnedRequestInfo::new(String::new(), HeaderMap::new()) - .with_request_target("/page".to_owned(), "id=a%20b&id=second&flag=1".to_owned()); + fn owned_request_info_reports_the_client_ip_it_was_built_with() { + let info = OwnedRequestInfo::new("203.0.113.5".to_owned()); - assert_eq!( - info.query_param("id").as_deref(), - Some("a b"), - "should percent-decode and return the first value for a repeated key" - ); - assert_eq!(info.query_param("flag").as_deref(), Some("1")); - assert_eq!( - info.query_param("missing"), - None, - "an absent parameter should be None" - ); + assert_eq!(info.client_ip(), "203.0.113.5"); } #[test] - fn path_and_query_accessors_return_the_request_target() { - let info = OwnedRequestInfo::new(String::new(), HeaderMap::new()) - .with_request_target("/a/b".to_owned(), "x=1".to_owned()); - assert_eq!(info.path(), "/a/b"); - assert_eq!(info.query(), "x=1"); - } + fn owned_request_info_defaults_to_no_client_ip() { + let info = OwnedRequestInfo::default(); - #[test] - fn request_info_defaults_to_an_empty_target() { - let info = OwnedRequestInfo::new("203.0.113.5".to_owned(), HeaderMap::new()); - assert_eq!(info.path(), "", "path should default to empty"); - assert_eq!(info.query(), "", "query should default to empty"); - assert_eq!( - info.query_param("id"), - None, - "query_param over an empty query should be None" - ); - } - - #[test] - fn a_provider_reads_cookies_from_the_header() { - let info = OwnedRequestInfo::new("203.0.113.5".to_owned(), headers_with_cookie()); assert_eq!( - info.header("cookie"), - Some("client-id=abc123; ts-ec=xyz"), - "cookies are read through the Cookie header" + info.client_ip(), + "", + "a host that cannot determine a client IP reports an empty one" ); } #[test] - fn borrowed_request_info_exposes_the_same_target() { - let headers = headers_with_cookie(); - let info = BorrowedRequestInfo::new("203.0.113.5", Some(&headers)) - .with_request_target("/page", "id=abc123"); + fn borrowed_request_info_reports_the_same_client_ip() { + let client_ip = "203.0.113.5".to_owned(); + let info = BorrowedRequestInfo::new(&client_ip); - assert_eq!(info.path(), "/page"); - assert_eq!(info.query(), "id=abc123"); - assert_eq!(info.query_param("id").as_deref(), Some("abc123")); - assert_eq!(info.header("cookie"), Some("client-id=abc123; ts-ec=xyz")); + assert_eq!(info.client_ip(), "203.0.113.5"); } } From b146aeb62b43bdd51ed0597e07a09b808844c120 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 09:42:21 +0100 Subject: [PATCH 22/36] Collapse the EC provider selector to statelessness and a named provider Every Edge Cookie provider goes through one mechanism and none of them is special, so the selector no longer carries a variant for the built-in HMAC provider. `EcProviderSelection` is now `None` for explicit statelessness and `Named(String)` for a provider chosen by name, and `hmac` is an ordinary name in the same open-ended namespace a vendor crate names its own provider from. A variant per provider wrote the special case into the type, so every match on it had to know that one provider was different, and the built-in provider is due to become a vendor-supplied module rather than living in core. The one place that still knows `hmac` is built into core is the resolution in `build_provider`, lifted into `resolve_named_provider` and commented to say that it goes when the built-in provider becomes a module, after which `hmac` resolves through the injected path like any other name. Nothing else branches on whether a name is built in. `Ec::validate_provider_selection` is now a name lookup through the new `EcProviders::has_block`, and the unreferenced-block check reads the new `EcProviders::configured_keys` rather than pushing `hmac` in by hand, which also drops `has_vendor` and `vendor_keys`, both of which only answered for names that are not built in. `EcProviderSelection::HMAC_KEY` becomes the module-level `HMAC_PROVIDER_KEY` beside `HMAC_PROVIDER_CODE`, because the selection type should not name any one provider. The configuration surface does not change. `[ec] provider = "none"`, `"hmac"` and any vendor key parse to the same behavior and are written back as exactly the same string, which the round-trip test now proves on the serialized scalar itself rather than only on the surrounding document text. This is not the reviewer's finding about scattered string literals, which the typed selector already fixed. It is the project's own rule that no provider is special. --- crates/trusted-server-core/src/ec/provider.rs | 217 +++++++++++------- crates/trusted-server-core/src/settings.rs | 96 ++++---- 2 files changed, 184 insertions(+), 129 deletions(-) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 4db672919..0503995ed 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -39,15 +39,20 @@ 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. Vendor keys are -/// open-ended (a vendor crate names its own), so any key that is not a -/// built-in becomes [`Vendor`](Self::Vendor) rather than a parse failure, and -/// whether the deployment can actually supply it is decided by -/// [`build_provider`]. +/// 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`]. /// -/// This is the one place the provider keys are spelled. Everything that needs -/// to ask which provider is selected matches on this rather than comparing -/// string literals. +/// 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 { @@ -56,30 +61,23 @@ pub enum EcProviderSelection { /// configured. None, - /// The built-in HMAC provider, spelled `"hmac"`, configured by - /// `[ec.providers.hmac]`. - Hmac, - - /// A vendor or host provider the adapter injects, named by its own key and - /// configured by the matching `[ec.providers.]` block. - Vendor(String), + /// 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 spelling of the built-in HMAC provider, which is also - /// [`HmacProvider::id`]'s return value and [`HMAC_PROVIDER_CODE`]'s text. - pub const HMAC_KEY: &'static str = "hmac"; - /// The configuration key this selection is written as. #[must_use] pub fn key(&self) -> &str { match self { Self::None => Self::NONE_KEY, - Self::Hmac => Self::HMAC_KEY, - Self::Vendor(key) => key, + Self::Named(key) => key, } } } @@ -88,8 +86,7 @@ impl From<&str> for EcProviderSelection { fn from(key: &str) -> Self { match key { EcProviderSelection::NONE_KEY => Self::None, - EcProviderSelection::HMAC_KEY => Self::Hmac, - other => Self::Vendor(other.to_owned()), + other => Self::Named(other.to_owned()), } } } @@ -98,8 +95,7 @@ impl From for EcProviderSelection { fn from(key: String) -> Self { match key.as_str() { EcProviderSelection::NONE_KEY => Self::None, - EcProviderSelection::HMAC_KEY => Self::Hmac, - _ => Self::Vendor(key), + _ => Self::Named(key), } } } @@ -108,19 +104,27 @@ impl From for String { fn from(selection: EcProviderSelection) -> Self { match selection { EcProviderSelection::None => EcProviderSelection::NONE_KEY.to_owned(), - EcProviderSelection::Hmac => EcProviderSelection::HMAC_KEY.to_owned(), - EcProviderSelection::Vendor(key) => key, + 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 registry code of the built-in HMAC provider. /// -/// The same text as [`EcProviderSelection::HMAC_KEY`], but a different role: -/// this is the `{code}~` namespace stamped on every identifier the built-in -/// provider mints, and it is what [`generation`] matches when it decides -/// whether an enveloped identifier is one of its own. -pub const HMAC_PROVIDER_CODE: ProviderCode = ProviderCode::new(EcProviderSelection::HMAC_KEY); +/// The same text as [`HMAC_PROVIDER_KEY`], but a different role: this is the +/// `{code}~` namespace stamped on every identifier the built-in provider +/// mints, and it is what [`generation`] matches when it decides whether an +/// enveloped identifier is one of its own. +pub const HMAC_PROVIDER_CODE: ProviderCode = ProviderCode::new(HMAC_PROVIDER_KEY); /// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. /// @@ -368,9 +372,7 @@ pub fn split_provider_code(full: &str) -> (Option<&str>, &str) { 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() == EcProviderSelection::HMAC_KEY && provider.accepts_id(value) - } + (None, value) => provider.id() == HMAC_PROVIDER_KEY && provider.accepts_id(value), } } @@ -443,7 +445,7 @@ impl<'a> AcceptedProviders<'a> { let (code, _) = split_provider_code(full); self.readers.iter().copied().find(|provider| match code { Some(code) => provider.code().as_str() == code, - None => provider.id() == EcProviderSelection::HMAC_KEY, + None => provider.id() == HMAC_PROVIDER_KEY, }) } @@ -578,7 +580,7 @@ impl HmacProvider { impl EdgeCookieProvider for HmacProvider { fn id(&self) -> &'static str { - EcProviderSelection::HMAC_KEY + HMAC_PROVIDER_KEY } fn code(&self) -> ProviderCode { @@ -616,11 +618,10 @@ impl EdgeCookieProvider for HmacProvider { /// /// # Errors /// -/// Returns [`TrustedServerError::EdgeCookie`] when the selected provider cannot -/// be built: `"hmac"` without an `[ec.providers.hmac]` block, or a vendor key -/// this deployment's adapter does not inject. Both fail loudly rather than -/// leaving the deployment running stateless under a selector that says -/// otherwise. +/// 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>, @@ -631,45 +632,68 @@ pub fn build_provider( let provider: Option> = match selection { // Explicit statelessness: the same meaning as omitting the selector. EcProviderSelection::None => None, - // Settings validation rejects `hmac` with no block before this runs, so - // reaching here means the two checks have drifted apart. Stopping is - // the only safe answer: returning `Ok(None)` would run the deployment - // stateless under a selector that says it has an identity provider. - EcProviderSelection::Hmac => { - 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(), - }) - })?; - Some(Box::new(HmacProvider::new(config.passphrase.clone())) as _) - } - // A vendor key names a vendor or host provider the adapter injects - // through [`RuntimeServices`](crate::platform::RuntimeServices), the same - // seam the device and geo providers use, so core never names a vendor. - // The injected provider is used when its own id matches the selected key, - // and its `[ec.providers.]` block is read by the adapter that built - // it. A selected key with no matching injected provider is a deployment - // error: fail loudly rather than silently running stateless. - EcProviderSelection::Vendor(key) => { - let provider = injected - .filter(|provider| provider.id() == key) - .map(|provider| Box::new(SharedProvider(provider)) as _); - if provider.is_none() { - return Err(Report::new(TrustedServerError::EdgeCookie { - message: format!( - "Edge Cookie provider `{key}` is selected but this deployment's \ - adapter does not provide it" - ), - })); - } - provider - } + // Every provider is named, and this is the one place a name is resolved + // to an implementation. Nothing else in the codebase asks whether a + // name is built in. + EcProviderSelection::Named(key) => Some(resolve_named_provider(key, ec, injected)?), }; 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. +/// +/// # 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. /// @@ -933,8 +957,11 @@ mod tests { // working and a config push does not rewrite the selector. for (key, expected) in [ (EcProviderSelection::NONE_KEY, EcProviderSelection::None), - (EcProviderSelection::HMAC_KEY, EcProviderSelection::Hmac), - ("acme", EcProviderSelection::Vendor("acme".to_owned())), + ( + 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"); @@ -943,12 +970,36 @@ mod tests { 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" + ); } } @@ -972,7 +1023,7 @@ mod tests { passphrase: test_passphrase(), }); let hmac = Ec { - provider: Some(EcProviderSelection::Hmac), + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), providers, ..Ec::default() }; @@ -981,7 +1032,7 @@ mod tests { .expect("the hmac selection should yield a provider"); assert_eq!( built.id(), - EcProviderSelection::HMAC_KEY, + HMAC_PROVIDER_KEY, "`hmac` should select the built-in provider" ); assert_eq!( @@ -993,7 +1044,7 @@ mod tests { // An arbitrary vendor key selects the provider the adapter injected // under that same key. let vendor = Ec { - provider: Some(EcProviderSelection::Vendor("acme".to_owned())), + provider: Some(EcProviderSelection::Named("acme".to_owned())), ..Ec::default() }; let built = build_provider(&vendor, Some(Arc::new(VendorProvider))) @@ -1132,7 +1183,7 @@ mod tests { // 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::Hmac), + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), ..Ec::default() }; diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index a39a8ff87..5a5a55f3c 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -20,7 +20,7 @@ use crate::cache_policy::{CachePolicy, CacheVisibility}; use crate::consent_config::ConsentConfig; use crate::constants::INTERNAL_HEADERS; use crate::creative_opportunities::CreativeOpportunitiesConfig; -use crate::ec::provider::EcProviderSelection; +use crate::ec::provider::{EcProviderSelection, HMAC_PROVIDER_KEY}; use crate::error::TrustedServerError; use crate::host_header::validate_host_header_override_value; use crate::platform::PlatformImageOptimizerRegion; @@ -627,33 +627,27 @@ impl Ec { return Ok(()); }; - let (key, configured) = match selection { - // `"none"` is explicit statelessness: the same meaning as omitting - // the selector, spelled out. It is subject to the same rule that no - // provider blocks may be left configured. - EcProviderSelection::None => { - if !self.providers.is_empty() { - return Err(Report::new(TrustedServerError::Configuration { - message: "[ec] provider = \"none\" selects stateless operation, but \ - [ec.providers.*] blocks are configured. Remove the blocks, or \ - select the provider they configure" - .to_owned(), - })); - } - return Ok(()); - } - EcProviderSelection::Hmac => { - (EcProviderSelection::HMAC_KEY, self.providers.hmac.is_some()) - } - // A vendor or host provider the adapter injects is configured when - // its `[ec.providers.]` block is present. The adapter validates - // the block's own contents when it builds the provider. - EcProviderSelection::Vendor(vendor_key) => { - (vendor_key.as_str(), self.providers.has_vendor(vendor_key)) + // `"none"` is explicit statelessness: the same meaning as omitting the + // selector, spelled out. It is subject to the same rule that no + // provider blocks may be left configured. + let EcProviderSelection::Named(key) = selection else { + if !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider = \"none\" selects stateless operation, but \ + [ec.providers.*] blocks are configured. Remove the blocks, or \ + select the provider they configure" + .to_owned(), + })); } + return Ok(()); }; - if !configured { + // Every provider is configured by the `[ec.providers.]` block that + // carries its own name, so the check is the same lookup for all of + // them. A provider the adapter injects has the contents of its block + // validated by that adapter when it builds the provider. + let key = key.as_str(); + if !self.providers.has_block(key) { return Err(Report::new(TrustedServerError::Configuration { message: format!( "Edge Cookie provider `{key}` is selected but has no `[ec.providers.{key}]` configuration" @@ -664,15 +658,12 @@ impl Ec { // Every configured block must be the selected one. An unreferenced // block is almost always a mistake (a mistyped selector or a stale // block), and accepting it silently invites configuration drift. - let mut unreferenced: Vec = Vec::new(); - if self.providers.hmac.is_some() && !matches!(selection, EcProviderSelection::Hmac) { - unreferenced.push(EcProviderSelection::HMAC_KEY.to_owned()); - } - for vendor_key in self.providers.vendor_keys() { - if vendor_key != key { - unreferenced.push(vendor_key.to_owned()); - } - } + let unreferenced: Vec = self + .providers + .configured_keys() + .filter(|configured| *configured != key) + .map(str::to_owned) + .collect(); if unreferenced.is_empty() { Ok(()) } else { @@ -730,7 +721,7 @@ impl Ec { "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ set [ec] provider = \"hmac\"" ); - self.provider = Some(EcProviderSelection::Hmac); + self.provider = Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)); self.providers.hmac = Some(HmacProviderConfig { passphrase }); Ok(()) } @@ -774,15 +765,28 @@ impl EcProviders { self.vendor.get(key) } - /// Whether a vendor provider configuration block is present for `key`. + /// Whether a `[ec.providers.]` block is present for `key`. + /// + /// The answer is the same question for every provider, whichever crate + /// supplies it, so nothing calling this has to know which providers are + /// built into core. #[must_use] - pub fn has_vendor(&self, key: &str) -> bool { - self.vendor.contains_key(key) + pub fn has_block(&self, key: &str) -> bool { + self.configured_keys().any(|configured| configured == key) } - /// The keys of the configured vendor provider blocks. - pub(crate) fn vendor_keys(&self) -> impl Iterator { - self.vendor.keys().map(String::as_str) + /// The keys of every configured `[ec.providers.]` block. + /// + /// The typed built-in block is reported under the name it is configured + /// with, so it appears alongside the vendor blocks rather than being + /// counted separately by each caller. This is the one place that mapping is + /// made, and it goes away when the built-in provider becomes a module and + /// its block joins the others. + pub(crate) fn configured_keys(&self) -> impl Iterator { + self.hmac + .iter() + .map(|_| HMAC_PROVIDER_KEY) + .chain(self.vendor.keys().map(String::as_str)) } /// Whether any provider configuration block is present. @@ -792,7 +796,7 @@ impl EcProviders { /// would otherwise silently run stateless. #[must_use] pub fn is_empty(&self) -> bool { - self.hmac.is_none() && self.vendor.is_empty() + self.configured_keys().next().is_none() } } @@ -4617,7 +4621,7 @@ mod tests { assert_eq!(settings.publisher.origin_host_header_override, None); assert_eq!( settings.ec.provider.as_ref(), - Some(&EcProviderSelection::Hmac), + Some(&EcProviderSelection::from(HMAC_PROVIDER_KEY)), "test settings should select the hmac EC provider" ); let Some(hmac) = &settings.ec.providers.hmac else { @@ -5066,7 +5070,7 @@ mod tests { .expect("should migrate the deprecated form"); assert_eq!( ec.provider.as_ref(), - Some(&EcProviderSelection::Hmac), + Some(&EcProviderSelection::from(HMAC_PROVIDER_KEY)), "the deprecated passphrase should select the hmac provider" ); assert_eq!( @@ -5130,7 +5134,7 @@ mod tests { .expect("a legacy passphrase of adequate length should still start"); assert_eq!( settings.ec.provider.as_ref(), - Some(&EcProviderSelection::Hmac), + Some(&EcProviderSelection::from(HMAC_PROVIDER_KEY)), "an adequate legacy passphrase should still select the hmac provider" ); assert_eq!( @@ -5228,7 +5232,7 @@ mod tests { fn legacy_passphrase_alongside_provider_config_is_rejected() { let mut ec = Ec { passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), - provider: Some(EcProviderSelection::Hmac), + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), ..Ec::default() }; let err = ec From 7c0428dc9e98cef8a95d2e2ac2fc9f6c583a51d2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 13:12:38 +0100 Subject: [PATCH 23/36] Accumulate provider response headers instead of replacing the origin's The response EC finalization edits is the finished one, so it already carries whatever the publisher's origin returned. The provider-header loop used `HeaderMap::insert`, which drops every existing value for that name, so a provider setting one evidence cookie deleted every `Set-Cookie` the origin had written, a publisher's session and sign-in cookies included, and a provider setting `Vary` deleted the origin's. `response_headers` is a list of pairs precisely so a provider can set more than one cookie, and `insert` collapsed those too. The rule, written out on the new `apply_provider_response_headers`, is that this seam is additive. A provider only ever adds evidence about the request, it never corrects the origin's output, so core has no grounds to discard a value it did not write. `Set-Cookie` can never be folded into one field line, the list-valued headers a provider realistically sets (`Vary` above all) mean the union of their field lines, and the single-valued headers where replacing would be right are exactly the ones `reserved_response_effect` already fails the request for. So nothing a provider may set here needs to replace, and appending is the direction that cannot silently destroy someone else's header. No test anywhere covered a provider header reaching a response. The new one drives a provider that sets its own cookie and its own `Vary` through the real mint path onto a response the origin has already written to, and asserts the origin's cookie, the provider's cookie, core's own `ts-ec` and both `Vary` entries all survive. --- crates/trusted-server-core/src/ec/finalize.rs | 136 +++++++++++++++++- crates/trusted-server-core/src/ec/provider.rs | 37 +++++ 2 files changed, 169 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 908cb5959..337a02126 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -16,6 +16,7 @@ use super::cookies::{expire_ec_cookie, set_ec_cookie}; 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. @@ -56,10 +57,13 @@ pub fn ec_finalize_response( // 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. - for (name, value) in ec_context.response_headers() { - response.headers_mut().insert(name, value.clone()); - } + // 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(); @@ -862,4 +866,128 @@ mod tests { "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::ec::provider::ProviderCode::new("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 mint 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:?}" + ); + } } diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 0503995ed..606efe4fc 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -266,6 +266,43 @@ pub fn reserved_response_effect( 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. /// From 69d5ed0002c75fd5ee9fc322052d3ecb798b0ea7 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 29 Aug 2026 13:23:50 +0100 Subject: [PATCH 24/36] State what a provider switch really does to existing identities The pluggable-providers spec required, in its provider-switching section, that switching must not strand the identities the previous provider minted and "above all must not make a later opt-out unable to revoke them". It then claimed old cookies stay recognized after a switch whenever the newly selected provider accepts their shape. That claim is false and cannot be made true here. Ownership is decided on the `{code}~` prefix before any provider is asked about shape, and the check is enforced twice: `AcceptedProviders::owner` dispatches on the code, and `canonical_kv_key` re-checks the derived key through `provider_owns_id`. So a newly selected provider rejects every identifier the previous one minted, whatever its shape. The new test drives this and shows the result: after a switch the retired identifier is never adopted, withdrawal still expires the browser cookie, but the retired provider's identity-graph row keeps `consent.ok = true` and is never tombstoned. It then sits for the one-year entry TTL. I changed the spec rather than the code. The mechanism the spec itself names for carrying identities across a switch is the `legacy_providers` reader list, which the same section marks as deferred to the migration spec, and `AcceptedProviders` is already built as the seam for it. Even once it lands the requirement would not hold on its own, because it would depend on the operator listing the retired provider, so an unconditional guarantee was never something the code could provide. The old wording also contradicted section 5 of the same document, which already states the true rule that a cookie carrying another provider's code is treated as absent. The replacement says plainly what a switch does to read-back, to the browser cookie and to the graph rows, and what an operator must do about revocation: deal with the retired provider's rows at the switch, since they are identifiable by that provider's `{code}~` key prefix, or accept that later withdrawals are recorded only in the browser until the TTL expires. The `cookie_ec_kv_key` doc comment claimed the same reach the spec did and is corrected to match. --- crates/trusted-server-core/src/ec/finalize.rs | 124 ++++++++++++++++++ crates/trusted-server-core/src/ec/mod.rs | 16 ++- 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 337a02126..49e3861b9 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -990,4 +990,128 @@ mod tests { "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 minted 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::ec::provider::ProviderCode::new("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 + // minted, 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/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 90dfc06a4..22f559b38 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -505,9 +505,19 @@ impl EcContext { /// 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 and a cookie the active provider no - /// longer mints both leave [`ec_kv_key`](Self::ec_kv_key) empty while a - /// live row still exists. + /// 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 minted + /// 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 switching section of + /// `docs/superpowers/specs/2026-07-30-pluggable-providers-design.md` 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() From 60a1f4b7673ddc8c04704a3430595494aedb7a58 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 08:37:09 +0100 Subject: [PATCH 25/36] Name the design documents rather than their paths in doc comments The provider series design specs move to the spec-only PR (#1084) so they can be reviewed before the code that implements them. Three doc comments cited those files by repository path, which no longer resolves from this branch. Refer to each document by name instead, so the comment stays true whichever PR is read first. --- crates/trusted-server-core/src/ec/mod.rs | 6 +++--- crates/trusted-server-core/src/ec/provider.rs | 20 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 22f559b38..b09e90e37 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -515,9 +515,9 @@ impl EcContext { /// `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 switching section of - /// `docs/superpowers/specs/2026-07-30-pluggable-providers-design.md` for - /// what an operator has to do about it. + /// 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() diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 606efe4fc..b9d02d584 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -306,12 +306,12 @@ where /// The registered short code that namespaces one Edge Cookie provider's /// identifiers. /// -/// Exactly four characters from `[a-z0-9]`, allocated append-only in -/// `docs/superpowers/specs/provider-code-registry.md` and never reused. The -/// code appears as the `{code}~` prefix of every identifier the provider -/// mints, so identifiers from different providers can never collide in the -/// cookie, the identity graph, or a withdrawal, and each identifier records -/// which provider created it. +/// 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 mints, so identifiers +/// from different providers can never collide in the cookie, the identity +/// graph, or a withdrawal, and each identifier records which provider +/// created it. #[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, derive_more::Display)] pub struct ProviderCode(&'static str); @@ -540,10 +540,10 @@ pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { /// identifier it mints. /// /// Mandatory, with no default: a provider must allocate a unique code in - /// `docs/superpowers/specs/provider-code-registry.md` before it can exist, - /// so no two providers can ever mint colliding identifiers. Core applies - /// the code at mint and checks it at read-back, and the provider itself - /// only ever sees its own value part. + /// the provider-code registry before it can exist, so no two providers + /// can ever mint colliding identifiers. Core applies the code at mint and + /// checks it at read-back, and the provider itself only ever sees its own + /// value part. fn code(&self) -> ProviderCode; /// Derives an Edge Cookie identifier from the provider's injected services From ada4d79cc8ff48cbe8f5736f5f14cf491e16c605 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 13:00:35 +0100 Subject: [PATCH 26/36] Restore the line continuations missed in the neighbouring files The mint-rejection fix restored one collapsed continuation in ec/mod.rs and said the rest of that file was clean, which it was. The same fault exists in four more places on this branch, so fixing only the reported one leaves the pattern half addressed. Each was written across two source lines without the trailing backslash, so the source indentation became a run of spaces inside the message: ec/admin.rs:373 the invalid-EC-ID response an operator sees ec/finalize.rs:125 the skipped-response-write log line ec/provider.rs:635 the missing-client-IP error from the HMAC provider ec/pull_sync.rs:72 the skipped-dispatch log line integrations/testlight.rs:196 the no-recognized-EC-ID error The continuation is restored in each, so every message reads as one sentence. The whole of trusted-server-core was scanned for the same shape, matching runs of five or more spaces inside a string literal. The only remaining matches are TOML fixtures in settings.rs tests, where the embedded newlines are deliberate. Addresses: ec/mod.rs:444 follow-up, the same fault outside the file first reported --- crates/trusted-server-core/src/ec/admin.rs | 4 +++- crates/trusted-server-core/src/ec/finalize.rs | 3 ++- crates/trusted-server-core/src/ec/provider.rs | 3 ++- crates/trusted-server-core/src/ec/pull_sync.rs | 3 ++- crates/trusted-server-core/src/integrations/testlight.rs | 3 ++- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index bb15b6892..56be6f44e 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -370,7 +370,9 @@ fn requested_ec_id( if !accepted_providers.accepts(&ec_id) { return Err(Box::new(json_error( StatusCode::BAD_REQUEST, - "invalid EC ID: not an identifier any provider this deployment reads issued (the built-in HMAC provider issues {64hex}.{6alnum}, with or without the hmac~ prefix)", + "invalid EC ID: not an identifier any provider this deployment reads \ + issued (the built-in HMAC provider issues {64hex}.{6alnum}, with or \ + without the hmac~ prefix)", ))); } diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 49e3861b9..33167d1a7 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -122,7 +122,8 @@ pub fn ec_finalize_response( if ec_context.ec_generated() { 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" + "Skipping generated EC response write because the KV graph or the \ + identity-graph key is unavailable" ); return; }; diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index b9d02d584..27898b661 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -632,7 +632,8 @@ impl EdgeCookieProvider for HmacProvider { 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" + message: "Edge Cookie provider `hmac` requires the client IP, and this host \ + could not supply one" .to_owned(), })); } diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index e55baeb40..3b5f43152 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -69,7 +69,8 @@ pub fn build_pull_sync_context(ec_context: &EcContext) -> Option Date: Sun, 30 Aug 2026 13:00:58 +0100 Subject: [PATCH 27/36] Correct the two provider doc comments the earlier pass missed The commit that rewrote the module docs to match the trait signature left two item-level doc comments in the same file still describing constructor injection, so the claim that nothing passes evidence by constructor was contradicted three declarations further down. IdentityInput's doc said request data reaches a provider "through the services injected into its constructor". EdgeCookieProvider::generate's doc said the identifier is derived "from the provider's injected services". Neither matches the signature, which takes request_info: &dyn RequestInfo as a parameter and reads evidence from it. The built-in HMAC provider does exactly that at ec/provider.rs:632. Both now describe the parameter the evidence actually arrives on. The crate was searched for the same wording; the only other mention is in ec/mod.rs on a test-only helper, where it correctly describes how the provider itself is constructed rather than how request evidence reaches it. Addresses: ec/provider.rs:4 follow-up, item docs still describing constructor injection --- crates/trusted-server-core/src/ec/provider.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 27898b661..72d94e30c 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -128,10 +128,11 @@ pub const HMAC_PROVIDER_CODE: ProviderCode = ProviderCode::new(HMAC_PROVIDER_KEY /// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. /// -/// Request data (client IP, User-Agent, headers, host signals) reaches a -/// provider through the services injected into its constructor, not through this -/// struct. This carries only the per-request gating context a provider may read -/// for behavior beyond gating. The gate has already confirmed Edge Cookie +/// 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. The gate has already confirmed Edge Cookie /// storage is allowed before `generate` is called. #[derive(Default)] pub struct IdentityInput<'a> { @@ -546,8 +547,8 @@ pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { /// value part. fn code(&self) -> ProviderCode; - /// Derives an Edge Cookie identifier from the provider's injected services - /// and the request's gating context. + /// Derives an Edge Cookie identifier from the request evidence in + /// `request_info` and the gating context in `input`. /// /// # Errors /// From abdd4cb382598b60ecf4f0dd7394b9667aa683ca Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 17:03:42 +0100 Subject: [PATCH 28/36] Stop a provider code from panicking when a vendor builds one at run time `ProviderCode::new` is public and validated its argument with `assert!`, so any caller outside this workspace could take down a live request by passing a code that was not exactly four characters of [a-z0-9]. The doc comment claimed the panic "never" fires on a request path, which held only for as long as every caller happened to pass a literal, and nothing enforced that. A vendor Edge Cookie provider is exactly the caller the claim could not cover. `new` now returns `Option`, so it cannot panic whatever it is given, and a caller outside core has to handle a malformed code. The compile-time guarantee the codes in this workspace relied on moves into a new `provider_code!` macro, which runs the same check inside a `const` block, so a bad literal fails the build and the value it yields needs no unwrapping. Every code in the workspace, the built-in HMAC code included, now goes through the macro. Addresses: crates/trusted-server-core/src/ec/provider.rs, where `ProviderCode::new` could panic at run time while its documentation said it could not. --- crates/trusted-server-core/src/ec/admin.rs | 2 +- .../trusted-server-core/src/ec/batch_sync.rs | 2 +- crates/trusted-server-core/src/ec/finalize.rs | 4 +- crates/trusted-server-core/src/ec/mod.rs | 12 +- crates/trusted-server-core/src/ec/provider.rs | 114 +++++++++++++++--- .../trusted-server-core/src/ec/pull_sync.rs | 2 +- 6 files changed, 107 insertions(+), 29 deletions(-) diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index 56be6f44e..2fb7c4b5b 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -1543,7 +1543,7 @@ mod tests { } fn code(&self) -> super::super::provider::ProviderCode { - super::super::provider::ProviderCode::new("t0op") + crate::provider_code!("t0op") } fn generate( diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs index e75c02ddb..046cd1da6 100644 --- a/crates/trusted-server-core/src/ec/batch_sync.rs +++ b/crates/trusted-server-core/src/ec/batch_sync.rs @@ -301,7 +301,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0op") + crate::provider_code!("t0op") } fn generate( diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 33167d1a7..12fdc3a25 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -880,7 +880,7 @@ mod tests { } fn code(&self) -> crate::ec::provider::ProviderCode { - crate::ec::provider::ProviderCode::new("t0eh") + crate::provider_code!("t0eh") } fn generate( @@ -1003,7 +1003,7 @@ mod tests { } fn code(&self) -> crate::ec::provider::ProviderCode { - crate::ec::provider::ProviderCode::new("t0sw") + crate::provider_code!("t0sw") } fn generate( diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index b09e90e37..e064ad23d 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -781,7 +781,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0op") + crate::provider_code!("t0op") } fn generate( @@ -878,7 +878,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0so") + crate::provider_code!("t0so") } fn generate( @@ -957,7 +957,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0ni") + crate::provider_code!("t0ni") } fn generate( @@ -1065,7 +1065,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0il") + crate::provider_code!("t0il") } fn generate( @@ -1126,7 +1126,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0hs") + crate::provider_code!("t0hs") } fn generate( @@ -1276,7 +1276,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0ca") + crate::provider_code!("t0ca") } fn generate( diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 72d94e30c..67e880a7a 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -124,7 +124,7 @@ pub const HMAC_PROVIDER_KEY: &str = "hmac"; /// `{code}~` namespace stamped on every identifier the built-in provider /// mints, and it is what [`generation`] matches when it decides whether an /// enveloped identifier is one of its own. -pub const HMAC_PROVIDER_CODE: ProviderCode = ProviderCode::new(HMAC_PROVIDER_KEY); +pub const HMAC_PROVIDER_CODE: ProviderCode = crate::provider_code!(HMAC_PROVIDER_KEY); /// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. /// @@ -317,30 +317,41 @@ where pub struct ProviderCode(&'static str); impl ProviderCode { - /// Creates a provider code, validating the registry format. + /// Creates a provider code when `code` matches the registry format. /// - /// # Panics + /// 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. /// - /// Panics when `code` is not exactly four characters of `[a-z0-9]`. Codes - /// are compile-time literals, so the panic fires in tests and never on a - /// request path. + /// 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) -> Self { + pub const fn new(code: &'static str) -> Option { let bytes = code.as_bytes(); - assert!( - bytes.len() == 4, - "provider code must be exactly four characters" - ); + if bytes.len() != 4 { + return None; + } let mut i = 0; while i < bytes.len() { let b = bytes[i]; - assert!( - b.is_ascii_lowercase() || b.is_ascii_digit(), - "provider code characters must be [a-z0-9]" - ); + if !b.is_ascii_lowercase() && !b.is_ascii_digit() { + return None; + } i += 1; } - Self(code) + Some(Self(code)) } /// The code as a string slice. @@ -350,6 +361,34 @@ impl ProviderCode { } } +/// 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 @@ -797,6 +836,45 @@ 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!( @@ -919,7 +997,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0ac") + crate::provider_code!("t0ac") } fn generate( @@ -1162,7 +1240,7 @@ mod tests { } fn code(&self) -> ProviderCode { - ProviderCode::new("t0in") + crate::provider_code!("t0in") } fn generate( diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 3b5f43152..0cf8cf214 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -518,7 +518,7 @@ mod tests { } fn code(&self) -> crate::ec::provider::ProviderCode { - crate::ec::provider::ProviderCode::new("t0op") + crate::provider_code!("t0op") } fn generate( From 138dc3acd1dffbe42f1fab089546095678061f39 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 17:09:42 +0100 Subject: [PATCH 29/36] Refuse two Edge Cookie providers claiming the same name `resolve_named_provider` looked for a built-in provider before the one the adapter injects, so a vendor provider whose id is `hmac` was dropped in favour of core's own and nothing said so. Nothing reserved the name and nothing warned, which left an operator with a configured vendor provider that never ran and no way to see why. This is not only a missing warning. Once this work merges, IAB Tech Lab is itself a vendor shipping an HMAC provider while core still ships one, so two suppliers really can arrive under one name in a single deployment, and there is no correct way to pick between them. `build_provider` now refuses that pair through `ensure_no_name_collision` and the error names both claimants, core and the deployment's adapter, along with the contested name. The check runs before the selector is read, so selecting a different provider does not hide the clash, and because the adapters call it through `ensure_provider_available` while they build application state, an operator is told at startup rather than on the first request that happens to select the name. Addresses: crates/trusted-server-core/src/ec/provider.rs, where `resolve_named_provider` silently preferred the built-in `hmac` provider over an injected one of the same name. --- crates/trusted-server-core/src/ec/provider.rs | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index 67e880a7a..af22a9a51 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -118,6 +118,16 @@ impl From for String { /// 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 @@ -685,6 +695,47 @@ impl EdgeCookieProvider for HmacProvider { } } +/// 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, /// injecting the services it needs. /// @@ -704,6 +755,7 @@ 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); }; @@ -727,6 +779,10 @@ pub fn build_provider( /// 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 @@ -1278,6 +1334,82 @@ mod tests { ); } + /// 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 { From 9f4061a6a31146ef3a43024c620e0ee006078f8d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 17:13:49 +0100 Subject: [PATCH 30/36] Build the internal header list from the Edge Cookie response headers `EC_RESPONSE_HEADERS` in the EC finalization module and the first four entries of `INTERNAL_HEADERS` in the constants module were the same four header names written out twice, in two files, with nothing keeping them in step. The two lists do different jobs, one is stripped from a response the request may not carry an identity on and the other is never forwarded to a third party, but every Edge Cookie output header has to be in both, so adding a fifth to one and forgetting the other would send Edge Cookie output to an origin that should never see it. `EC_RESPONSE_HEADERS` now lives once, in the constants module, and `INTERNAL_HEADERS` is assembled from it and the remaining internal names while the crate is compiled, so the Edge Cookie half cannot be edited in one place and missed in the other. EC finalization reads the same constant instead of keeping a copy. The new test in the constants module asserts the containment, the total, and that no name appears twice, so going back to two hand-written lists fails the build. Addresses: crates/trusted-server-core/src/ec/finalize.rs and crates/trusted-server-core/src/constants.rs, where one list of Edge Cookie response headers was maintained by hand in two places. --- crates/trusted-server-core/src/constants.rs | 97 +++++++++++++++++-- crates/trusted-server-core/src/ec/finalize.rs | 9 +- 2 files changed, 91 insertions(+), 15 deletions(-) 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/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 12fdc3a25..2d2c28895 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -8,6 +8,7 @@ use std::collections::HashSet; use edgezero_core::body::Body as EdgeBody; use http::Response; +use crate::constants::EC_RESPONSE_HEADERS; use crate::settings::Settings; use super::EcContext; @@ -19,14 +20,6 @@ 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 the resolved consent gate, last-seen updates, cookie From 0f0722f80dacb9d170162a016e353a1635656e53 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 17:47:43 +0100 Subject: [PATCH 31/36] Resolve the Edge Cookie provider once per request instead of twice The composition root resolved `[ec] provider` and threw the provider away, keeping only the knowledge that the selection could be satisfied, and then the request path resolved the same settings again to get a provider it could use. On the Fastly, Cloudflare and Spin adapters that is twice for every request, because those three run a fresh instance per request and rebuild application state each time, which was confirmed by reading `run_app` in the matching edgezero adapters. The composition root now keeps what it resolved, in `AppState`, and hands the same instance to every request through the new `RuntimeServices::resolved_ec_provider`. Core reads it through `request_provider`, which returns the threaded instance when there is one and otherwise resolves exactly as before, so an adapter that threads nothing, the core tests and any embedder driving core directly included, keeps today's behaviour, the loud failure on a selected but uninjected provider included. Nothing about which provider is chosen changes, only how many times the choosing happens. The Axum adapter is deliberately left checking rather than keeping, because it is a long-lived process whose application state is built once at start-up, so it has no second resolution to save. Addresses: crates/trusted-server-core/src/ec/provider.rs and the Fastly, Cloudflare and Spin adapters, where `ensure_provider_available` and `EcContext::read_from_request` each built the provider once per request. --- crates/trusted-server-adapter-axum/src/app.rs | 5 ++ .../src/app.rs | 42 ++++++--- .../trusted-server-adapter-fastly/src/app.rs | 52 ++++++++--- crates/trusted-server-adapter-spin/src/app.rs | 54 +++++++---- crates/trusted-server-core/src/ec/mod.rs | 50 +++++++++-- crates/trusted-server-core/src/ec/provider.rs | 89 ++++++++++++++++++- crates/trusted-server-core/src/edge_cookie.rs | 6 +- .../src/platform/test_support.rs | 25 ++++++ .../trusted-server-core/src/platform/types.rs | 56 ++++++++++++ 9 files changed, 328 insertions(+), 51 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index f3c1403ba..8def77aac 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -81,6 +81,11 @@ fn build_state_with_settings( // 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. There is no second resolution per request here to save. ensure_provider_available(&settings.ec, None)?; let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 2c20d4470..4a6255b1d 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -18,7 +18,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; -use trusted_server_core::ec::provider::ensure_provider_available; +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}; @@ -57,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. @@ -115,12 +125,13 @@ fn settings_from_cloudflare_config_json() -> Result Result, Report> { - // Composition root: reject a provider selection this adapter can never - // supply, once, before any request is served. This adapter 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. - ensure_provider_available(&settings.ec, None)?; + // 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)?; @@ -128,6 +139,7 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_provider, })) } @@ -135,8 +147,11 @@ 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`, @@ -195,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, @@ -393,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); @@ -698,7 +713,10 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build test request"); let ctx = RequestContext::new(req, PathParams::default()); - let services = build_per_request_services(&ctx); + // 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) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 2c30abf08..dce14bbfd 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -112,8 +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::build_provider; -use trusted_server_core::ec::provider::ensure_provider_available; +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; @@ -161,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. @@ -191,12 +201,13 @@ pub(crate) fn build_state_from_settings( ) -> Result, Report> { warn_if_certificate_check_disabled(&settings); - // Composition root: reject a provider selection this adapter can never - // supply, once, before any request is served. This adapter 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. - ensure_provider_available(&settings.ec, None)?; + // 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)?; @@ -210,6 +221,7 @@ pub(crate) fn build_state_from_settings( registry: Arc::new(registry), default_kv_store, auction_telemetry_sink, + ec_provider, })) } @@ -277,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)) @@ -290,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] { @@ -572,7 +592,7 @@ async fn execute_named( // 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 = build_provider(&state.settings.ec, services.ec_provider())?; + 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), @@ -740,7 +760,7 @@ fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> // A partner echoes back an identifier the deployment's own provider // minted, so validation and KV normalization are dispatched through // that provider rather than the built-in HMAC grammar. - let provider = build_provider(&state.settings.ec, services.ec_provider())?; + let provider = request_provider(&state.settings.ec, services)?; handle_batch_sync(&kv, &partner_registry, &limiter, provider.as_deref(), req) }); @@ -1526,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, @@ -1534,6 +1559,7 @@ mod tests { orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), default_kv_store, + ec_provider, }); TrustedServerApp::routes_for_state(&state) } diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 294150959..388fc26dc 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -16,7 +16,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; -use trusted_server_core::ec::provider::ensure_provider_available; +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; @@ -50,6 +50,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. @@ -73,12 +83,13 @@ fn build_state() -> Result, Report> { 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. This adapter 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. - ensure_provider_available(&settings.ec, None)?; + // 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)?; @@ -86,6 +97,7 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_provider, })) } @@ -513,6 +525,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); @@ -522,7 +541,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))) @@ -534,7 +553,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))) @@ -567,7 +586,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 @@ -610,7 +629,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( @@ -651,7 +670,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 @@ -664,7 +683,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 @@ -677,7 +696,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 @@ -694,7 +713,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) @@ -710,7 +729,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); @@ -935,6 +954,9 @@ mod tests { .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(); diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index e064ad23d..2309b5ea3 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -78,7 +78,7 @@ use crate::geo::GeoInfo; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; -use provider::{EdgeCookieProvider, GeneratedEdgeCookie, IdentityInput, build_provider}; +use provider::{EdgeCookieProvider, GeneratedEdgeCookie, IdentityInput}; use self::kv::KvIdentityGraph; use self::kv_types::KvEntry; @@ -202,12 +202,14 @@ impl EcContext { ) -> Result> { let parsed = parse_ec_from_request(req)?; - // Build the selected provider once. It is used here to decide whether - // the incoming cookie value is a usable identifier. Building it needs - // no request data, so nothing is cloned from the request. - let ec_provider = services.ec_provider(); + // 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> = - build_provider(&settings.ec, ec_provider.clone())?.map(Arc::from); + 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 @@ -812,6 +814,42 @@ mod tests { } } + #[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; diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs index af22a9a51..fb20f32a2 100644 --- a/crates/trusted-server-core/src/ec/provider.rs +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -847,10 +847,55 @@ pub fn ensure_provider_available( ec: &Ec, injected: Option>, ) -> Result<(), Report> { - build_provider(ec, injected)?; + 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. /// @@ -1444,6 +1489,48 @@ mod tests { ); } + #[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, diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index 67ffc0aba..fd593216b 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -14,7 +14,7 @@ use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::ec::generation::normalize_ip; #[cfg(test)] use crate::ec::provider::IdentityInput; -use crate::ec::provider::{build_provider, provider_owns_id}; +use crate::ec::provider::{provider_owns_id, request_provider}; use crate::error::TrustedServerError; #[cfg(test)] use crate::evidence::BorrowedRequestInfo; @@ -51,7 +51,7 @@ pub fn generate_ec_id( log::trace!("Generating fresh EC ID from normalized client context"); - let Some(provider) = build_provider(&settings.ec, services.ec_provider())? else { + let Some(provider) = request_provider(&settings.ec, services)? else { log::info!("No Edge Cookie provider configured; running statelessly"); return Ok(None); }; @@ -148,7 +148,7 @@ pub fn recognized_ec_id( return Ok(None); }; - let Some(provider) = build_provider(&settings.ec, services.ec_provider())? else { + let Some(provider) = request_provider(&settings.ec, services)? else { log::debug!( "No Edge Cookie provider configured; withholding the request's EC ID from egress" ); diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index cdfd3b96d..67203cb6d 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -714,6 +714,31 @@ pub(crate) fn noop_services_with_ec_provider_without_client_ip( noop_services_with_ec_provider_and_ip(ec_provider, None) } +/// Build a [`RuntimeServices`] carrying an Edge Cookie provider that a +/// composition root already resolved, the way a production adapter threads it. +/// +/// Use this to check that the request path reuses that instance rather than +/// resolving `[ec] provider` for itself. [`noop_services_with_ec_provider`] +/// is the other half of the pair, offering a vendor provider as an input to +/// the selector instead of the selector's answer. +pub(crate) fn noop_services_with_resolved_ec_provider( + resolved: Arc, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: Some("203.0.113.10".parse().expect("should parse test client IP")), + ..ClientInfo::default() + }) + .resolved_ec_provider(resolved) + .build() +} + fn noop_services_with_ec_provider_and_ip( ec_provider: Arc, client_ip: Option, diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index a6c535ba9..5cd9c295a 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -196,6 +196,13 @@ pub struct RuntimeServices { /// own crate and be injected, so core never names a vendor (the same /// pattern as [`geo`](Self::geo)). pub(crate) ec_provider: Option>, + /// The Edge Cookie provider this deployment already resolved from + /// `[ec] provider` while it built application state. + /// + /// `None` when the adapter resolved nothing here, in which case the request + /// path resolves the selection itself, which is what a deployment that + /// selects no provider and the core tests both do. + pub(crate) resolved_ec_provider: Option>, } impl RuntimeServices { @@ -294,6 +301,21 @@ impl RuntimeServices { self.ec_provider.clone() } + /// Returns the Edge Cookie provider the composition root already resolved, + /// when the adapter threaded one through. + /// + /// Resolving `[ec] provider` reads no request data, so the answer is the + /// same for every request and an adapter that resolves it once while it + /// builds application state can hand the result here instead of the + /// request path resolving the same settings again. `None` means nothing was + /// threaded, so the request path resolves for itself. Read this through + /// [`request_provider`](crate::ec::provider::request_provider) rather than + /// directly, so both answers are handled in one place. + #[must_use] + pub fn resolved_ec_provider(&self) -> Option> { + self.resolved_ec_provider.clone() + } + /// Wrap the KV store in a [`super::KvHandle`] for ergonomic access to /// JSON helpers, pagination, and validation. #[must_use] @@ -314,6 +336,25 @@ impl RuntimeServices { } } + /// Returns a clone of this instance with the resolved Edge Cookie provider + /// replaced. + /// + /// Adapters that build their per-request services through a shared helper + /// with no application state in hand use this to thread the provider the + /// composition root resolved. `None` leaves the request path to resolve + /// `[ec] provider` for itself, which is what a deployment selecting no + /// provider does. + #[must_use] + pub fn with_resolved_ec_provider( + self, + resolved_ec_provider: Option>, + ) -> Self { + Self { + resolved_ec_provider, + ..self + } + } + /// Returns a clone of this instance with the template cache replaced. /// /// Spike-only (#1009). @@ -362,6 +403,7 @@ pub struct RuntimeServicesBuilder { auction_telemetry_sink: Option>, client_info: Option, ec_provider: Option>, + resolved_ec_provider: Option>, } impl RuntimeServicesBuilder { @@ -378,6 +420,7 @@ impl RuntimeServicesBuilder { auction_telemetry_sink: None, client_info: None, ec_provider: None, + resolved_ec_provider: None, } } @@ -469,6 +512,18 @@ impl RuntimeServicesBuilder { self } + /// Set the Edge Cookie provider the composition root already resolved. + /// + /// Optional, and different from [`ec_provider`](Self::ec_provider), which + /// is the vendor provider offered to the selector as an input. This one is + /// the output, the provider the selector actually chose, so setting it + /// keeps the request path from resolving the same settings a second time. + #[must_use] + pub fn resolved_ec_provider(mut self, provider: Arc) -> Self { + self.resolved_ec_provider = Some(provider); + self + } + /// Construct [`RuntimeServices`] from the accumulated configuration. /// /// # Panics @@ -510,6 +565,7 @@ impl RuntimeServicesBuilder { .client_info .expect("should set client_info before building RuntimeServices"), ec_provider: self.ec_provider, + resolved_ec_provider: self.resolved_ec_provider, } } } From a698f5ba09a0b6d636af39aabf2df9751a489a15 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 20:49:55 +0100 Subject: [PATCH 32/36] Load Spin settings from the config store instead of a baked template The Spin adapter cannot start on upstream/main today, and this fixes it here. `build_state` compiled `trusted-server.example.toml` into the binary and parsed it, but that template ships placeholder secrets by design and its placeholder admin password is the first entry in `PASSWORD_PLACEHOLDERS`, so `validate_admin_handler_passwords` refused it every time. `build_state` therefore never returned `Ok`, the router fell back to the start-up error handler, and the component answered 503 to every request. The failure is "Handler `^/_ts/admin` uses a placeholder password; configure a strong secret". Nothing caught it because nothing called `build_state`. Every Spin test enters through the `routes_with_settings` parity seam and supplies its own settings, so the one path a deployed component actually takes was the one path never exercised. Settings now come from the platform config store at run time, which is what the Fastly, Axum and Cloudflare adapters already do, so an operator publishes one with `ts config push` and the component reads it. The new `SpinPlatformConfigStore` reads Spin component variables directly rather than through the per-request handle, because application state is built before any request context exists. Component variables are ambient, which is how the secret store already reads them, and both paths map keys through `spin_variable_name` so start-up and the request path read the same variable for the same key. The new test calls `build_state` and requires any failure to be the absence of a config store. Outside the Spin runtime there are no component variables, so it cannot return `Ok` under `cargo test`, but a configuration compiled into the binary would fail for a different reason and the test says so. Restoring the old body fails it with the placeholder-password message. Addresses: crates/trusted-server-adapter-spin/src/app.rs, where `build_state` parsed a baked example template whose placeholder admin password made every request fail. --- crates/trusted-server-adapter-spin/src/app.rs | 52 +++++++++++++++++-- .../src/platform.rs | 45 ++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 388fc26dc..d5faaa1c3 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -35,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 @@ -64,12 +67,23 @@ pub struct AppState { /// 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, Axum and Cloudflare 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 +/// initialise. 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) } @@ -912,6 +926,36 @@ mod tests { 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. 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, From 252aadefcccc5a535c9e34a6d59447572ae1a588 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 30 Aug 2026 20:57:18 +0100 Subject: [PATCH 33/36] Stop exposing an inbound Edge Cookie identifier nothing has vouched for `get_ec_id` is public on upstream/main today and this fixes it here. It reads the `x-ts-ec` request header and then the `ts-ec` cookie, and checks the result only with `ec_id_has_only_allowed_chars`. That function is the global cookie backstop, the length cap and the cookie-safe alphabet, and its own documentation in `ec/cookies.rs` says the strict check is the one used to reject untrusted request values. On its own it accepts any run of `[A-Za-z0-9._~-]` up to the cap, so it cannot tell an identifier this deployment minted from one an attacker typed. `x-ts-ec` is stripped from responses but not from inbound requests, so the header really is the client's to set, and the raw reader prefers it over the cookie. This is the inbound twin of the egress fault this branch already fixes, which is why it belongs here. The right check is not the built-in strict format validator. A vendor provider's identifier is not required to match the HMAC `<64 hex>.<6 alphanumeric>` shape, so holding every deployment to it would drop exactly the opaque identifiers the provider model exists to carry. The right check is provider ownership, where the `{code}~` prefix is dispatched to the provider that owns it and that provider's `accepts_id` decides, which is what `recognized_ec_id` already does and what the EC lifecycle applies on read-back. The raw reader cannot make that check, because it has neither settings nor the selected provider, so it stops being a public entry point. It is now `pub(crate)` and named `unvalidated_ec_id_from_request`, so no caller can read it as returning a validated identifier, and `recognized_ec_id` is the only way in from outside the module. Nothing outside the crate called the old name. The new test drives three identifiers this deployment could never have issued through both readers, shows the bounds alone accept all three, and requires the public path to recognize none of them, while an identifier the selected provider does own is still returned. Replacing the ownership check with the bounds fails it on the first one. Addresses: crates/trusted-server-core/src/edge_cookie.rs, where `get_ec_id` was public and validated client-supplied identifiers with the outbound backstop list. --- crates/trusted-server-core/src/edge_cookie.rs | 116 +++++++++++++++--- 1 file changed, 96 insertions(+), 20 deletions(-) diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index fd593216b..3485e386e 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -73,18 +73,31 @@ pub fn generate_ec_id( 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 minted 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) @@ -119,12 +132,20 @@ pub fn get_ec_id(req: &Request) -> Result, Report, ) -> Result, Report> { - let Some(ec_id) = get_ec_id(req)? else { + let Some(ec_id) = unvalidated_ec_id_from_request(req)? else { return Ok(None); }; @@ -186,7 +207,7 @@ pub(crate) fn get_or_generate_ec_id_from_http_request( services: &RuntimeServices, req: &Request, ) -> Result, Report> { - if let Some(id) = get_ec_id(req)? { + if let Some(id) = unvalidated_ec_id_from_request(req)? { return Ok(Some(id)); } @@ -387,12 +408,64 @@ 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 minted 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 mints. + "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) @@ -409,7 +482,7 @@ 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) @@ -427,7 +500,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())); } @@ -455,7 +529,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()); } @@ -477,7 +551,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()), @@ -510,7 +585,8 @@ mod tests { &format!("{}=bad