Skip to content

Add a pluggable Edge Cookie provider seam with the built-in HMAC provider - #1043

Open
jwrosewell wants to merge 3 commits into
IABTechLab:mainfrom
jwrosewell:split/1-ec-provider
Open

Add a pluggable Edge Cookie provider seam with the built-in HMAC provider#1043
jwrosewell wants to merge 3 commits into
IABTechLab:mainfrom
jwrosewell:split/1-ec-provider

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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 EdgeCookieProvider trait in crates/trusted-server-core/src/ec/provider.rs, with the existing HMAC implementation as the built-in and configuration selecting it.

  • [ec] provider names a block under [ec.providers.<key>]. Omitted means stateless with no Edge Cookie, and provider = "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.
  • The deprecated [ec] passphrase form still starts. It migrates to provider = "hmac" plus [ec.providers.hmac] with a deprecation warning, so a fleet can move configuration and binaries independently. Both forms together are rejected.
  • Identifier bounds are global and provider-independent, with at most 256 bytes and the alphabet [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).
  • Read-back goes through the selected provider's accepts_id, and the identity-graph key through its normalize_id_for_kv canonical 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.
  • Vendor [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.
  • Every provider carries a mandatory registered four-character code (provider-code-registry.md, the registry your spec set defines). 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 switching providers cannot silently adopt another provider's identities. The built-in provider mints hmac~<hash>.<suffix> and dual-reads its pre-envelope bare form for one release cycle, so deployed cookies keep working.
  • The partner-facing paths accept the enveloped form. A cold read on 27 August found that pull sync, batch sync and the admin lookup still validated the bare shape, so a freshly minted 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_id now accepts the hmac~ envelope as well as the legacy bare form and rejects any other provider's code, normalize_ec_id_for_kv keeps 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 (commit 6f15e50c0).
  • Generation failures log at error level, not warn.

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.

…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.
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.
jwrosewell added a commit to jwrosewell/trusted-server that referenced this pull request Aug 27, 2026
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
aram356 requested review from ChristianPavilonis, aram356 and prk-Jr and removed request for prk-Jr August 27, 2026 15:57

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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] passphrase bypasses the new 32-byte minimum - see inline at crates/trusted-server-core/src/settings.rs:658 (suggestion)
  • [ec.providers.hmac] silently accepts unknown keys - see inline at crates/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_provider silently returns Ok(None) for provider = "hmac" with no block - see inline at crates/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 into ProviderCode's, leaving the trait undocumented - see inline at crates/trusted-server-core/src/ec/provider.rs:177
  • ec::get_ec_id is dead code, yet was modified to accept any provider code - see inline at crates/trusted-server-core/src/ec/mod.rs:137
  • Module docs describe constructor injection that is not how RequestInfo flows - see inline at crates/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
  • 🤔 RequestInfo accessors have no production consumer in this PR - cross-cutting, below
  • 🤔 Spec revision followed the implementation - cross-cutting, below
  • 📌 Operator guides still document [ec] passphrase as 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-slot Option<Arc<dyn EdgeCookieProvider>> matched by id(), configured under [ec.providers.<key>] - and a second nomenclature ("providers"). RuntimeServices is 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_provider keys the row with provider_kv_key (ec/mod.rs:476). But handle_identify reads with the raw cookie value (kv.get(ec_id), ec/identify.rs:89), withdrawal tombstones are written under the raw value (ec/finalize.rs, the write_withdrawal_tombstone loop), 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 the CanonicalizingProvider case 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 in EcContext (for example an ec_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) and handle_first_party_click (proxy.rs:1609) forward the raw ts-ec cookie / x-ts-ec header value to origin and click-target URLs through edge_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>, so evaluate_cluster's prefix (ec_hash, ec/kv.rs:715) becomes hmac~<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, and cluster_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's provider.id() == "hmac", and HMAC_PROVIDER_CODE in ec/generation.rs). A typed selector, for example enum 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.

  • 🤔 RequestInfo accessors have no production consumer in this PR. path(), query(), query_param(), header_names(), user_agent(), and header() are supplied by production code but consumed only by tests in this PR (the HMAC provider reads only client_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] passphrase as the current form. docs/guide/configuration.md:1933, docs/guide/key-rotation.md:31, docs/guide/error-reference.md:72, plus ec-setup-guide.md / edge-cookies.md / fastly.md predate the provider layout, the deprecation, and the new stateless default in trusted-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

Comment on lines +652 to +658
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(())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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:

Suggested change
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.

Comment on lines +725 to +726
#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)]
pub struct HmacProviderConfig {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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.)

Suggested change
#[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.

Comment on lines +300 to +304
"hmac" => ec
.providers
.hmac
.as_ref()
.map(|config| Box::new(HmacProvider::new(config.passphrase.clone())) as _),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ 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:

Suggested change
"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",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick - This string carries a 22-space run (a missing \ line continuation), so the logged error reads "...bytes, or outside the cookie-safe alphabet".

Suggested change
"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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ChristianPavilonis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants