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 45cbedc2c..0009e1953 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -135,6 +135,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-axum/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 5b605bcff..cb22e2126 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -151,6 +151,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-cloudflare/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 41e5e65ee..f9a44b33d 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 @@ -1353,6 +1353,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-passphrase-at-least-32-bytes!!" [request_signing] @@ -1422,6 +1425,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -1854,6 +1860,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -2493,6 +2502,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -2618,6 +2630,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index a19d0485d..ca5607f9b 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -522,6 +522,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 18f309c68..7702386df 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -300,6 +300,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 1bcede1fc..3cadf721d 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -178,6 +178,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index f75ea687e..2389ccebc 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -35,6 +35,9 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 818b6fcc5..75f22b864 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -365,6 +365,9 @@ origin_url = "https://origin.example.com" proxy_secret = "change-me-proxy-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "production-secret-key-32-bytes-min" [[handlers]] diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 6ede36e9c..2525a528d 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -154,7 +154,9 @@ mod tests { fn strings_that_look_like_json_scalars_round_trip_as_strings() { let mut original = test_settings(); original.publisher.proxy_secret = Redacted::new("1234567890".to_string()); - original.ec.passphrase = Redacted::new("12345678901234567890123456789012".to_string()); + original.ec.providers.hmac = Some(crate::settings::HmacProviderConfig { + passphrase: Redacted::new("12345678901234567890123456789012".to_string()), + }); original.handlers[0].password = Redacted::new("true".to_string()); let reconstructed = settings_from_config_blob(&envelope_json(&original)) @@ -166,8 +168,22 @@ mod tests { "numeric-looking proxy secret should remain a string" ); assert_eq!( - reconstructed.ec.passphrase.expose(), - original.ec.passphrase.expose(), + reconstructed + .ec + .providers + .hmac + .as_ref() + .expect("should reconstruct the hmac provider") + .passphrase + .expose(), + original + .ec + .providers + .hmac + .as_ref() + .expect("should keep the hmac provider") + .passphrase + .expose(), "numeric-looking passphrase should remain a string" ); assert_eq!( diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index ac0e0c05b..1b3da4785 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -13,8 +13,6 @@ //! endpoint (`/_ts/api/v1/identify`) exposes the EC ID in its response //! body for legitimate JS use cases. -use std::borrow::Cow; - use edgezero_core::body::Body as EdgeBody; use http::{HeaderValue, Response, header}; @@ -24,64 +22,26 @@ use crate::settings::Settings; /// Maximum age for the EC cookie (1 year in seconds). const COOKIE_MAX_AGE: i32 = 365 * 24 * 60 * 60; +/// Maximum length in bytes of an Edge Cookie identifier. +/// +/// A global bound enforced wherever an identifier enters the system (mint, +/// cookie read-back, cookie write), so no provider can emit a value the cookie +/// layer, logs, or the KV key space cannot carry. +pub(crate) const MAX_EC_ID_LEN: usize = 256; + fn is_allowed_ec_id_char(c: char) -> bool { - c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '~') } -// Outbound allowlist for cookie sanitization: permits [a-zA-Z0-9._-] as a -// defense-in-depth backstop when setting the Set-Cookie header. This is -// intentionally broader than the inbound format validator +// Identifier allowlist: [A-Za-z0-9._~-], the cookie-safe alphabet every +// Edge Cookie identifier must fit regardless of which provider minted it. +// This is intentionally broader than the built-in format validator // (`generation::is_valid_ec_id`), which enforces the exact -// `<64-hex>.<6-alphanumeric>` structure and is used to reject untrusted -// request values before they enter the system. +// `<64-hex>.<6-alphanumeric>` structure of the HMAC provider; an opaque +// vendor identifier only has to fit the alphabet and the length bound. #[must_use] pub(crate) fn ec_id_has_only_allowed_chars(ec_id: &str) -> bool { - ec_id.chars().all(is_allowed_ec_id_char) -} - -fn sanitize_ec_id_for_cookie(ec_id: &str) -> Cow<'_, str> { - if ec_id_has_only_allowed_chars(ec_id) { - return Cow::Borrowed(ec_id); - } - - let safe_id = ec_id - .chars() - .filter(|c| is_allowed_ec_id_char(*c)) - .collect::(); - - log::warn!( - "Stripped disallowed characters from EC ID before setting cookie (len {} -> {}); \ - callers should reject invalid request IDs before cookie creation", - ec_id.len(), - safe_id.len(), - ); - - Cow::Owned(safe_id) -} - -/// Returns `true` if every byte in `value` is a valid RFC 6265 `cookie-octet`. -/// An empty string is always rejected. -/// -/// RFC 6265 restricts cookie values to printable US-ASCII excluding whitespace, -/// double-quote, comma, semicolon, and backslash. Rejecting these characters -/// prevents header-injection attacks where a crafted value could append -/// spurious cookie attributes (e.g. `evil; Domain=.attacker.com`). -/// -/// Non-ASCII characters (multi-byte UTF-8) are always rejected because their -/// byte values exceed `0x7E`. -#[must_use] -fn is_safe_cookie_value(value: &str) -> bool { - // RFC 6265 §4.1.1 cookie-octet: - // 0x21 — '!' - // 0x23–0x2B — '#' through '+' (excludes 0x22 DQUOTE) - // 0x2D–0x3A — '-' through ':' (excludes 0x2C comma) - // 0x3C–0x5B — '<' through '[' (excludes 0x3B semicolon) - // 0x5D–0x7E — ']' through '~' (excludes 0x5C backslash, 0x7F DEL) - // All control characters (0x00–0x20) and non-ASCII (0x80+) are also excluded. - !value.is_empty() - && value - .bytes() - .all(|b| matches!(b, 0x21 | 0x23..=0x2B | 0x2D..=0x3A | 0x3C..=0x5B | 0x5D..=0x7E)) + !ec_id.is_empty() && ec_id.len() <= MAX_EC_ID_LEN && ec_id.chars().all(is_allowed_ec_id_char) } /// Formats a `Set-Cookie` header value for the EC cookie. @@ -98,56 +58,48 @@ fn format_set_cookie(domain: &str, value: &str, max_age: i32) -> String { /// /// Per spec §5.2, the EC cookie domain is computed from /// `settings.publisher.domain` (not `cookie_domain`) to ensure the EC -/// cookie is always scoped to the publisher's apex domain. The EC ID is -/// sanitized through a narrow outbound allowlist as a defense-in-depth -/// backstop against header injection. +/// cookie is always scoped to the publisher's apex domain. Callers validate +/// the identifier with [`ec_id_has_only_allowed_chars`] before this point; +/// an identifier is rejected outright rather than rewritten, so the cookie +/// value and the identity-graph key can never silently diverge. #[must_use] pub(crate) fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String { - let safe_id = sanitize_ec_id_for_cookie(ec_id); - format_set_cookie( &settings.publisher.ec_cookie_domain(), - safe_id.as_ref(), + ec_id, COOKIE_MAX_AGE, ) } /// Sets the EC ID cookie on the given response. /// -/// Validates `ec_id` against RFC 6265 `cookie-octet` rules before -/// interpolation. If the value contains unsafe characters (e.g. semicolons), -/// the cookie is not set and a warning is logged. This prevents an attacker -/// from injecting spurious cookie attributes via a controlled ID value. +/// Validates `ec_id` against the identifier alphabet and length bound before +/// interpolation. An identifier that fails validation is rejected and the +/// cookie is not set, with an error logged; the value is never rewritten, so +/// a provider identifier survives byte for byte or not at all. This also +/// prevents an attacker from injecting spurious cookie attributes via a +/// controlled ID value. /// /// `cookie_domain` comes from operator configuration and is considered trusted. -/// -/// # Panics (debug only) -/// -/// Debug-asserts that `ec_id` passes [`super::generation::is_valid_ec_id`] -/// as a defense-in-depth check against cookie injection. pub fn set_ec_cookie(settings: &Settings, response: &mut Response, ec_id: &str) { - if !is_safe_cookie_value(ec_id) { - log::warn!( - "Rejecting EC ID for Set-Cookie: value of {} bytes contains characters illegal in a cookie value", - ec_id.len() + if !ec_id_has_only_allowed_chars(ec_id) { + log::error!( + "Rejecting EC ID for Set-Cookie: value of {} bytes is empty, over {} bytes, or \ + contains characters outside the identifier alphabet", + ec_id.len(), + MAX_EC_ID_LEN, ); return; } - debug_assert!( - super::generation::is_valid_ec_id(ec_id), - "EC ID must be validated before cookie creation: got '{ec_id}'" - ); - match HeaderValue::from_str(&create_ec_cookie(settings, ec_id)) { Ok(val) => { response.headers_mut().append(header::SET_COOKIE, val); } Err(e) => { - // Unreachable in practice — is_safe_cookie_value and the debug - // assertion above gate the value, and format_set_cookie emits - // only controlled bytes. Logged for defense-in-depth symmetry - // with the rejection logging above. + // Unreachable in practice: the identifier allowlist above gates + // the value, and format_set_cookie emits only controlled bytes. + // Logged for defense-in-depth symmetry with the rejection above. log::warn!("Skipping EC Set-Cookie: invalid header value: {e}"); } } @@ -177,6 +129,28 @@ pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) #[cfg(test)] mod tests { use super::*; + + #[test] + fn identifier_bounds_reject_oversize_and_accept_tilde() { + assert!( + ec_id_has_only_allowed_chars("a.~-_Z9"), + "the cookie-safe alphabet includes the tilde" + ); + assert!( + !ec_id_has_only_allowed_chars(""), + "an empty identifier is rejected" + ); + let oversize = "a".repeat(MAX_EC_ID_LEN + 1); + assert!( + !ec_id_has_only_allowed_chars(&oversize), + "an identifier over the length cap is rejected" + ); + let at_cap = "a".repeat(MAX_EC_ID_LEN); + assert!( + ec_id_has_only_allowed_chars(&at_cap), + "an identifier at the length cap is accepted" + ); + } use crate::test_support::tests::create_test_settings; use http::header; @@ -226,17 +200,21 @@ mod tests { } #[test] - fn create_ec_cookie_sanitizes_disallowed_chars_in_id() { + fn set_ec_cookie_rejects_disallowed_chars_outright() { + // Rejection, never rewriting: an identifier outside the alphabet must + // not produce a cookie at all, so the cookie value and the identity + // graph key can never silently diverge. let settings = create_test_settings(); - let result = create_ec_cookie(&settings, "evil;injected\r\nfoo=bar\0baz"); - let value = result - .strip_prefix(&format!("{COOKIE_TS_EC}=")) - .and_then(|s| s.split_once(';').map(|(v, _)| v)) - .expect("should have cookie value portion"); - - assert_eq!( - value, "evilinjectedfoobarbaz", - "should strip disallowed characters and preserve safe chars" + let mut response = Response::new(EdgeBody::empty()); + set_ec_cookie( + &settings, + &mut response, + "evil;injected +foo=bar", + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "an identifier outside the alphabet should set no cookie" ); } @@ -289,47 +267,6 @@ mod tests { ); } - #[test] - fn is_safe_cookie_value_rejects_empty_string() { - assert!(!is_safe_cookie_value(""), "should reject empty string"); - } - - #[test] - fn is_safe_cookie_value_accepts_valid_ec_id_characters() { - assert!( - is_safe_cookie_value("abcdef0123456789.ABCDEFabcdef"), - "should accept hex digits, dots, and alphanumeric characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_non_ascii() { - assert!( - !is_safe_cookie_value("val\u{fc}e"), - "should reject non-ASCII UTF-8 characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_illegal_characters() { - assert!(!is_safe_cookie_value("val;ue"), "should reject semicolon"); - assert!(!is_safe_cookie_value("val,ue"), "should reject comma"); - assert!( - !is_safe_cookie_value("val\"ue"), - "should reject double-quote" - ); - assert!(!is_safe_cookie_value("val\\ue"), "should reject backslash"); - assert!(!is_safe_cookie_value("val ue"), "should reject space"); - assert!( - !is_safe_cookie_value("val\x00ue"), - "should reject null byte" - ); - assert!( - !is_safe_cookie_value("val\x7fue"), - "should reject DEL character" - ); - } - #[test] fn expire_ec_cookie_sets_max_age_zero() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/ec/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 0dfb5906f..8eaa82a75 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -1537,6 +1537,9 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] @@ -1570,6 +1573,9 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index c9b3f5ded..06b987d6e 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -3024,6 +3024,9 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#; diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 280eae847..4399dc912 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -974,7 +974,7 @@ impl IntegrationRegistry { // may lack consent signals such as the Sec-GPC header. if is_navigation_request(&req) { if let Err(err) = ec_context.generate_if_needed(settings, kv) { - log::warn!("EC generation failed for integration proxy: {err:?}"); + log::error!("EC generation failed for integration proxy: {err:?}"); } } else { log::debug!( diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 48e92faed..3801ed9f3 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -47,6 +47,7 @@ pub mod creative_opportunities; pub mod ec; pub(crate) mod edge_cookie; pub mod error; +pub mod evidence; pub mod geo; pub mod host_header; pub(crate) mod host_rewrite; diff --git a/crates/trusted-server-core/src/platform/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 ddc8ac612..fde31f749 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -423,9 +423,38 @@ impl EcPartner { #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct Ec { - /// Publisher passphrase used as HMAC key for EC generation. - #[validate(custom(function = Ec::validate_passphrase))] - pub passphrase: Redacted, + /// The key of the Edge Cookie identity provider to activate. + /// + /// Names one of the blocks under [`providers`](Self::providers), for + /// example `"hmac"`. Set it in the `[ec]` TOML section or override it with + /// the `TRUSTED_SERVER__ec__provider` environment variable so the same + /// compiled WebAssembly can switch providers at deployment. When absent, no + /// Edge Cookie is generated and Trusted Server runs statelessly; the + /// explicit `"none"` spells the same choice. Selecting a provider whose + /// block is missing is rejected at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + #[serde(default)] + pub provider: Option, + + /// Deprecated location of the HMAC passphrase, read so a configuration + /// written for the previous release still starts. + /// + /// [`migrate_legacy_passphrase`](Self::migrate_legacy_passphrase) maps it + /// to `provider = "hmac"` with the passphrase in the `[ec.providers.hmac]` + /// block and logs a deprecation warning, so a fleet can move configuration + /// and binaries independently. A configuration carrying both the old and + /// the new form is rejected rather than guessed at. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passphrase: Option>, + + /// Configuration blocks for the available Edge Cookie identity providers. + /// + /// Each provider has its own optional `[ec.providers.]` block. The + /// [`provider`](Self::provider) selector names which one is active, so a + /// block can be configured (or kept) without being the one in use. + #[serde(default)] + #[validate(nested)] + pub providers: EcProviders, /// Fastly KV store name for the EC identity graph. #[serde(default)] @@ -513,6 +542,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)] @@ -2720,6 +2934,8 @@ impl Settings { }) })?; + settings.ec.migrate_legacy_passphrase()?; + settings.ec.validate_provider_selection()?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -2810,8 +3026,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()); @@ -3629,9 +3847,14 @@ mod tests { ); assert_eq!(settings.publisher.origin_host_header_override, None); assert_eq!( - settings.ec.passphrase.expose(), - "test-secret-key-32-bytes-minimum" + settings.ec.provider.as_deref(), + Some("hmac"), + "test settings should select the hmac EC provider" ); + let Some(hmac) = &settings.ec.providers.hmac else { + panic!("test settings should configure the hmac EC provider"); + }; + assert_eq!(hmac.passphrase.expose(), "test-secret-key-32-bytes-minimum"); settings.validate().expect("Failed to validate settings"); } @@ -3798,6 +4021,34 @@ mod tests { ); } + #[test] + fn provider_selection_allows_no_provider_for_stateless_operation() { + let ec = Ec::default(); + assert!(ec.provider.is_none(), "default Ec selects no provider"); + ec.validate_provider_selection() + .expect("should allow no provider selected and run statelessly"); + } + + #[test] + fn provider_selection_rejects_a_selector_without_a_configured_block() { + // Point the selector at a provider whose `[ec.providers.]` block is + // absent, mirroring a deployment that sets the env override to a + // provider it never configured. + let toml_str = + crate_test_settings_str().replace(r#"provider = "hmac""#, r#"provider = "acme""#); + + let err = Settings::from_toml(&toml_str) + .expect_err("selecting an unconfigured provider should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "unconfigured provider selection should be a configuration error, got: {:?}", + err.current_context() + ); + } + #[test] fn cache_asset_rule_globs_respect_path_separators() { let toml_str = format!( @@ -3904,6 +4155,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!( @@ -4017,6 +4287,35 @@ mod tests { ); } + #[test] + fn legacy_passphrase_migrates_to_the_hmac_provider() { + let mut ec = Ec { + passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), + ..Ec::default() + }; + ec.migrate_legacy_passphrase() + .expect("should migrate the deprecated form"); + assert_eq!( + ec.provider.as_deref(), + Some("hmac"), + "the deprecated passphrase should select the hmac provider" + ); + assert_eq!( + ec.providers + .hmac + .as_ref() + .expect("should configure the hmac block") + .passphrase + .expose(), + "test-secret-key-32-bytes-minimum", + "the passphrase should move into the hmac block" + ); + assert!( + ec.passphrase.is_none(), + "the deprecated field should be consumed by the migration" + ); + } + #[test] fn cache_asset_rule_validation_rejects_invalid_config() { let duplicate_ids = format!( @@ -4094,6 +4393,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( @@ -4415,7 +4782,9 @@ origin_host_header_overide = "www.example.com""#, let mut settings = Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); settings.publisher.proxy_secret = Redacted::new("unit-test-proxy-secret".to_owned()); - settings.ec.passphrase = Redacted::new("test-secret-key-32-bytes-minimum".to_owned()); + settings.ec.providers.hmac = Some(HmacProviderConfig { + passphrase: Redacted::new("test-secret-key-32-bytes-minimum".to_owned()), + }); settings.handlers[0].password = Redacted::new("replace-with-admin-password-32-bytes".to_owned()); @@ -5034,6 +5403,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" "#, ) @@ -5065,6 +5437,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" "#, ); @@ -6219,6 +6594,9 @@ origin_host_header_overide = "www.example.com""#, proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -6552,6 +6930,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -6636,6 +7017,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -6672,6 +7056,9 @@ origin_url = "https://origin.example.com" proxy_secret = "secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -6714,6 +7101,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/docs/superpowers/specs/2026-07-30-pluggable-providers-design.md b/docs/superpowers/specs/2026-07-30-pluggable-providers-design.md new file mode 100644 index 000000000..5075c54bf --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-pluggable-providers-design.md @@ -0,0 +1,585 @@ +# Design Spec: Pluggable Edge Cookie, Device, and Geo Providers + +**Status:** Implemented in PR #1043 (Edge Cookie provider seam) and PR #1044 +(device and geo selection); revised against the implementation, 2026-08-25. +**Author:** Engineering +**Issue references:** #777, #778, #780, #781 +**Related specs:** `2026-07-30-permission-model-design.md`, +`2026-07-30-provider-migration-rollout-design.md`, +`2026-07-30-client-cycle-ec-resolve-design.md` +**Last updated:** 2026-08-25 + +> **Context.** PR #838 proposed a first implementation of this epic in a single +> change. Review of that PR surfaced design gaps this spec exists to close +> before a second implementation pass: an identity abstraction that owned +> minting but not recognition, per-adapter divergence in provider selection, +> silent misconfiguration modes, and speculative trait surface with no +> production caller. This spec is the authoritative statement of what the +> provider architecture must do; where it contradicts PR #838, this spec wins. +> The second pass has now landed (PR #1043 and PR #1044, with permission +> enforcement in PR #1045), and this revision restates the spec to match the +> implemented code. A final section records every divergence from the +> 2026-07-31 draft. + +--- + +## 1. Overview and goals + +Trusted Server makes three per-request data decisions that were previously +hard-wired: whether to create or keep an Edge Cookie (EC) identity, how to +classify the requesting device, and whether to resolve geolocation. Each is +now a **provider**, a selectable component chosen in operator configuration, +with a deliberately neutral default. + +Goals, as implemented: + +- A deployment picks an implementation per concern (including none) without a + code change to Trusted Server core. +- Defaults are neutral. With no configuration, no EC is created, device + classification uses only the User-Agent, and no geolocation is performed. A + default deployment makes no third-party or host-specific call. +- An **EC provider declares** the permissions its data use requires + (`required_permissions` on the trait), and **core enforces** that + declaration before minting or using an identity. A provider cannot + authorize itself. The enforcement machinery is the permission model's + subject and lands with it in PR #1045 (see the permission model spec). + Geo and device carry the same declaration method with an empty default, + for the reasons spelled out in section 5. +- All adapters (Fastly, Axum, Cloudflare, Spin) route selection through the + same core builders, so identical configuration selects identical providers + everywhere. A selection the deployment cannot satisfy fails loudly rather + than degrading. The EC API routes (identify, batch-sync, ec/resolve) are + registered by the Fastly entry point only today, because the portability + adapters do not yet wire a platform KV store. The Spin adapter's route + list documents that gap explicitly rather than leaving those paths silent. + +Non-goals: + +- No vendor provider ships in this epic beyond the host-platform + implementations named below. The `crates/edgecookie/` directory holds a + README describing where vendor EC crates will live. +- The client-cycle (browser round-trip) provider type has its own spec. The + trait ships the seam for it (`resolve_from_client`, a no-op by default) + and a demonstration provider (`client-fixed`) compiled only into test and + demonstration builds. Production selection of the demo provider is a + startup error. + +## 2. Provider taxonomy + +| Concern | Trait | Built-in default | Opt-in implementations | +| ----------- | -------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| EC identity | `EdgeCookieProvider` | none (stateless) | `hmac` (in core, HMAC over client IP, preserves today's identity), `host-signals` (in core, see below), and `client-fixed` (demo builds only) | +| Device | `DeviceProvider` | `builtin` (User-Agent only) | `fastly` (TLS JA4 and HTTP/2 signals through an injected `HostSignals` service) | +| Geo | `PlatformGeo` | none (no location) | `platform` (host geo lookup) | + +The geo trait is the existing `PlatformGeo` in `platform/traits.rs` rather +than a new `GeoProvider` name. The EC trait lives in `ec/provider.rs` and the +device trait in `ec/device.rs`. + +Selection keys are strings in operator configuration +(`trusted-server.example.toml` carries the commented template): + +```toml +[ec] +provider = "hmac" + +[ec.providers.hmac] +passphrase = "replace-with-32-plus-byte-random-secret" + +[device] +provider = "builtin" # default. "fastly" opts into TLS/H2 signal evidence + +[geo] +provider = "platform" # default is none (no location, no host call) +default_country = "FR" # required (section 6) +# assume_single_jurisdiction = true # required when EC runs with no geo +``` + +**The `host-signals` EC provider** (identity from HMAC over the host TLS JA4 +and HTTP/2 fingerprints plus the client IP) was deliberately dropped from the +2026-07-31 draft. It has since shipped in PR #1044 as an opt-in built-in +(`[ec.providers.host-signals]`), implemented against the host-agnostic +`HostSignals` capability rather than a Fastly API, so any host that supplies +the fingerprints can run it and a host that supplies none cannot build it. +When the host supplies no fingerprint at all the provider defers with a +warning instead of degrading to an IP-only identifier under the host-signals +name. **An open review question stands on whether this provider should ship +in the series at all**, because its identifier shape shares the built-in +HMAC grammar and a sign-off row defers host fingerprint processing. The +question is flagged for the series review and this spec does not present +the provider as settled either way. + +## 3. The identity lifecycle contract + +This is the section PR #838 lacked. Its trait abstracted **minting** an +identifier but left **recognition** and **KV key normalization** hard-coded +to the built-in HMAC shape, so a provider whose identifiers did not match +`{64hex}.{6alnum}` minted cookies that the very next request discarded. + +The implemented contract routes every lifecycle operation core performs on +an EC value through the selected provider: + +| Lifecycle operation | Where core uses it | Contract | +| ------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Mint** | EC generation on first eligible request, and the client-cycle resolve endpoint | The provider returns the identifier (`generate` server-side, `resolve_from_client` for the client cycle) and only core writes the cookie, after enforcing the global bounds below. | +| **Recognize** | Reading `ts-ec` back from the request, deciding `ec_was_present`, withdrawal checks | `accepts_id` answers whether a value is a well-formed identifier the provider issues. A value the selected provider does not recognize is treated as absent, so it is never used or egressed, while the raw cookie value stays visible to withdrawal handling. | +| **KV key** | Identity-graph row reads and writes | `normalize_id_for_kv` returns the key form. The default lowercases the built-in HMAC hash segment and preserves the suffix, keeping today's keys. An opaque or case-sensitive provider overrides to the identity function so distinct identifiers never collapse into one row. | +| **Withdraw** | Expiring the cookie and writing revocation markers | The identifiers eligible for withdrawal are exactly those the selected provider accepts through `accepts_id`, never a shape check the provider cannot influence. | + +**Invariant:** for every provider `P` and every identifier `id` minted by +`P`, `id` round-trips read-back byte for byte. A test in `ec/mod.rs` proves +the round-trip with a non-default provider whose identifiers are opaque, and +a second test in `ec/resolve.rs` proves the client-cycle value survives the +full scenario verbatim. + +The draft's richer lifecycle surface, a canonicalizing `parse` with +per-provider equivalence fixtures, a core-constructed graph key built from a +provider `graph_key_suffix`, a declared cluster-prefix capability, declared +namespace descriptors with a startup disjointness proof, and a reusable +conformance suite driven by fixtures, is **not implemented in these PRs**. +Recognition plus KV normalization proved sufficient for the operations core +actually performs today, and each deferred piece is tracked as follow-up +work rather than silently dropped (see the revision record). Until the key +grammar lands, the KV key is the provider's normalized identifier verbatim, +which keeps every pre-epic HMAC row reachable, and the pre-epic IP-cluster +prefix listing (keyed on the 64-hex HMAC hash prefix) continues unchanged. + +One global rule sits above every provider, and it is implemented: + +- **Identifier bounds.** A minted identifier obeys a global cookie-safe + alphabet (normatively `[A-Za-z0-9._~-]`, valid cookie octets with no + separators, whitespace, or control characters) and a global maximum of + **256 bytes**, stated here so dependent documents reference one number. + The bound applies to the identifier itself, not only its key form. Core + enforces the bound wherever an identifier enters the system, at mint + (both `generate` and the resolve endpoint), at cookie read-back, and at + cookie write. The constant is `MAX_EC_ID_LEN` in `ec/cookies.rs`. A + violating value is rejected outright and logged. No sanitizing rewrite + exists anywhere on the path, so an identifier survives byte for byte or + not at all, and the cookie value and the identity-graph key can never + silently diverge. + +## 4. Trait surface: minimalism rule + +Every trait method must have at least one production (non-test) caller in +the same change that introduces it. How the surface observed in PR #838 +resolved in the implementation: + +- `keys_equal`: **not shipped.** Its legitimate purpose (equivalent-envelope + comparison, #778) is served structurally, because read-back acceptance and + KV normalization both route through the provider, so no comparison method + exists to leave uncalled. +- `GeneratedEdgeCookie::response_headers`: **shipped, with a production + caller.** EC finalization applies provider-requested headers to the + outbound response, and the client-cycle resolve path returns them, which + is how a client-side provider requests further evidence from the page. + The draft banned the field when nothing consumed it. The consumer landed + in the same series, satisfying the rule the ban enforced. +- `IdentityInput.permissions` / `IdentityInput.consent`: **shipped and + populated.** The organic mint path passes the request's resolved + permission state and consent context so a provider can read them for + behavior beyond gating. The gate itself has already run before `generate` + is called, so a provider cannot use the fields to authorize itself. +- `required_permissions` on `DeviceProvider` and `PlatformGeo`: **present, + with an empty default and no enforcement point.** The draft removed the + method from both traits because PR #838's copies were decorative. The + implementation keeps one uniform declaration seam across all three traits + instead. The built-in device and geo providers declare empty sets, and + core enforces the declaration only for the EC provider (section 5), so + nothing reads as a gate that is not one. The geo circularity argument + stands unchanged and is restated in section 5. + +The implemented `EdgeCookieProvider` surface (`ec/provider.rs`): + +```rust +pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { + /// Stable configuration key ("hmac"). + fn id(&self) -> &'static str; + /// Registered four-character code (provider-code-registry.md), the + /// `{code}~` namespace of every identifier the provider mints. + /// Mandatory, no default: a provider cannot exist without a unique + /// code, so identifiers from different providers can never collide. + fn code(&self) -> ProviderCode; + /// Derives an identifier from the provider's injected services and the + /// request evidence passed at call time. A client-side provider defers + /// here (returns no id) and mints later in resolve_from_client. + fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + ) -> Result>; + /// Whether `value` is a well-formed identifier this provider issues. + /// Default: the built-in HMAC shape (`<64 hex>.<6 alphanumeric>`). + fn accepts_id(&self, value: &str) -> bool { /* built-in shape */ } + /// The KV-key form of `value`. Default: lowercase the HMAC hash + /// segment, preserve the suffix. Opaque providers return the value + /// unchanged. + fn normalize_id_for_kv(&self, value: &str) -> String { /* ... */ } + /// Permissions this provider's data use requires. Default: none, so a + /// vendor-neutral provider requires no permission. + fn required_permissions(&self) -> PermissionSet { /* none */ } + /// Client-cycle counterpart to generate: mints from a value the page + /// posted to the resolve endpoint, after verifying it. Default: no-op, + /// so a server-side provider does not participate. See the + /// client-cycle spec. + fn resolve_from_client( + &self, + input: &ClientResolveInput<'_>, + ) -> Result> { /* ... */ } +} +``` + +Core owns the code envelope. At mint it prefixes the provider's value with +`{code}~`, at read-back it strips and checks the code before the provider's +`accepts_id` sees the value part, and the identity graph key preserves the +code verbatim around the provider's canonical form. A cookie carrying +another provider's code is treated as absent, never adopted, so switching +providers cannot silently mix identity populations, and a withdrawal always +acts on a key that can only belong to one provider. The built-in HMAC +provider mints `hmac~<64 hex>.<6 alphanumeric>` and dual-reads its +pre-envelope bare form for one release cycle so deployed cookies keep +working; the bare form belongs to hmac alone. Codes are allocated +append-only in `provider-code-registry.md`, and a leading digit is valid +(`51dd`). + +The draft's alternative shape (`parse` returning a typed `EcId`, +`graph_key_suffix`, `cluster_prefix`, `verify`, and a version-carrying +`GeneratedIdentity`) was not adopted. `verify`, provider versions, and +`mint_version` are tracked follow-up work with the migration spec. +Request data reaches a provider through injected services and the +`RequestInfo` passed at call time, not through a fixed parameter struct, so +a provider can read headers, cookies, client hints, and the URL without a +trait change. + +## 5. Permission enforcement is core's job, for EC providers + +Before minting through an EC provider, core resolves the request's +permission state and refuses when the provider's `required_permissions()` +are not all set. The gate is implemented in `EcContext`. The selected +provider is built once at request read time, its declaration is checked +against the resolved state, and generation is skipped (with a log line +naming the jurisdiction) when the requirement is not met. With no provider +selected, nothing may mint or use an identifier, so the gate is closed +rather than open by default. The enforcement point lands with the +permission model in PR #1045, and the permission model spec governs the +resolution machinery (country and region baselines, signals, and the +requires-signal floor). + +**Recognition and withdrawal always run**, permissions or not. Read-back +acceptance and withdrawal eligibility go through `accepts_id` with no +permission check, and withdrawal handling keeps the raw cookie value even +when the identifier is treated as absent, so an opt-out can always reach +the identity it revokes. A blanket execution gate would refuse to run the +provider in exactly the state an opt-out produces. + +The draft additionally specified an identity activation protocol (a +two-record commit point before any egress), rowless-cookie classification +and per-prefix withdrawal records, negative-record admission rules, and a +typed egress boundary (`AuthorizedIdentity`, +`RedactedRequestView`). **None of that is implemented in these PRs.** +Those positions remain recorded in the draft and are tracked as follow-up +work with the permission model spec, which owns identity-state persistence +and egress typing. The revision record lists them as deferred. + +The gate applies to EC providers **only**. Geo and device are ungated for +two different reasons, stated separately because only one of them is +structural: + +- **Geo: circularity.** The permission set is resolved from jurisdiction, + which is resolved by the geo provider. Gating geo on the resolved set is + unsatisfiable. `PlatformGeo::required_permissions` exists with an empty + default for interface uniformity, and nothing consults it on the lookup + path. +- **Device: host evidence is an explicit opt-in, not authorized by + selection defaults.** Device classification is not an input to permission + resolution. The neutral `builtin` classifier reads only the User-Agent + and makes no host call. The draft went further and made selecting a + fingerprint-reading device provider a startup error pending a separate + security design. The implementation instead ships `[device] provider = +"fastly"` as a selectable opt-in. The Fastly adapter injects a + `HostSignals` service carrying the TLS JA4 and HTTP/2 fingerprints, and + the provider uses them to strengthen the browser/bot gate that guards EC + writes. Identity rows persist the derived classification fields (the JA4 + class segment and a 12-hex-character hash prefix of the HTTP/2 SETTINGS + fingerprint), not raw fingerprints, and the neutral default persists + neither because the builtin provider produces no such fields. + +## 6. Selection, validation, and failure modes + +All configuration validation happens at **settings construction** +(`Settings::finalize_deserialized` runs every check below), so a +misconfiguration expressible in configuration alone is a startup error, +never a silent behavior change. A selection that only the running host can +satisfy (an injected vendor provider, or host fingerprints) fails loudly +when the provider is built, stopping the request rather than degrading. + +| Configuration state | Behavior | +| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[ec] provider` set, its `[ec.providers.]` block missing | Startup error naming the missing block. There is no closed key list in core for EC, because a vendor key is legitimate when its block is present, so an unknown key with no block fails this same check. | +| `[ec.providers.]` block present, `provider` unset | **Startup error.** (In PR #838 this silently ran stateless. The half-migrated config becomes a production identity outage detected by revenue drop. Rejecting it is the fix.) An operator who genuinely wants stateless deletes the block. | +| `provider = "none"` (explicit stateless) | Valid, and means exactly what omitting the selector means. Any configured provider block alongside it is a startup error, the same stray-block rule as below. | +| A configured `[ec.providers.]` block that is not the selected one | **Startup error** (checked for the `hmac` block and every vendor block). An unreferenced block is almost always a mistyped selector or a stale block, and accepting it silently invites configuration drift. | +| A selected vendor key whose provider the adapter did not inject | Loud failure when the provider is built, naming the key, so the deployment never silently runs stateless. | +| `provider = "host-signals"` on a host that supplies no fingerprints | Loud failure when the provider is built. A host that cannot produce `HostSignals` cannot run the provider. | +| `provider = "client-fixed"` in a production build | Startup error. The demonstration provider is compiled only behind the `client-fixed-demo` cargo feature. | +| No `provider`, no providers block | Valid, the neutral default for that concern. | +| Deprecated `[ec] passphrase` | Migrated to `provider = "hmac"` with the passphrase in `[ec.providers.hmac]`, with a deprecation warning naming the new location. Both forms together are rejected so a half-edited file fails loudly instead of one form silently winning. | +| Any unknown key in `[ec]`, `[device]`, `[geo]`, or a built-in provider block | Startup error. `deny_unknown_fields` is on `Ec`, `DeviceConfig`, `GeoConfig`, and both built-in provider config structs, so a typo like `providr`, or a key from a deferred feature (`legacy_providers`, `rewrite_legacy`, `versions`), fails loudly. | +| `[device] provider` names an unknown key | Startup error. Valid keys are `builtin` (default) and `fastly`. | +| `[geo] provider` names an unknown key | Startup error. Valid states are unset (default, no geolocation), `none` (the same, spelled out), and `platform`. | +| `[geo] default_country` unset, or matching no `permissions.yaml` rule | **Startup error.** The value is the permission baseline for a request the geo provider leaves unmatched, so there must always be one and the value must resolve to a real rule. | +| An EC provider configured, no geo provider, `assume_single_jurisdiction` unset | **Startup error.** With geolocation off, every request resolves as `default_country`, so a visitor from any other jurisdiction silently receives the default jurisdiction's rules. That is acceptable only as an explicit operator decision. | + +One draft row was not adopted, the startup error for a minting provider +with no identity-graph store. `[ec] ec_store` remains optional, because the +portability adapters run without platform KV. The client-cycle resolve +endpoint refuses to mint when no graph is available (a cookie without a row +could never be withdrawn through the graph), and the organic path persists +the row whenever the graph is configured. Whether configuration should +force the pairing is follow-up work with the migration spec. + +Vendor provider blocks deserve their own note. Any `[ec.providers.]` +block whose key is not a built-in is captured in core as raw values (a +flattened map), and the adapter that injects the vendor provider +deserializes its own block into the vendor crate's config type. Core never +names a vendor, so a new provider adds nothing to core. The vendor crate +applies its own `deny_unknown_fields` when it deserializes. + +### 6.1 Provider switching: active writer, legacy readers + +Switching `[ec] provider` must not strand the identities the previous +provider minted, and above all must not make a later opt-out unable to +revoke them. The draft specified an ordered `legacy_providers` reader list, +provider `versions` with `mint_version` rotation, provenance tagging, and +retirement evidence rules. **None of that is implemented in these PRs.** +The keys are rejected as unknown, and the design is tracked follow-up work +with the migration spec. + +What the implementation provides today is shape-based continuity. +Read-back and withdrawal go +through the selected provider's `accepts_id`, so after a switch the old +cookies remain recognized exactly when the newly selected provider accepts +their shape (a provider inheriting the default accepts the built-in HMAC +grammar). Old cookies the new provider does not recognize are treated as +absent and never egress. The `cluster_fallback` degradation policy from the +draft is likewise deferred with the cluster capability itself. + +### 6.2 Runtime failure modes + +Startup validation covers configuration. This covers a healthy +configuration meeting an unhealthy runtime. Implemented behavior, each row +logged, none silent: + +| Failure | Behavior | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `generate` returns an error | No identity this request. The organic caller logs at error level and the request proceeds stateless. No cookie is written. | +| A provider mints an identifier outside the global bounds | Rejected at mint, never rewritten. The organic path yields no identity. The resolve endpoint returns 400. | +| Identity-graph write fails at mint | The mint is undone (no identifier, no cookie), with the error logged. The resolve endpoint returns 503. The next eligible request retries. | +| The host-signals provider finds no TLS/HTTP-2 fingerprints | Defers with a warning. No identity this request, and no degraded IP-only identifier is minted under the host-signals name. | +| Geo lookup **fails** (the provider errors) | Every permission resolves to the requires-signal floor, and the failure is logged at error level. The failure is **not** papered over with the `default_country` baseline. | +| Geo resolves **no location**, or a country/region with no rule | The `[geo] default_country` baseline applies. This is the configured-default case, deliberately distinct from the failure row above (`GeoStatus` in `ec/consent.rs`). | +| An incoming cookie value fails the bounds at read-back | Treated as absent, with a warning naming the source. | + +The distinction between a failed lookup and no location is resolved in +core, where `EcContext::read_from_request_resolving_geo` runs the +configured geo provider itself and classifies the outcome, so every +adapter reports the two states identically. The draft's remaining matrix rows (rowless +withdrawal records, promotion, the negative-intent outbox, the identity +safety breaker, cluster-listing degradation) belong to the deferred +material of sections 5 and 6.3. + +### 6.3 Storage contract + +The draft specified a delimiter-free physical key grammar with fixed-width +segments, a provider-code registry, record classes for family revocation, +authority state, negative-intent outbox, rowless withdrawal, and deployment +metadata, wire schemas with known-answer vectors, and a per-field graph-row +contract. The provider-code registry is now implemented: codes are +allocated in `provider-code-registry.md`, carried as the `{code}~` prefix +of every minted identifier, and therefore present in every graph key. The +key grammar differs from the draft in one deliberate way, a tilde separator +instead of delimiter-free fixed width, because pre-envelope bare +identifiers remain deployed and a code such as `51dd` is valid hex, so +delimiter-free parsing could misread a legacy identifier during the +migration window. The remainder (record classes, family revocation, +authority state, outbox, rowless withdrawal, wire schemas, per-field +contract) is not implemented in these PRs and stands as recorded design for +the follow-ups. + +The implemented storage today keys the identity graph by the selected +provider's `normalize_id_for_kv` output verbatim. For the built-in HMAC +provider that is the identifier with the hash segment lowercased, which is +today's key, so every pre-epic row stays reachable and the pre-epic +cluster prefix listing stays intact. For an opaque provider the identifier +itself is the key. Rows carry the same JSON envelope as before the epic, +extended with the derived device-classification fields noted in section 5. + +## 7. Composition root and adapter parity + +Provider construction happens in one place per concern, in core, called by +every adapter. No adapter wires a concrete implementation directly into the +request path: + +- `build_provider` (`ec/provider.rs`) constructs the selected EC provider, + injecting the host's `HostSignals` when supplied and matching an + adapter-injected vendor provider by its `id()`. The provider is built + once per request during `EcContext` construction and reused for + read-back, the permission gate, and minting, so the per-request + triple-build observed in PR #838 (cloning the secret into a fresh box up + to three times per request) is gone. +- `build_device_provider` (`ec/device.rs`) returns the builtin classifier + unless `fastly` is selected, in which case the adapter's closure builds + the host-evidence provider. +- `build_geo_provider` (`platform/mod.rs`) returns `DisabledGeo` unless + `platform` is selected, in which case the adapter's host geo + implementation is used. All four adapters (Fastly, Axum, Cloudflare, + Spin) route their host geo through this selector when they assemble + their runtime services, verified in each adapter's platform wiring. + +All four adapters construct the EC request state through the same core +constructors (`EcContext::read_from_request_resolving_geo` and its +variants), so selector behavior and the geo failure classification are +identical everywhere. The cross-adapter parity suite +(`trusted-server-integration-tests`) asserts geo response parity across +adapters. The EC API routes are Fastly-only today, as section 1 notes, and +the Spin adapter's route list records why. + +The draft's adapter capability matrix (declared per-record-class +consistency semantics, durability and retention proofs, activation and +lease qualification) is **not implemented in these PRs** and is tracked +follow-up work. The matrix's motivating rule is preserved for that +follow-up, which is that "has KV" says nothing about whether a revocation +is observable, so eligibility for identity features must eventually be +declared and checked, not assumed. + +## 8. Crate layout and CI + +Host and vendor provider crates live in nested directories grouped by +capability, with flat package names following the existing convention: + +- `crates/device/fastly` is package `trusted-server-device-fastly`. +- `crates/geo/fastly` is package `trusted-server-geo-fastly`. +- `crates/edgecookie/` is the documented home for vendor EC + crates. The directory currently holds only a README, because the + built-in providers live in core and no vendor crate exists yet. + +The draft mandated flat directories (`crates/trusted-server-geo-fastly`) +and banned placeholder directories. The implementation diverges on both +points. Nested directories scale per vendor as providers multiply, package +names already carry the flat convention, and the README stakes out the +location before the first vendor crate lands. Both divergences are +recorded in the revision table. + +Every new crate is in the `.cargo/config.toml` aliases (`check-fastly`, +`clippy-fastly`, `test-fastly`, `build-fastly`), so the provider crates are +linted with `-D warnings` and tested by the same gates as every other +workspace member, closing the PR #838 gap where new crates compiled only +transitively. + +## 9. Behavior preservation notes + +Two defaults chosen for neutrality change effective behavior on existing +Fastly deployments. Both are called out in the migration spec and must be +prominent in release notes: + +- **Bot gate.** The pre-provider EC bot gate required JA4 and platform + class. The default `builtin` classifier is User-Agent only, so the gate + is weaker by default. The stronger gate is available as `[device] +provider = "fastly"` rather than being startup-rejected as the draft + specified. Release notes call out the weaker default rather than + presenting selection alone as authorization. +- **Geo.** With no geo provider, jurisdiction resolution falls to the + required `[geo] default_country`. The permission model constrains the + combination so it cannot silently grant permissions to mis-attributed + traffic. The default must resolve to a real `permissions.yaml` rule, a + deployment running an EC provider without geo must set + `assume_single_jurisdiction = true`, and a failed lookup resolves to the + requires-signal floor instead of the default. The default flip landed in + the same series as those constraints, honoring the draft's sequencing + requirement that the constraint exist before the flip. + +## 10. Testing strategy + +Implemented, in the crates named: + +- Round-trip tests with a non-default provider, proving an opaque + identifier survives read-back byte for byte (`ec/mod.rs`) and the + client-cycle value survives the full scenario as cookie and KV key + (`ec/resolve.rs`). +- Delegation tests proving the injected-provider wrapper forwards + `accepts_id` and `normalize_id_for_kv` to the inner provider, so a + vendor identifier is never dropped by the built-in defaults. +- Gate tests proving the HMAC provider's declared requirement blocks + generation until the permission is set, and that a provider declaring + nothing requires nothing. +- Settings validation tests covering the section 6 table, including the + missing block, the block without a selector, explicit `none`, the stray + block, unknown selector keys for all three concerns, unknown fields in + every section, the deprecated passphrase migration with its both-forms + rejection, `default_country` validation, and the jurisdiction + acknowledgment. +- Geo builder tests showing the default selects no geo, `none` selects no + geo explicitly, and `platform` selects the host implementation. +- Host-signals provider tests covering minting from fingerprints, + deferring without them, and the loud failure of a selected but + uninjected vendor provider. + +Deferred with their features are the fixture-driven provider conformance +suite, legacy-reader tests, and the parity cases for capability-mismatch +startup failures. + +## 11. Implementation order + +As landed: + +1. **PR #1043, the Edge Cookie provider seam.** The trait with recognition + and KV normalization, the global identifier bounds, selection and + validation, the vendor block capture, the deprecated-passphrase + migration, and the round-trip proof with a non-default provider. +2. **PR #1044, device and geo selection.** `DeviceProvider` with the + builtin default and the opt-in Fastly host-evidence provider, + `PlatformGeo` selection with the no-geo default, all four adapters + routed through the shared builders, and the opt-in host-signals EC + provider (carrying the open review question of section 2). +3. **PR #1045, the permission model.** The enforcement point for + `required_permissions`, the `default_country` requirement and + jurisdiction acknowledgment, and the failed-lookup floor. That change + has its own spec, which this document cross-references rather than + restates. + +The draft's step 4 warning (do not flip the geo neutral default before the +permission model exists) was honored. The flip and its constraints landed +together in the permission model change. + +## 12. Divergences from issue #778 + +This spec supersedes #778 on the following points, so implementation has +one acceptance contract: + +| #778 says | This spec says | Why | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Identifier comparison is a provider operation (`keys_equal`) | Comparison is structural. Read-back acceptance and KV normalization route through the provider, so no comparison method exists (§3, §4) | Satisfies the same requirement with no method to leave uncalled | +| A provider can return response headers | Kept, with a production consumer. EC finalization applies them, and the client-cycle path uses them (§4) | The caller the minimalism rule demands landed in the same series | +| One built-in provider (HMAC) preserving today's behavior | HMAC preserved verbatim, plus the opt-in host-signals built-in (open question, §2) and the demo client-cycle provider | Switching semantics beyond shape-based recognition (`legacy_providers`) remain follow-up work with the migration spec | + +## 13. Revision record vs the 2026-07-31 draft + +One row per divergence between the 2026-07-31 draft and the implementation +this revision describes. + +| Draft position | Implemented position | Why | +| ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Trait surface is a canonicalizing `parse` returning a typed id, plus `graph_key_suffix`, `cluster_prefix`, and `verify` | `accepts_id` (recognition) plus `normalize_id_for_kv` (KV key form), defaults matching the built-in shape. No typed id, key suffix, cluster capability, or `verify`. `keys_equal` stays out, as the draft required. | Recognition and KV keying are the two operations core performs today. A byte-for-byte round-trip test with a non-default provider pins the contract. | +| `GeneratedEdgeCookie::response_headers` and `IdentityInput.permissions` / `.consent` banned as speculative surface | Shipped with production consumers. Finalization applies provider headers, the resolve path returns them, and the organic mint path populates the input fields. | The client-cycle resolve path landed in the same series and is their caller, satisfying the minimalism rule the ban enforced. | +| Identifier bounds enforced at mint and parse | Enforced at mint (`generate` and the resolve endpoint), cookie read-back, and cookie write. Violations rejected outright, never rewritten. `MAX_EC_ID_LEN` in `ec/cookies.rs`. | Every identifier entry point is covered, and the pre-epic sanitizing rewrite was removed as a silent-divergence hazard. | +| `provider = "none"` is valid alongside `legacy_providers` blocks | `none` (or an omitted selector) with any configured provider block is a startup error. | No `legacy_providers` exists in these PRs, so a block alongside statelessness can only be a mistake. | +| Every selection key is closed and unknown keys are startup errors | Device and geo keys are closed. EC vendor keys are open. Unknown blocks are captured as raw values in core, the adapter deserializes its own block, and a selected key with no injected provider fails loudly. | Core never names a vendor, so a vendor provider adds no core change. | +| Capability mismatch is a startup error at adapter wiring time | Configuration coherence fails at startup. A host-capability mismatch (missing `HostSignals`, uninjected vendor) fails loudly when the provider is built, stopping the request. | The adapter capability declaration that would move the check to startup is deferred with the capability matrix. | +| A minting provider with no identity-graph store is a startup error | Not implemented. `ec_store` stays optional. The resolve endpoint refuses to mint without a graph. The organic path persists rows whenever the graph is configured. | Portability adapters run without platform KV. Whether configuration should force the pairing is follow-up work. | +| `[device] provider = "fastly"` is startup-rejected pending a separate security design | Shipped as a selectable opt-in. The Fastly adapter injects `HostSignals`, the provider strengthens the browser/bot gate, and rows persist derived classes, not raw fingerprints. | Selection is an explicit operator opt-in and the neutral default makes no host fingerprint call. | +| The `host-signals` EC provider is deliberately dropped and its selection rejected | Shipped in PR #1044 as an opt-in built-in that defers with a warning when the host supplies no signals. **Open, flagged for the series review**, not settled either way. | Its identifier shape shares the HMAC grammar, and a sign-off row defers host fingerprint processing, so the review decides whether the provider ships in the series. | +| Geo default flip sequenced into the later permission-model step, with an acknowledgment guard | Landed as specified in the same series, with the default of none, `default_country` required and validated against `permissions.yaml`, the `assume_single_jurisdiction` acknowledgment, and a failed lookup resolving to the requires-signal floor with error logging (`GeoStatus`, resolved in core so all adapters agree). | The permission model shipped in PR #1045, so the constraints exist where the draft required them. | +| All adapters serve the full EC feature set identically | Selector behavior is identical through the shared builders and core constructors. The EC API routes (identify, batch-sync, ec/resolve) are Fastly-only, documented in the Spin route list. | The portability adapters do not yet wire platform KV, and the gap is documented rather than silent. | +| Conformance suite, adapter capability matrix, delimiter-free key grammar, `verify`, `legacy_providers`, `versions` / `mint_version` | None of these are in PR #1043 or #1044. All are tracked follow-up work, deferred, not silently dropped. | The shipped seam did not need them, and each returns with the feature that gives it a production caller, per the spec's own minimalism rule. | +| `required_permissions` removed from the device and geo traits, added to the EC trait only at the permission-model step | Present on all three traits from the start, with empty defaults. Core enforces the EC declaration (gate in `EcContext`, landing in PR #1045). No device or geo enforcement point exists. | One uniform declaration seam, with an empty default that gates nothing, avoids the decorative-gate hazard while keeping the interface stable. The geo circularity stands. | +| Flat crate directories (`crates/trusted-server-geo-fastly`), no placeholder directories | Nested directories per capability (`crates/device/fastly`, `crates/geo/fastly`, `crates/edgecookie/`), flat package names. `crates/edgecookie` ships a README before its first crate. | Nested directories scale per vendor, package names already carry the naming convention, and the README stakes out the vendor location. | diff --git a/docs/superpowers/specs/provider-code-registry.md b/docs/superpowers/specs/provider-code-registry.md new file mode 100644 index 000000000..9cad450fb --- /dev/null +++ b/docs/superpowers/specs/provider-code-registry.md @@ -0,0 +1,30 @@ +# Provider-code registry (normative, append-only, never reused) + +Four-character codes (`[a-z0-9]`, zero-padded) that namespace Edge Cookie +identifiers. Every EC provider MUST allocate a code here before it can +exist: the `EdgeCookieProvider::code()` trait method is mandatory, and core +applies the code as the `{code}~` prefix of every identifier the provider +mints, checks it at read-back, and keys the identity graph with it. A +provider only ever sees its own value part, so identifiers from different +providers can never collide in the cookie, the graph, or a withdrawal, and +every identifier records which provider created it. + +Allocation is a reviewed commit to this file; codes are immutable and never +recycled, including for retired providers. A leading digit is valid. The +tilde separator keeps parsing exact while pre-envelope identifiers remain +deployed: a legacy bare identifier contains no tilde and dual-reads under +the built-in HMAC provider only. + +The class of provider expected to grow this table is one that consumes a +web-browser-supplied unique identifier, arriving either as a new web +platform feature or from a user-installed extension, delivered to the edge +through the client-cycle resolve path and verified by the provider before +minting. + +| Code | Provider | Allocated | Status | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------- | +| `hmac` | Built-in HMAC EC provider. Mints `hmac~<64 hex>.<6 alnum>`; dual-reads its pre-envelope bare form for one release cycle so deployed cookies keep working | 2026-08-02 | active | +| `hs00` | Built-in host-signal EC provider (opt-in; TLS JA4 plus HTTP/2 signals plus client IP) | 2026-08-25 | active | +| `cfix` | Client-fixed demonstration provider (compiled only behind the `client-fixed-demo` cargo feature) | 2026-08-25 | active, test and demo only | +| `51dd` | 51Degrees Identifier (51Did) vendor provider | 2026-08-25 | reserved | +| `t0..` | Prefix family reserved for in-tree test providers (`t0cc`, `t0op`, and similar); never valid in configuration | 2026-08-25 | reserved | diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 7c7e83635..ccd062090 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -12,12 +12,21 @@ origin_url = "https://origin.example.com" proxy_secret = "change-me-proxy-secret" [ec] -passphrase = "trusted-server-placeholder-secret" +# Edge Cookie identity is OFF by default: with no provider selected, Trusted +# Server runs statelessly and generates no Edge Cookie. Activate one by +# uncommenting the selector AND its [ec.providers.] block together (a +# block with no selector is rejected at startup), or set the selector with the +# TRUSTED_SERVER__ec__provider environment variable. The built-in hmac provider +# is host-neutral; a vendor provider needs its own cargo feature. +# provider = "hmac" ec_store = "ec_identity_store" pull_sync_concurrency = 3 # cluster_trust_threshold = 10 # cluster_recheck_secs = 3600 +# [ec.providers.hmac] +# passphrase = "replace-with-32-plus-byte-random-secret" + # Example partner configuration. Replace the token before validating/pushing. # [[ec.partners]] # name = "Example Partner"