Add a pluggable Edge Cookie provider seam with the built-in HMAC provider - #1043
Add a pluggable Edge Cookie provider seam with the built-in HMAC provider#1043jwrosewell wants to merge 3 commits into
Conversation
312a4fc to
73b40b9
Compare
83e551d to
e278981
Compare
…ider
First of five PRs decomposing the provider and permission epic. The
EdgeCookieProvider trait routes Edge Cookie minting, cookie read-back,
and KV keying through the selected provider, so a vendor identifier
round-trips verbatim instead of being dropped by the built-in shape
check.
- [ec] provider selector with per-provider [ec.providers.<key>] blocks.
The deprecated [ec] passphrase form still starts for one release
cycle: it maps to provider = "hmac" with a deprecation warning, and a
configuration carrying both forms is rejected. provider = "none"
spells explicit statelessness. A configured block that is not the
selected provider is rejected at startup, as is a block with no
selector.
- Global identifier bounds enforced by core at mint, read-back, and
cookie write: the cookie-safe alphabet [A-Za-z0-9._~-] and a 256-byte
cap. An identifier outside the bounds is rejected loudly, never
rewritten, so the cookie value and the identity-graph key can never
silently diverge.
- The identity graph is keyed by the provider's canonical form of the
identifier (normalize_id_for_kv), so equivalent representations of
one identity share one row.
- Request evidence abstraction (crate::evidence) giving providers read
access to the client IP, headers (including cookies), URL path, and
query parameters.
- Adapter injection seam: RuntimeServices carries an optional vendor
provider, so a vendor provider lives in its own crate and core never
names it. A selected provider the adapter does not inject fails the
request loudly rather than silently running stateless.
- Provider generate failures log at error level with the request
proceeding stateless.
Edge Cookie creation and use stay gated by the existing consent context
exactly as on main, including with no provider selected; the permission
model replaces that input in the third PR of this series.
Config migration: move [ec] passphrase to [ec.providers.hmac] and set
[ec] provider = "hmac". The old form keeps working for one release with
a warning. Passphrases shorter than 32 characters are now rejected at
startup; previously they were accepted.
The design spec for this slice and the next lives at
docs/superpowers/specs/2026-07-30-pluggable-providers-design.md, the
2026-07-31 draft revised to match the implementation with a
revision-record table of every divergence.
Every provider carries a mandatory registered four-character code
(provider-code-registry.md): core mints {code}~value, checks the code
at read-back, and keys the identity graph with it, so identifiers from
different providers can never collide and a switch of provider cannot
silently adopt another provider's identities. The built-in hmac
provider mints hmac~<hash>.<suffix> and dual-reads its pre-envelope
bare form for one release cycle.
e278981 to
4529151
Compare
Since the provider-code envelope, the mint path issues identifiers as
hmac~{64hex}.{6alnum}, and that is the value identify hands to partners.
Pull sync, batch sync and the admin lookup still validated the bare
shape through is_valid_ec_id, so pull sync skipped every freshly minted
identifier, batch sync answered invalid_ec_id for the value partners were
given, and the admin lookup answered 400. CI stayed green because the
lifecycle scenario seeds a bare cookie.
is_valid_ec_id now accepts the hmac envelope as well as the legacy bare
form and rejects any other provider's code, and normalize_ec_id_for_kv
keeps the envelope so the key matches the one written at mint. Tests
cover the validator, the normalizer and each of the three call sites
with a coded identifier.
The five-PR series (IABTechLab#1043 to IABTechLab#1047) opens the identity, device and geo seams. The nine vendor integrations already in core sit behind the integration registry instead, which is a private table, so none of them can move out until that table is opened. This spec defines the one core change that opens it: public registration builders with a second input on IntegrationRegistry, browser JavaScript carried on the registration, startup validation as a hook, the same treatment for auction providers and the bid renderer contract, and neutral replacements for the two places where a vendor reaches into core. It then sets out the migration of all nine existing integrations, one PR each. The change is complete in itself: after it, no vendor move needs a core change. Written against the series' tree with the file and line references for every claim about the current code. Documentation only.
CodeQL's cleartext-logging query treats a call whose name contains "passphrase" as a sensitive source, and because the method mutates the Settings it belongs to, every later log line that prints anything from Settings (store names, timeouts, header names) is reported as writing a secret to a log. The passphrase itself is a Redacted<String> and none of the flagged lines prints it. The method now describes what it does, migrate_legacy_ec_layout, and its behavior is unchanged.
aram356
left a comment
There was a problem hiding this comment.
Summary
This PR lands the Edge Cookie provider seam with the built-in HMAC provider, per the pluggable-providers design spec carried in the same change. The lifecycle contract (mint, recognition, KV keying), the global identifier bounds, startup validation, the deprecated-passphrase migration, and the partner-path envelope fix are substantially implemented, with strong test coverage, and CI is fully green.
The major blocker is architectural: vendor extensibility should lean on the existing integration system rather than introduce a parallel "provider" mechanism. The codebase has one established home for vendor code (the integration registry), and this PR adds a second seam, a second config namespace, and a second nomenclature for what a vendor ships. We want that resolved at spec level before PRs 2-5 of the series build on the current shape - see the first cross-cutting finding below.
Beyond that, changes are requested on: a reproduced bypass of the advertised 32-byte passphrase minimum on the deprecated configuration form, two points where the implementation does not do what the spec states (unknown-key rejection in the hmac block; canonical-key routing on identity-graph reads and withdrawals), and an egress guarantee the proxy paths do not honor.
4 of the inline comments below carry a one-click GitHub
suggestion. Use Commit suggestion (or Add suggestion to batch) to apply them as commits on the PR branch. The remaining comments describe the fix in prose because the change spans multiple files or non-contiguous lines and cannot be auto-applied.
Blocking
🔧 wrench
- Vendor identity should lean on the integration system, not a second extension mechanism - the major blocker; cross-cutting, below
- Legacy
[ec] passphrasebypasses the new 32-byte minimum - see inline atcrates/trusted-server-core/src/settings.rs:658(suggestion) [ec.providers.hmac]silently accepts unknown keys - see inline atcrates/trusted-server-core/src/settings.rs:726(suggestion)- Identity-graph reads and withdrawal tombstones bypass the provider's canonical key - cross-cutting, below
❓ question
- Spec says an unrecognized cookie value is "never used or egressed", but the proxy forwarding paths egress it - cross-cutting, below
Non-blocking
♻️ refactor / 🤔 thinking / ⛏ nitpick / 📌 out of scope
- ♻️
build_providersilently returnsOk(None)forprovider = "hmac"with no block - see inline atcrates/trusted-server-core/src/ec/provider.rs:304(suggestion) - ⛏ 22-space run inside the mint-rejection error message - see inline at
crates/trusted-server-core/src/ec/mod.rs:444(suggestion) - ♻️
EdgeCookieProvider's doc comment is fused intoProviderCode's, leaving the trait undocumented - see inline atcrates/trusted-server-core/src/ec/provider.rs:177 - ⛏
ec::get_ec_idis dead code, yet was modified to accept any provider code - see inline atcrates/trusted-server-core/src/ec/mod.rs:137 - ⛏ Module docs describe constructor injection that is not how
RequestInfoflows - see inline atcrates/trusted-server-core/src/ec/provider.rs:4 - 🤔 Cluster prefix listing splits across the envelope migration - cross-cutting, below
- ♻️ Magic strings
"hmac"/"none"scattered across four call sites - cross-cutting, below - 🤔
RequestInfoaccessors have no production consumer in this PR - cross-cutting, below - 🤔 Spec revision followed the implementation - cross-cutting, below
- 📌 Operator guides still document
[ec] passphraseas the current form - cross-cutting, below
Cross-cutting / body-level findings
-
🔧 Vendor identity should lean on the integration system, not a second extension mechanism (the major blocker). The codebase already has one home for vendor code: the integration registry (
IntegrationRegistration::builder(ID).with_proxy().with_head_injector()...), capability-based and config-namespaced under[integrations.<id>]. This PR adds a second vendor seam -RuntimeServices::ec_provider, a single-slotOption<Arc<dyn EdgeCookieProvider>>matched byid(), configured under[ec.providers.<key>]- and a second nomenclature ("providers").RuntimeServicesis otherwise the platform composition surface (KV store, geo, HTTP client, client info: things the host supplies); a vendor identity module is not a host capability, and a vendor realistically ships a JS integration and an identity function together, which this split forces into two mechanisms. Please rework the vendor seam onto the integration system: identity provision as a registration capability (for example.with_ec_provider(...)), with[ec] provider = "<integration id>"still supplying the select-exactly-one semantics; the built-in HMAC provider can stay hard-wired in core as the default, and geo/device rightly remain platform services. If there is a reason this cannot work, the spec should defend the separate provider mechanism against this alternative explicitly - and we want that settled at spec level before PRs 2-5 of the series build on the current shape. -
🔧 Identity-graph reads and withdrawal tombstones bypass the provider's canonical key. The spec's lifecycle table (section 3) routes identity-graph row reads and writes through
normalize_id_for_kv. Mint honors that:EcContext::generate_with_providerkeys the row withprovider_kv_key(ec/mod.rs:476). Buthandle_identifyreads with the raw cookie value (kv.get(ec_id),ec/identify.rs:89), withdrawal tombstones are written under the raw value (ec/finalize.rs, thewrite_withdrawal_tombstoneloop), and EID ingestion keys by the raw value. For the built-in HMAC provider raw and canonical coincide, so nothing misbehaves today; for the first provider whose canonical form differs from the cookie value (exactly theCanonicalizingProvidercase this PR's own test proves at mint), identify misses the row written at mint, and a withdrawal tombstone lands on a key no live row uses, so the revocation never takes effect. Proposed fix: compute the canonical key once inEcContext(for example anec_kv_key()accessor derived from the selected provider) and use it in identify, the finalize tombstones, and EID ingestion - or amend the spec to state that reads and withdrawals become canonical-form-routed only when the first canonicalizing provider ships, and track that as a follow-up. -
❓ Spec says an unrecognized cookie value is "never used or egressed", but the proxy forwarding paths egress it. Section 3's Recognize row states that a value the selected provider does not recognize "is never used or egressed."
append_ec_id(proxy.rs:1263) andhandle_first_party_click(proxy.rs:1609) forward the rawts-eccookie /x-ts-echeader value to origin and click-target URLs throughedge_cookie::get_ec_id, which checks only the character/length allowlist - so a foreign-coded value (zz00~...), or any cookie in a stateless (no-provider) deployment, is egressed on those paths. The looseness predates this PR, but the PR introduces the spec claim. Which should change - the spec (scope the guarantee to the EC lifecycle paths and note the proxy forwarding exception) or the code (route those call sites through provider ownership)? -
🤔 Cluster prefix listing splits across the envelope migration. Section 3 says the pre-epic IP-cluster prefix listing "continues unchanged." Fresh mints are now keyed
hmac~<hash>.<suffix>, soevaluate_cluster's prefix (ec_hash,ec/kv.rs:715) becomeshmac~<hash>for coded rows while legacy rows still list under the bare<hash>. Two rows for the same client IP that straddle the envelope migration therefore never count each other, andcluster_size(a NAT/fraud signal in identify responses) undercounts while both populations coexist. Worth a sentence in the spec, and possibly a follow-up to bridge the count during the migration window. -
♻️ Magic strings
"hmac"/"none"are scattered across four call sites (Ec::validate_provider_selection,build_provider,provider_owns_id'sprovider.id() == "hmac", andHMAC_PROVIDER_CODEinec/generation.rs). A typed selector, for exampleenum EcProviderSelection { None, Hmac, Vendor(String) }with a custom deserializer (vendor keys are open-ended, so a catch-all variant is needed), would centralize the vocabulary before #1044 adds more built-ins. Non-blocking: the string form works and is startup-validated. -
🤔
RequestInfoaccessors have no production consumer in this PR.path(),query(),query_param(),header_names(),user_agent(), andheader()are supplied by production code but consumed only by tests in this PR (the HMAC provider reads onlyclient_ip()). The spec's own minimalism rule (section 4) requires a production caller in the same change that introduces a method; the consumers arrive later in the stack. For a stacked series this can be acceptable, but the spec should say which PR consumes each accessor, or the accessors should land with their consumers. -
🤔 Spec revision followed the implementation. The spec is commendably candid that it is the 2026-07-31 draft "revised against the implementation" with a revision-record table, and that table is genuinely useful. The process consequence is worth naming, though: when the normative spec is restated to match landed code, divergences become ratifications rather than decisions, and questions like the extension-model one above surface at review time instead of design time. For the remaining PRs in the series, it would serve the spec-first intent better to land spec changes ahead of the implementing PR and let review happen against the spec before the code exists.
-
📌 Operator guides still document
[ec] passphraseas the current form.docs/guide/configuration.md:1933,docs/guide/key-rotation.md:31,docs/guide/error-reference.md:72, plusec-setup-guide.md/edge-cookies.md/fastly.mdpredate the provider layout, the deprecation, and the new stateless default intrusted-server.example.toml. A docs pass is needed in this series; a follow-up PR is fine.
CI Status
- cargo fmt: PASS (required)
- cargo test: PASS (required)
- format-docs: PASS (required)
- format-typescript: PASS (required)
- cargo test (axum native): PASS
- cargo test (cloudflare native + wasm32-unknown-unknown check/build): PASS
- cargo check/build/test (spin native + wasm32-wasip1): PASS
- cargo test (cross-adapter parity): PASS
- cargo test (ts CLI, native): PASS
- cargo check (cloudflare native + wasm32-unknown-unknown): PASS
- vitest: PASS
- Analyze (rust): PASS
- Analyze (javascript-typescript): PASS
- Analyze (actions): PASS
- CodeQL: PASS
- prepare integration artifacts: PASS
- integration tests: PASS
- integration tests (Fastly EC lifecycle): PASS
- browser integration tests: PASS
| 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(()) |
There was a problem hiding this comment.
🔧 wrench - The advertised 32-byte passphrase minimum is bypassed on the deprecated form. finalize_deserialized runs derive validation before migrate_legacy_ec_layout(), and this deprecated field no longer carries a #[validate] attribute, so [ec] passphrase = "short" (or an empty value) migrates and starts successfully. Reproduced with a scratch test: Settings::from_toml returns Ok for a legacy 5-byte passphrase. That contradicts the PR description ("enforced wherever the passphrase is configured") and the commit message. Validating inside the migration keeps the enforcement self-contained for every construction path:
| 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(()) | |
| Self::validate_passphrase(&passphrase).map_err(|err| { | |
| Report::new(TrustedServerError::Configuration { | |
| message: format!( | |
| "[ec] passphrase (deprecated) is invalid ({err}): use a random secret \ | |
| of at least {} bytes, placed in [ec.providers.hmac]", | |
| Self::MIN_PASSPHRASE_LENGTH, | |
| ), | |
| }) | |
| })?; | |
| log::warn!( | |
| "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ | |
| set [ec] provider = \"hmac\"" | |
| ); | |
| self.provider = Some("hmac".to_owned()); | |
| self.providers.hmac = Some(HmacProviderConfig { passphrase }); | |
| Ok(()) |
Verified: with this applied, the legacy short passphrase fails startup, and the full local gate passes.
| #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] | ||
| pub struct HmacProviderConfig { |
There was a problem hiding this comment.
🔧 wrench - The spec (section 6) states deny_unknown_fields is on "both built-in provider config structs", but this struct has no such attribute, so [ec.providers.hmac] passphrase = "..." typo_key = "x" is silently accepted. (A typo'd block name is caught by the stray-block rule; a typo'd key inside the block is not.)
| #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] | |
| pub struct HmacProviderConfig { | |
| #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] | |
| #[serde(deny_unknown_fields)] | |
| pub struct HmacProviderConfig { |
Verified: with this applied, an unknown key in the hmac block fails startup, and the full local gate passes.
| "hmac" => ec | ||
| .providers | ||
| .hmac | ||
| .as_ref() | ||
| .map(|config| Box::new(HmacProvider::new(config.passphrase.clone())) as _), |
There was a problem hiding this comment.
♻️ refactor - When provider = "hmac" is selected but the block is absent, this arm returns Ok(None) and the deployment silently runs stateless. Settings validation rejects that configuration at startup, but if this seam is ever reached with such a config (programmatic Settings, a future construction path), the result is the exact "silent identity outage" the spec's failure-mode table exists to prevent. The vendor arm fails loudly; this arm should too:
| "hmac" => ec | |
| .providers | |
| .hmac | |
| .as_ref() | |
| .map(|config| Box::new(HmacProvider::new(config.passphrase.clone())) as _), | |
| "hmac" => match ec.providers.hmac.as_ref() { | |
| Some(config) => Some(Box::new(HmacProvider::new(config.passphrase.clone())) as _), | |
| // Settings validation rejects a selected provider with no block; | |
| // if that is bypassed, fail loudly rather than silently running | |
| // stateless. | |
| None => { | |
| return Err(Report::new(TrustedServerError::EdgeCookie { | |
| message: "Edge Cookie provider `hmac` is selected but [ec.providers.hmac] \ | |
| is not configured" | |
| .to_owned(), | |
| })); | |
| } | |
| }, |
| 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", |
There was a problem hiding this comment.
⛏ nitpick - This string carries a 22-space run (a missing \ line continuation), so the logged error reads "...bytes, or outside the cookie-safe alphabet".
| "Provider `{}` produced an identifier that is empty, over {} bytes, or outside the cookie-safe alphabet", | |
| "Provider `{}` produced an identifier that is empty, over {} bytes, or \ | |
| outside the cookie-safe alphabet", |
| } | ||
| } | ||
|
|
||
| pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { |
There was a problem hiding this comment.
♻️ refactor - This trait has no doc comment: the paragraph written for it ("A strategy for deriving an Edge Cookie identifier...") is fused into the doc block of ProviderCode above (lines 58-77), where it reads as part of that struct's documentation. The stray text is also stale: it says a provider returns Ok(None) from generate, but generate returns GeneratedEdgeCookie { id: None }. Apply manually (two non-contiguous edit sites, so this cannot be a single suggestion): move the strategy paragraph here, reword the Ok(None) sentence to the id: None semantics, and leave ProviderCode with only its own registry-code doc.
| // 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 |
There was a problem hiding this comment.
⛏ nitpick - This pub fn get_ec_id has no callers anywhere in the workspace (proxy.rs and testlight.rs use edge_cookie::get_ec_id), yet this PR loosened its filter to accept any {code}~ value without an ownership check against the selected provider. A future caller picking it up would adopt foreign-coded identifiers that EcContext deliberately treats as absent. Either delete the function or align its filter with provider_owns_id.
| //! 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 |
There was a problem hiding this comment.
⛏ nitpick - The module doc says a provider's constructor takes the services it needs, with RequestInfo as the example, but RequestInfo is passed at generate call time, not at construction; the opening sentence is also garbled ("...for the client IP) (the adapter, through [build_provider]) supplies instances per request."). Same constructor-injection claim in evidence.rs lines 3-7. Worth a small rewrite so the first thing a vendor implementer reads matches the trait signature.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Reviewed the pluggable Edge Cookie provider changes at 0f5c063214ba1d46478311851f08fe9b10c2ccf8. I am requesting changes based on the inline findings. This review includes one P1, three P2s, and one non-blocking migration clarification. cargo test-fastly and all 18 GitHub checks passed at the reviewed head; these findings concern runtime and provider-contract behavior rather than test failures.
| .filter(|provider| provider.id() == other) | ||
| .map(|provider| Box::new(SharedProvider(provider)) as _); | ||
| if provider.is_none() { | ||
| return Err(Report::new(TrustedServerError::EdgeCookie { |
There was a problem hiding this comment.
P1: Portability adapters swallow unavailable-provider errors
build_provider correctly returns an error here, but Axum, Cloudflare, and Spin catch it from read_from_request_with_geo and replace the EC context with EcContext::default(), so the request continues without identity. That contradicts the provider contract, which says an unavailable required service or injected provider stops the request. Fastly already propagates the error. Please return an error response in those adapters or reject the selection while building adapter state, and add a regression test for an uninjected provider on each adapter.
| // 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()); |
There was a problem hiding this comment.
P2: Provider response effects can overwrite core-managed state
These headers are inserted without checking names or cookie ownership. A provider can return Set-Cookie: ts-ec=..., including when it returns no identifier, and bypass core's identifier validation, graph-write requirement, and managed EC cookie code. It can also overwrite reserved x-ts-* or response-framing headers. Providers may legitimately need their own evidence cookies, so banning every Set-Cookie would be too broad. Please validate these effects or expose a typed response API that reserves the managed ts-* cookie names, the x-ts-* namespace, and framing or hop-by-hop headers while allowing provider-owned cookies.
| let mut parts = value.split('.'); | ||
| let bare = match split_provider_code(value) { | ||
| (Some(code), bare) if code == HMAC_PROVIDER_CODE => bare, | ||
| (Some(_), _) => return false, |
There was a problem hiding this comment.
P2: Partner paths reject identifiers from the next provider
is_valid_ec_id explicitly rejects every provider code except hmac, and pull sync, batch sync, and the admin lookup all call it. This is already concrete in the stacked work: PR #1044 adds the hs00~ host-signal provider without changing these consumers, so its valid identifiers work in organic read and write paths but are skipped or rejected by all three partner and diagnostic paths. Please separate global cookie bounds from provider-specific validation, dispatch validation and KV normalization by provider code, and cover a non-HMAC identifier in pull sync, batch sync, and admin lookup tests.
| // 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() { |
There was a problem hiding this comment.
P2: Generic generation requires client IP before calling the provider
This check rejects the request before the selected provider can decide whether it needs client IP. RequestInfo::client_ip already defines an empty string as the unavailable state, and providers are meant to read only the request evidence they need. The generic check therefore blocks header-, cookie-, query-, and client-derived providers that can operate without IP. Please move the requirement into HmacProvider and any other provider that uses IP, then pass the documented empty value to providers that do not.
| /// 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 |
There was a problem hiding this comment.
Non-blocking: Define when the bare HMAC reader can be removed
This comment promises one release cycle of bare-HMAC compatibility, but returning users do not have their bare cookie rewritten and the cookie lifetime is one year. The current code is safe while this reader remains. Before scheduling its removal, please define a retirement condition based on the maximum cookie and graph-row lifetime plus rollout skew and observed legacy-reader traffic. Otherwise, remove the one-release wording and keep the reader.
First of five stacked PRs decomposing #838 as requested in the #986 review, where each PR carries one feature and its design spec. This PR is the Edge Cookie provider seam. The stack order is #1043, #1044, #1045, #1046, #1047. Each PR's own change is visible by comparing its head branch to the previous PR's head branch, and this first PR is independently mergeable to
main.Spec: docs/superpowers/specs/2026-07-30-pluggable-providers-design.md, which is the Tech Lab 2026-07-31 draft revised to match this implementation, with a revision-record table listing every divergence and why. The spec covers both this PR (the EC seam) and #1044 (device and geo selection).
What this PR does
Edge Cookie identity generation becomes a selectable provider behind the
EdgeCookieProvidertrait incrates/trusted-server-core/src/ec/provider.rs, with the existing HMAC implementation as the built-in and configuration selecting it.[ec] providernames a block under[ec.providers.<key>]. Omitted means stateless with no Edge Cookie, andprovider = "none"spells the same choice explicitly (rejected if provider blocks are left configured). A selected provider with no block, an unreferenced stray block, and an unknown key in a block all fail at startup, so misconfiguration is loud.[ec] passphraseform still starts. It migrates toprovider = "hmac"plus[ec.providers.hmac]with a deprecation warning, so a fleet can move configuration and binaries independently. Both forms together are rejected.[A-Za-z0-9._~-], enforced at mint, cookie read-back, and cookie write. A violating identifier is rejected outright and never rewritten, so the cookie value and the identity-graph key can never silently diverge (the previous sanitize-by-stripping path is removed).accepts_id, and the identity-graph key through itsnormalize_id_for_kvcanonical form, so an opaque vendor identifier round-trips byte-for-byte. One test proves a non-default provider round-trips verbatim and another proves the graph is keyed by the canonical form.[ec.providers.<key>]blocks are captured as raw values in core and deserialized by the adapter that injects the vendor provider, so core never names a vendor.{code}~value, checks the code at read-back, and keys the identity graph with it, so identifiers from different providers can never collide and switching providers cannot silently adopt another provider's identities. The built-in provider mintshmac~<hash>.<suffix>and dual-reads its pre-envelope bare form for one release cycle, so deployed cookies keep working.hmac~identifier was skipped by pull sync, refused by batch sync and answered 400 by the admin lookup while CI stayed green on a bare seeded cookie.is_valid_ec_idnow accepts thehmac~envelope as well as the legacy bare form and rejects any other provider's code,normalize_ec_id_for_kvkeeps the envelope so the key matches the one written at mint, and each of the three call sites has a test with a coded identifier (commit6f15e50c0).Breaking change
A minimum HMAC passphrase length of 32 bytes is now enforced wherever the passphrase is configured. A shorter passphrase that previously started will fail startup validation with a direct message.
How it was verified
Full local gate on this branch, all clean.
cargo test-fastly(core plus adapter suites),cargo test-axum,cargo test-cloudflare,cargo test-spin, the integration parity suite,cargo fmt --check, and all six per-target clippy aliases.Framing
Privacy is a spectrum, and this change is neutral infrastructure. It does not decide whether identity is created, it makes that decision configurable and inspectable, and the deployer selects a provider (or none) according to the laws and policies that apply to them. Trust comes from that flexibility being respected and visible in configuration rather than hard-coded.
References #777. Decomposes #838 (kept as a draft reference until this series merges). Spec baseline from #986.
Produced with AI assistance under James Rosewell's direction, and flagged here so reviewers know to apply the usual scrutiny.