diff --git a/.changeset/tmp-router-attestation-rfc.md b/.changeset/tmp-router-attestation-rfc.md new file mode 100644 index 0000000000..9ccfc63334 --- /dev/null +++ b/.changeset/tmp-router-attestation-rfc.md @@ -0,0 +1,13 @@ +--- +"adcontextprotocol": minor +--- + +spec: TMP router attestation (experimental, `trusted_match.router_attestation`) + +Promotes the existing one-paragraph "TEE upgrade path" mention in router-architecture into a normative, experimentally-gated feature with a wire shape: a `GET /.well-known/tmp-router-attestation?nonce=` endpoint, a JSON envelope schema (`/schemas/trusted-match/router-attestation.json`), a per-request `X-TMP-Attestation` header carrier, and a new optional `attestation_requirement` block on `provider-registration.json` so providers can declare they require an attested router. Experimental gate `trusted_match.router_attestation` is separate from `trusted_match.core` and `trusted_match.verified_identity`, so participants can support core TMP without committing to attestation. + +The load-bearing invariant is the **binding rule**: the JWK in `signing_key` MUST appear bound in the platform user-data slot of `attestation_document` alongside the nonce, and verifiers MUST reject when the bound key doesn't byte-match the envelope's `signing_key` (RFC 7638 thumbprint comparison is the acceptable form). This rule is what makes the router's existing per-provider `X-AdCP-Signature`/`X-AdCP-Key-Id` signature path cryptographically anchored to the attested binary instead of to an out-of-band-deployed key. + +Scope is wire shape only. Out of scope: reproducible-build pipeline, KMS provisioning, `adcp-go` implementation, conformance scenarios, verifier-kit implementations, measurement allowlists. The `attestation_format` enum starts with four canonical externally-defined formats (`aws_nitro_cose_sign1_v1`, `intel_tdx_quote_v4`, `amd_sev_snp_attestation_v1`, `gcp_confidential_space_v1`) — same canonical-external-identifier pattern as `enums/feed-format.json`; entries added to the platform-agnostic lint allowlist with path-qualified justifications. Extension is via `ext` plus future enum additions through the normal change process. + +Additive change to an experimental surface: existing TMP behavior is unchanged for participants that do not opt in (no `attestation_requirement` block on a provider's registration means routers do not attach `X-TMP-Attestation` and providers do not require it). Schemas: new `router-attestation.json`; `provider-registration.json` gains optional `attestation_requirement` (additive, not in `required`). Docs: new `docs/trusted-match/router-attestation.mdx` normative page; `router-architecture.mdx` and `privacy-architecture.mdx` keep their informative framing and point at the normative page for the wire shape. Design rationale in `specs/tmp-router-attestation.md`. diff --git a/docs.json b/docs.json index 0902c64eec..2909c15c39 100644 --- a/docs.json +++ b/docs.json @@ -1161,6 +1161,7 @@ "docs/trusted-match/context-and-identity", "docs/trusted-match/specification", "docs/trusted-match/router-architecture", + "docs/trusted-match/router-attestation", "docs/trusted-match/privacy-architecture", "docs/trusted-match/data-protection-roles", "docs/trusted-match/migration-from-axe", diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index d14f31cfdd..93da73a55f 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -596,14 +596,14 @@ Array of experimental AdCP surfaces this agent implements. A surface is experime | Field | Type | Description | |-------|------|-------------| -| `experimental_features` | string[] | Experimental feature ids (e.g., `["brand.rights_lifecycle", "governance.campaign", "measurement.core", "trusted_match.core", "sponsored_intelligence.core"]`) | +| `experimental_features` | string[] | Experimental feature ids (e.g., `["brand.rights_lifecycle", "governance.campaign", "measurement.core", "trusted_match.core", "trusted_match.router_attestation", "sponsored_intelligence.core"]`) | Buyers should inspect `experimental_features` before relying on an experimental surface. A seller that does not list a surface is asserting it does not implement it — there is no "silently experimental" mode. **Example:** ```json { - "experimental_features": ["brand.rights_lifecycle", "measurement.core", "trusted_match.core"] + "experimental_features": ["brand.rights_lifecycle", "measurement.core", "trusted_match.core", "trusted_match.router_attestation"] } ``` diff --git a/docs/trusted-match/privacy-architecture.mdx b/docs/trusted-match/privacy-architecture.mdx index c34290abb0..b19d0f0cf5 100644 --- a/docs/trusted-match/privacy-architecture.mdx +++ b/docs/trusted-match/privacy-architecture.mdx @@ -126,13 +126,14 @@ Temporal decorrelation is defense in depth. It is not the primary separation mec ## TEE Attestation Details -TMP's reference architecture targets AWS Nitro Enclaves, though the protocol is TEE-agnostic. Any TEE that produces verifiable attestation documents is compatible. +TMP is TEE-agnostic; the experimental [Router Attestation](/docs/trusted-match/router-attestation) surface (`trusted_match.router_attestation`) defines the normative wire shape (endpoint, envelope schema, nonce policy, signing-key binding rule, verification flow, `X-TMP-Attestation` per-request carrier, and provider-side `attestation_requirement` opt-in). The treatment in this section is informative — what attestation does and does not prove, and how it relates to TMP's regulatory and audit posture. See the normative page for the format-version enum, the verification flow, and the failure modes. ### What attestation proves -- The router binary running inside the enclave matches the published, audited source code. -- The code paths for context and identity are structurally separate, with no shared state. +- The router binary running inside the enclave matches the published, audited source code (subject to the verifier's local measurement allowlist). +- The code paths for context and identity are structurally separate, with no shared state, on the running binary — not only on the published source. - The binary has not been modified by the operator, the hosting provider, or any runtime process. +- The signing key the provider already verifies request signatures against is held by the attested binary — see the [binding rule](/docs/trusted-match/router-attestation#binding-rule). Without this binding, attestation proves that *some* binary is attested but says nothing about whether that binary is what produced the signatures the provider trusts. ### What attestation does not prove @@ -140,18 +141,9 @@ TMP's reference architecture targets AWS Nitro Enclaves, though the protocol is - That the publisher's join logic is correct. The publisher is the first party and is not constrained by TMP's separation model. - That the code is free of bugs. Attestation proves the code matches the published source. Whether that source is correct is a separate question, addressed by open-source audit. -### Attestation measurements +### Attestation measurements (informative) -Each attestation document includes cryptographic hashes of the running environment: - -| Measurement | What it covers | -|---|---| -| **Image hash** | Hash of the enclave image. Confirms the binary matches the expected build. | -| **Kernel hash** | Hash of the operating environment. | -| **Application hash** | Hash of the application-level code. | -| **Role hash** | Confirms the enclave's permissions match expectations (e.g., no access to external databases). | - -A publisher or auditor can verify these measurements against the published build artifacts. This verification can be automated and performed continuously. +Platform attestation documents carry cryptographic hashes of the running environment — image hash, kernel hash, application hash, and role/permissions measurements. The exact set, names, and semantics are format-specific and live in each verifier kit; the normative envelope is opaque to AdCP. A publisher or auditor verifies these measurements against the published build artifacts; this verification can be automated and performed continuously. AdCP does not maintain a normative measurement allowlist — see the rationale's "[No measurement allowlist in the spec](https://github.com/adcontextprotocol/adcp/blob/main/specs/tmp-router-attestation.md)" section for why this is a deploy-side decision. ## Comparison to OpenRTB diff --git a/docs/trusted-match/router-architecture.mdx b/docs/trusted-match/router-architecture.mdx index 0de5927306..718a836525 100644 --- a/docs/trusted-match/router-architecture.mdx +++ b/docs/trusted-match/router-architecture.mdx @@ -48,7 +48,7 @@ The router is a single Go binary with two structurally separate code paths: one The separation is in the code and auditable. The context path cannot read identity data because it is not passed to it, not stored in any reachable location, and not referenced in any data structure the context path processes. The same applies in reverse for the identity path. The router is open-source — anyone can verify this by reading the source. -TEE attestation is an upgrade path. Without TEE, you trust that the operator deployed the published binary. With TEE, attestation proves the deployed binary matches the audited source, removing that trust requirement. +TEE attestation is an upgrade path. Without TEE, you trust that the operator deployed the published binary; with TEE-attested operation, the router exposes a verifiable envelope at `/.well-known/tmp-router-attestation` and providers MAY require it via `attestation_requirement` on their registration. The wire shape, binding rule, and verification flow are defined in [Router Attestation](/docs/trusted-match/router-attestation) (experimental, `trusted_match.router_attestation`). ## Provider Registration @@ -197,7 +197,7 @@ The TMP Router replaces this with a multi-provider, multi-surface, protocol-stan | Discovery | Publisher configuration | Publisher configuration | | Surfaces | Web (Prebid Server) | Web, AI, mobile, CTV, retail media | | Request format | Full OpenRTB BidRequest (~2-10KB JSON) | TMP ContextMatchRequest (~200-600 bytes JSON) | -| Privacy | Data masking before sending | Structural separation (TEE-ready) | +| Privacy | Data masking before sending | Structural separation; TEE-attested operation available via [Router Attestation](/docs/trusted-match/router-attestation) (experimental) | | Identity handling | User ID in bid request | Separate Identity Match operation | For existing Prebid Server deployments, the TMP module replaces vendor-specific RTD modules with a generic TMP client. For surfaces without Prebid, the router's HTTP/2 API provides the same functionality. diff --git a/docs/trusted-match/router-attestation.mdx b/docs/trusted-match/router-attestation.mdx new file mode 100644 index 0000000000..9cbc92cba8 --- /dev/null +++ b/docs/trusted-match/router-attestation.mdx @@ -0,0 +1,237 @@ +--- +title: Router Attestation +description: Normative TEE attestation endpoint, envelope, and verification flow for the TMP Router. +"og:title": "AdCP TMP Router Attestation" +--- + +# Router Attestation + +This page defines the wire shape, endpoint, header carrier, and verification flow for TEE attestation of the TMP Router. It is the normative companion to the informative trust framing in [Privacy Architecture](/docs/trusted-match/privacy-architecture) and the router protocol surface in [The TMP Router](/docs/trusted-match/router-architecture). For design rationale, alternatives considered, and security analysis see [`specs/tmp-router-attestation.md`](https://github.com/adcontextprotocol/adcp/blob/main/specs/tmp-router-attestation.md). + + +**Experimental — declare `trusted_match.router_attestation` in `experimental_features`** (separate from `trusted_match.core` and `trusted_match.verified_identity`, so a participant can implement core TMP without committing to attestation). Fields and behavior on this surface are not subject to deprecation cycles until 3.0.0 GA; the surface may change between 3.x releases with at least 6 weeks' notice. See [experimental status](/docs/reference/experimental-status) for the full contract. + + +The protocol's privacy guarantee is structural — context and identity flow through code paths that share no state ([Privacy Architecture](/docs/trusted-match/privacy-architecture#the-separation-principle)). Without attestation, that guarantee depends on operational trust that the deployed binary matches the audited source. Attestation removes that trust requirement: the verifier reads an attestation document from the router, verifies it against the platform vendor's root, and confirms that the binary running inside the enclave matches the published, audited source code, subject to the verifier's local measurement allowlist. This page is how the verifier does that. + +## Endpoint + +``` +GET /.well-known/tmp-router-attestation?nonce= +``` + +- Path: `/.well-known/tmp-router-attestation` (per [RFC 8615](https://datatracker.ietf.org/doc/html/rfc8615)). Public; no authentication. +- Query parameter: `nonce` — base64url-encoded (RFC 4648 §5, unpadded). The decoded value MUST be at least 16 and at most 32 raw bytes of cryptographically random data (22–43 characters in the encoded form). The router MUST reject requests where `nonce` is shorter than 16 raw bytes, longer than 32 raw bytes, or contains characters outside the base64url alphabet. +- Method: `GET`. No request body. +- Response: HTTP `200`, `Content-Type: application/json`, body validating against [`/schemas/trusted-match/router-attestation.json`](https://adcontextprotocol.org/schemas/v3/trusted-match/router-attestation.json). +- Error responses: HTTP `400` for nonce-validation failures, `503` when the enclave is unable to produce an attestation document (e.g., during startup or vendor-side outage). The router MUST NOT return `200` with a partial or malformed envelope. + +The endpoint is rate-limit-sensitive (envelope production involves vendor-side cryptographic operations); routers SHOULD rate-limit per source IP (recommended: 10 req/sec) and SHOULD cache nothing — every request produces a fresh envelope so the verifier-supplied nonce binds to this fetch. + +## Envelope + +The response is a single object matching [`/schemas/trusted-match/router-attestation.json`](https://adcontextprotocol.org/schemas/v3/trusted-match/router-attestation.json): + +```jsonc +{ + "attestation_format": "aws_nitro_cose_sign1_v1", + "attestation_document": "", + "nonce": "", + "signing_key": { + "kid": "router-2026-06", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + }, + "expires_at": "2026-06-30T18:00:00Z" +} +``` + +### Field reference + +| Field | Type | Required | Description | +|---|---|---|---| +| `attestation_format` | enum | Yes | Canonical identifier of the platform attestation format and version. v1 set: `aws_nitro_cose_sign1_v1`, `intel_tdx_quote_v4`, `amd_sev_snp_attestation_v1`, `gcp_confidential_space_v1`. Each value names a single externally-defined attestation format; the verifier kit for the named format parses the document. Extension is via `ext` plus a future enum addition. | +| `attestation_document` | string | Yes | Base64url-encoded (RFC 4648 §5, unpadded) attestation document in the declared format. Opaque to AdCP. Verifier kits perform vendor-root signature verification, extract measurements, and map the envelope's generic `nonce` and `signing_key` onto the format-specific user-data slot. | +| `nonce` | string | Yes | Base64url echo of the request's `nonce` query parameter. The verifier MUST compare this value byte-for-byte against the value it sent (Verification step 1) AND MUST confirm it appears in the user-data slot of `attestation_document` (Verification step 4). | +| `signing_key` | JWK | Yes | The public key the router uses to sign per-provider outbound requests (the `X-AdCP-Signature`/`X-AdCP-Key-Id` path described in [Identity Match signed fields](/docs/trusted-match/specification#identity-match-signed-fields)). Same JWK shape as [`/schemas/core/agent-signing-key.json`](https://adcontextprotocol.org/schemas/v3/core/agent-signing-key.json). Subject to the [binding rule](#binding-rule). | +| `expires_at` | string (date-time) | Yes | RFC 3339 timestamp; the router's suggested freshness ceiling. Verifiers MUST reject envelopes where the current time is past this value, and MAY enforce a tighter window via `attestation_requirement.min_freshness_sec` on their provider registration. | +| `ext` | object | No | Vendor-namespaced extension surface. MUST NOT be used to shadow the binding rule or to carry an alternative attestation document. | + +The schema declares `additionalProperties: false` at the top level; the `ext` field is the only vendor-extension surface. Receivers MUST reject envelopes with unknown top-level fields. + +## Nonce requirements + +A verifier MUST: + +1. Generate at least 16 bytes (and at most 32 bytes) of cryptographically random data per verification. The verifier MUST NOT reuse a nonce across verifications. +2. Encode the bytes as base64url (RFC 4648 §5, unpadded; characters `[A-Za-z0-9_-]`, no `=` padding). +3. Place the encoded value in the `nonce` query parameter on the `GET /.well-known/tmp-router-attestation` request. +4. After receiving the envelope, byte-compare `envelope.nonce` against the value the verifier sent. A mismatch is an immediate reject (Verification step 1). +5. Confirm that the platform-specific user-data slot of `attestation_document` contains the same nonce, per the verifier kit's slot-projection rule for `attestation_format` (Verification step 4). + +The nonce is the verifier-controlled freshness signal. Without it, a captured envelope is replayable for the lifetime of `expires_at` against any other verifier. The slot-projection check in step 5 is what makes the nonce *cryptographically* — rather than merely textually — bound to the fetched envelope. + +## Binding rule + +> The JWK in `signing_key` MUST appear bound in the platform user-data slot of `attestation_document` alongside the nonce. Verifiers MUST reject when the bound public key does not byte-match the envelope's `signing_key` after canonical JWK serialization (RFC 7638 JSON Web Key Thumbprint comparison is the acceptable comparison form). + +This rule is the protocol-level invariant that anchors the existing per-provider `X-AdCP-Signature`/`X-AdCP-Key-Id` signature path to the attested binary. Without it, a router could attest a clean binary and then sign requests with a different (deployment-side) key — every per-provider signature would verify, but no protocol mechanism would detect the swap. With it, every per-provider signature inherits the attestation: the signing key is provably the key the attested enclave committed to. + +The bound key is *the same key* the provider already verifies request signatures against ([Identity Match signed fields](/docs/trusted-match/specification#identity-match-signed-fields)). The trust anchor in [`agent-signing-key.json`](https://adcontextprotocol.org/schemas/v3/core/agent-signing-key.json) is the discovery surface for the publishable key; the envelope carries the JWK directly (not the thumbprint) so the verifier can re-derive the canonical form and compare. + +The slot-projection convention (how the JWK and nonce are laid out in the platform user-data slot) is defined in each verifier kit, not in this schema, because the slot name and concatenation rules differ per platform (Nitro `user_data`, TDX/SEV-SNP `REPORTDATA`, GCP claim format). + +## Per-request attestation: `X-TMP-Attestation` + +When a provider's registration carries `attestation_requirement.required: true`, the router MUST attach an `X-TMP-Attestation` header to every outbound `POST /context` and `POST /identity` request to that provider. + +### Header format + +``` +X-TMP-Attestation: +``` + +The header value is the base64url-encoded (RFC 4648 §5, unpadded) UTF-8 JSON serialization of the same envelope returned by `/.well-known/tmp-router-attestation`. Routers SHOULD fetch and cache a verified envelope keyed by signing-key `kid`, and MUST refresh on rotation or when `expires_at` is past. + +### When attached + +- When the recipient provider's registration carries `attestation_requirement.required: true` for the calling deployment — MUST attach on every request. +- When the recipient provider's registration carries `attestation_requirement.required: false` or omits the block — MUST NOT attach (the header adds up to ~10 KB per request depending on `attestation_format` — Nitro is ~5 KB, TDX/SEV-SNP and GCP tokens are ~1-2 KB — and providers that haven't opted in shouldn't pay it). + +### Provider-side handling + +A provider that requires attestation: + +1. Reads `X-TMP-Attestation`, base64url-decodes, JSON-parses, schema-validates against [`/schemas/trusted-match/router-attestation.json`](https://adcontextprotocol.org/schemas/v3/trusted-match/router-attestation.json). +2. Runs the [Verification flow](#verification-flow) below, with the caveat that the per-request nonce check (step 1) is relaxed in favor of the envelope's `expires_at` + the provider's `min_freshness_sec` — per-request nonce is impractical because the provider did not initiate the fetch. Per-request integrity is supplied by the per-provider `X-AdCP-Signature` on the request payload (signed by the same `signing_key` from the envelope), and the binding rule ensures that signing key is the attested one. +3. Caches the verified envelope by `signing_key` thumbprint for at most `min_freshness_sec`. Subsequent requests under the same `kid` reuse the cached verification; on `kid` change or expiry, re-verify. +4. On any verification failure ([failure modes](#failure-modes)), rejects the request with HTTP `403` and an `error` body whose `code` reflects the failure class. + +A provider that does not require attestation ignores the header (`attestation_requirement: { required: false }` or absent). + +## Verification flow + +A verifier (a provider with `attestation_requirement.required: true`, or any actor independently auditing a router) MUST perform the following steps, in order, on each verification: + +1. **Nonce echo (envelope-fetch path only).** If the verifier obtained the envelope via `GET /.well-known/tmp-router-attestation`, byte-compare `envelope.nonce` against the value the verifier sent. Mismatch → reject (`nonce_mismatch`). On the per-request `X-TMP-Attestation` path this step is skipped; freshness is enforced by step 3. +2. **Expiry.** Compare current time to `envelope.expires_at`. If current time is past, reject (`envelope_expired`). +3. **Min-freshness (provider policy).** If the verifier's `attestation_requirement.min_freshness_sec` is set, compare to the envelope's verifier-derived issuance age (the verifier kit projects an `issued_at` out of the platform document). If age exceeds the policy, reject (`envelope_stale`). +4. **Format support.** If `envelope.attestation_format` is not in the verifier's `attestation_requirement.acceptable_formats`, reject (`unsupported_format`). +5. **Platform document verification.** Hand `attestation_document` to the verifier kit for `attestation_format`. The kit MUST: verify the document's signature against the platform vendor's root certificate authority; extract measurements; project the user-data slot. +6. **Slot-bound nonce.** Compare the nonce in the projected user-data slot to `envelope.nonce`. Mismatch → reject (`slot_nonce_mismatch`). +7. **Binding rule.** Compare the JWK in the projected user-data slot to `envelope.signing_key` using RFC 7638 thumbprint equality. Mismatch → reject (`signing_key_not_bound`). This is the load-bearing check; without it the envelope provides no protocol-level guarantee that signed requests originate from the attested binary. +8. **Measurement policy.** Compare the extracted measurements to the verifier's local allowlist. AdCP does not constrain this allowlist (the allowlist is a deploy-side decision per the rationale doc). On allowlist mismatch, reject (`measurement_disallowed`). +9. **Cache.** On success, cache the verified envelope keyed by `envelope.signing_key`'s RFC 7638 thumbprint for at most `min_freshness_sec`. + +Subsequent per-provider request signatures from the bound `signing_key` are then verified per the existing flow in [Request Authentication](/docs/trusted-match/specification#request-authentication). The binding rule is what makes those signatures inherit the attestation. + +## Failure modes + +| Failure | Cause | Verifier behavior | +|---|---|---| +| `nonce_mismatch` | `envelope.nonce` does not byte-equal the value the verifier sent on `/.well-known/tmp-router-attestation`. | Reject. Do not retry against the same envelope. Acceptable to re-fetch with a fresh nonce. | +| `envelope_expired` | Current time is past `envelope.expires_at`. | Reject. Re-fetch from the router. | +| `envelope_stale` | Envelope age exceeds the verifier's `min_freshness_sec`. | Reject. Re-fetch from the router. | +| `unsupported_format` | `envelope.attestation_format` is not in the verifier's `acceptable_formats`. | Reject. The verifier MUST NOT fall back to a different format implicitly; the operator updates the policy or the router emits an acceptable format. | +| `slot_nonce_mismatch` | The nonce in the platform user-data slot does not match `envelope.nonce`. | Reject. This is a strong signal of envelope tampering or vendor-document replay. | +| `signing_key_not_bound` | The JWK in the platform user-data slot does not byte-equal `envelope.signing_key` after canonical JWK serialization. | Reject. This is the load-bearing check — failing it means the envelope cannot anchor the per-provider request signatures, regardless of whether the signatures themselves verify. | +| `measurement_disallowed` | The extracted measurements are not in the verifier's local allowlist. | Reject. The allowlist is a deploy-side decision; the verifier surfaces the failure to its operations channel for build-allowlist review. | +| `platform_verification_failed` | The verifier kit failed to verify the platform document against the vendor root (signature invalid, vendor cert expired, format malformed). | Reject. Surface to operations. | +| `network_error` | `/.well-known/tmp-router-attestation` was unreachable, returned a non-`200`, or returned a malformed body. | Verifiers MAY retry with backoff. The verifier MUST NOT serve traffic to the router as if attestation succeeded; on sustained failure, fail closed (treat as a verification failure). | + +On any rejection, the provider returns HTTP `403` to the calling router with a TMP `error` body using the failure name as `code` (or `internal_error` if the failure is not in the table above), and SHOULD log the failure for operator review. + +## Caching + +Verifier cache discipline for the envelope mirrors the existing TMP signing-key cache discipline at [Key rotation](/docs/trusted-match/specification#key-rotation) — the convention TMP signing keys and TMPX HPKE keys already share. Verifiers SHOULD: + +- Cache the verified envelope keyed by the RFC 7638 thumbprint of `signing_key`, for at most the provider's `attestation_requirement.min_freshness_sec` (default 300 seconds, same as the existing signing-key TTL). +- On a `kid` change, invalidate the cached envelope and re-fetch eagerly — the same eager-re-fetch rule the spec already applies to signing keys at [`specification.mdx#key-rotation`](/docs/trusted-match/specification#key-rotation) ("When a signature fails verification, the router SHOULD re-fetch the key before rejecting — the agent may have rotated"). +- Treat `revoked_at` on the trust-anchor signing key as a revoked envelope: the envelope's `signing_key.kid` is the same `kid` the verifier already looks up against `agent-signing-key.json`, so the existing revocation propagation rules apply unchanged. + +The 300-second default is operationally aligned with the rest of the protocol; providers with a genuine reason to cache longer (e.g., very-high-throughput deployments) MAY raise it to up to 86400 seconds via `min_freshness_sec` on their registration. Lower values tighten the post-revocation window at the cost of more frequent envelope production. + +## Interaction with the per-provider signature flow + +The router already signs per-provider requests over a canonical preimage ([Identity Match signed fields](/docs/trusted-match/specification#identity-match-signed-fields)). The attestation envelope does not replace that signature path; it anchors it. + +- The provider's per-request verification path (verify Ed25519 signature against the publisher's trust anchor in [`agent-signing-key.json`](https://adcontextprotocol.org/schemas/v3/core/agent-signing-key.json)) is unchanged. +- The router's per-provider re-signing on the [Identity Match fan-out](/docs/trusted-match/router-architecture#identity-match-fan-out) is unchanged. +- The binding rule (Verification step 7) is added at envelope-verification time: the public key already in the trust anchor MUST byte-equal the public key bound in the platform user-data slot. Once the binding is verified, every subsequent per-request signature from that key inherits the attestation, because the signing key is provably held by the attested binary. + +A provider that supports attestation but the binding check fails MUST reject every subsequent request signed by that `kid` until a fresh, successfully-verified envelope rotates the binding. A captured signing key held outside the enclave cannot satisfy the binding because the enclave that produces envelopes is bound to a different key. + +## Conformance + +A TMP Router claiming `trusted_match.router_attestation` support MUST: + +- Expose `GET /.well-known/tmp-router-attestation` on every host that accepts router traffic. +- Validate `nonce` length and base64url alphabet on every request to that endpoint; reject malformed nonces with HTTP `400`. +- Emit envelopes whose `signing_key` is bound in `attestation_document`'s user-data slot alongside `nonce` per the format-specific verifier-kit convention. +- Attach `X-TMP-Attestation` to every outbound request to a provider whose registration carries `attestation_requirement.required: true`. +- Refresh the cached envelope on signing-key rotation or when `expires_at` is past. + +A provider claiming `trusted_match.router_attestation` support MUST: + +- Declare `attestation_requirement` on its provider registration when it requires attestation. +- Run the [Verification flow](#verification-flow) on the inbound `X-TMP-Attestation` header for every request when `required: true`. +- Reject with HTTP `403` and a TMP `error` body on any verification failure, using the failure-mode names from the table above. +- Cache verified envelopes by RFC 7638 thumbprint of the bound `signing_key`, bounded by `min_freshness_sec`. + +A verifier (whether a provider or an independent auditor) claiming support MUST run the verification flow in order — the binding rule is not skippable. + +## Examples + +The following examples use fictional entities from the AdCP character bible (Acme Outdoor, Pinnacle Agency, StreamHaus). + +### Envelope (Nitro) + +```jsonc +{ + "attestation_format": "aws_nitro_cose_sign1_v1", + "attestation_document": "g6JpbW9kdWxlX2lkeCdpLTBmZWFkY2FmZWJlZWY... (truncated)", + "nonce": "S3JqWHBuQTkydlF6dGZYbg", + "signing_key": { + "kid": "streamhaus-router-2026-06", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + }, + "expires_at": "2026-06-30T18:00:00Z" +} +``` + +### Provider registration declaring attestation required + +```yaml +- provider_id: pinnacle-agency-us + endpoint: https://us.tmp.pinnacle-agency.example/v1 + context_match: true + identity_match: true + countries: [US] + uid_types: [uid2, rampid, id5] + timeout_ms: 40 + priority: 0 + attestation_requirement: + required: true + acceptable_formats: [aws_nitro_cose_sign1_v1, gcp_confidential_space_v1] + min_freshness_sec: 300 +``` + +A router serving requests on behalf of StreamHaus calling Pinnacle Agency MUST attach `X-TMP-Attestation` to every `POST /context` and `POST /identity` request; Pinnacle Agency runs the verification flow and rejects on any failure. + +### Provider registration that does not require attestation (default) + +```yaml +- provider_id: acme-outdoor-eu + endpoint: https://eu.tmp.acmeoutdoor.example/v1 + context_match: true + identity_match: true + countries: [DE, FR, IT, ES, NL] + uid_types: [euid, id5] + # no attestation_requirement → header is not attached, requests proceed unattested +``` diff --git a/specs/tmp-router-attestation.md b/specs/tmp-router-attestation.md new file mode 100644 index 0000000000..7126690b7e --- /dev/null +++ b/specs/tmp-router-attestation.md @@ -0,0 +1,256 @@ +# TMP Router Attestation — Design Proposal + +**Status**: proposed as experimental (`trusted_match.router_attestation`); this document is the design rationale, the normative spec lives in `docs/trusted-match/router-attestation.mdx`. +**Target surface**: TMP Router infrastructure (`/.well-known/tmp-router-attestation`, `X-TMP-Attestation` request header, `attestation_requirement` on provider registration), experimental. +**Relates to**: [`docs/trusted-match/router-architecture.mdx`](../docs/trusted-match/router-architecture.mdx), [`docs/trusted-match/privacy-architecture.mdx`](../docs/trusted-match/privacy-architecture.mdx), [`docs/trusted-match/specification.mdx`](../docs/trusted-match/specification.mdx). + +This proposal promotes the existing one-paragraph "TEE upgrade path" mention into a normative, experimentally-gated feature with: a `/.well-known/` endpoint, a wire envelope schema, a load-bearing key-to-attestation binding rule, and a per-request `X-TMP-Attestation` carrier so providers can require attestation without going out-of-band. It does **not** ship a verifier kit, a reproducible-build pipeline, KMS provisioning, an `adcp-go` implementation, or conformance scenarios; those land in follow-up bundles. The point of this RFC is to fix the *wire shape* — what an attestation looks like on the wire, what a verifier is allowed to assume, and how a provider declares "I require this" — so the artifacts that follow have a stable contract to target. + +## Problem + +The router today is the trusted single point in the TMP topology that performs structural separation of context from identity, filters per-provider identity tokens, and re-signs each fan-out forward (`router-architecture.mdx:156`). The privacy guarantee is operationally enforced — the operator deploys the published binary; reviewers audit the source; nothing inspects what is actually running. The current language acknowledges this as a known gap: "Without TEE, you trust that the operator deployed the published binary" (`router-architecture.mdx:51`). The `privacy-architecture.mdx` section "TEE Attestation Details" (line 127-154) sketches what attestation would do — image hash, kernel hash, application hash — but defines no wire shape and no protocol-level invariant. + +This matters because the router holds an Ed25519 signing key. The publisher's `agent-signing-key.json` says "this is the public key whose signatures providers MUST accept." But there is no protocol-level evidence that **the private half of that key is held by the attested binary**, rather than by the operator's deployment scripts. A malicious or compelled operator who controls deployment can: + +1. Sign the published audited source into a build. +2. Deploy a *modified* binary that produces the same publishable signing-key registration but bridges the context and identity code paths internally. +3. Pass every audit: the signed registration looks correct, every per-provider signature verifies, the only thing that changes is what the binary does between receiving a request and emitting one. + +Code audit catches Class 1 (the published source is wrong). TEE attestation closes Class 2 (the deployed binary is not the published source). Both are needed; the protocol currently bakes in the first and waves at the second. + +This RFC supplies the wire shape for the second. The decision space is small once it is framed correctly: the verifier needs (a) to know what binary is running, (b) to know what key that binary signs with, and (c) to confirm that the key the protocol already trusts for per-provider signatures **is** the key the binary signs with. Without (c), a publisher could attest a clean binary and then re-sign with a deployment-side key — attestation theater, useful for marketing, useless for the trust gap. + +## Framing: attestation as anchor for an existing signature, not a new trust root + +The router already produces per-provider Ed25519 signatures over a canonical preimage (`specification.mdx:548`). Those signatures bind the request to a specific provider, seller, and epoch. What they do *not* bind to is the binary that produced them — the signature proves "this preimage was signed by this key," not "this key lives in an attested enclave." + +Attestation, framed as a separate trust system, would create a second discovery problem (where does the verifier find the attestation? how does it relate to the signature it already verifies?), a second key-rotation problem, and a second policy surface. Framed as an **anchor** for the existing signature, the contract collapses to one rule: + +> The public key in the attestation's user-data slot MUST byte-equal the public key the provider already uses to verify per-provider request signatures. + +That one rule — the **binding rule** — is what makes the rest fall into place. The endpoint returns an envelope (`{ document, signing_key, nonce, expires_at }`). The verifier asks the platform-specific kit: "does this document attest a binary that committed to *this* `signing_key` and *this* nonce?" If yes, every subsequent `X-AdCP-Signature` from that key inherits the attestation. If no, the envelope is rejected and the provider falls back to whatever it accepts (which, per its `attestation_requirement` policy, may be "nothing"). + +This framing keeps the protocol's existing key/signature/epoch machinery as the data-plane mechanism and makes the attestation a low-frequency control-plane attestation of *the key itself* rather than of every request. Per-impression attestation is bandwidth-prohibitive and was considered and rejected (see [Alternatives](#alternatives-considered)). + +## Design + +### 1. Endpoint + +``` +GET /.well-known/tmp-router-attestation?nonce= +``` + +Public, no auth, no body. Returns a single JSON object matching `/schemas/trusted-match/router-attestation.json`. + +**Why `/.well-known/`.** The router already exposes `/healthz`, `/metrics`, and (per the new RFC) needs a stable discovery URL the verifier can probe before sending any traffic. RFC 8615 `/.well-known/` is the right pattern: a single per-host registered path the protocol owns. Splitting attestation into a separate hostname or path layer would force every consumer to learn a second discovery channel; `/.well-known/` reuses the host the verifier is already going to talk to. + +**Why GET.** Idempotent, cacheable (with the caveat that caching defeats nonce freshness — the verifier opts out per-fetch by virtue of supplying a fresh nonce). No body to forge or schema-validate on the request side. + +**Why query-string nonce.** A verifier MUST supply a fresh value per fetch; in-URL is the simplest place to put it without inventing a POST body just for one query parameter. Length is bounded at 16-32 raw bytes (22-43 base64url-no-pad characters) — 16 bytes is a generic cryptographic-nonce floor (128 bits of entropy, well above birthday-collision risk for any realistic fetch volume); 32 bytes is enough to fold in additional verifier-side context if a kit wants to. Larger is unnecessary; smaller is rejected. + +### 2. Wire envelope + +```jsonc +{ + "attestation_format": "aws_nitro_cose_sign1_v1", + "attestation_document": "", + "nonce": "", + "signing_key": { "kty": "OKP", "crv": "Ed25519", "x": "..." , "kid": "router-2026-06" }, + "expires_at": "2026-06-30T18:00:00Z" +} +``` + +**`attestation_format` is an enum, not a free-form URN.** v1 initial set: `aws_nitro_cose_sign1_v1`, `intel_tdx_quote_v4`, `amd_sev_snp_attestation_v1`, `gcp_confidential_space_v1`. Each names a single externally-defined attestation format and version — the same shape as `enums/feed-format.json`'s `google_merchant_center` / `linkedin_jobs` / `openai_product_feed`. The verifier kit for each format is responsible for parsing the platform document, mapping the envelope's generic `nonce` onto the format-specific user-data slot, and applying any measurement allowlist the deployment cares about. A vendor-neutral URN scheme was considered and rejected (see [Alternatives](#alternatives-considered)). + +**`attestation_document` is opaque to AdCP.** This schema neither parses nor validates platform documents. AdCP does not become a downstream of every TEE format's spec churn; verifier kits are versioned per-platform and ship independently. The flip side: receivers without a verifier kit for the declared format MUST reject the envelope as `unsupported_format` rather than falling through. + +**`signing_key` is the JWK shape from `/schemas/core/agent-signing-key.json`.** No new JWK schema is invented. The reuse matters because this is *the same key* the provider already verifies request signatures against (`agent-signing-key.json` in the trust anchor). The schema reference makes that explicit at the type level. + +**`expires_at` is the router's suggested ceiling.** A verifier MAY enforce a tighter window via `attestation_requirement.min_freshness_sec`. The router's ceiling exists so a consumer that has *no* freshness preference still gets a reasonable cap — without it, a captured envelope is good forever. + +**`ext` is the standard escape hatch.** Vendor-namespaced; not for shadowing the binding rule. + +### 3. Nonce policy + +The verifier MUST: + +1. Generate at least 16 bytes of cryptographically random data per verification, never reuse. +2. Encode the bytes as base64url (RFC 4648 §5, unpadded) and place in the `nonce` query parameter. +3. After receiving the envelope, byte-compare `envelope.nonce` against the value it sent. A mismatch is an immediate reject. +4. Confirm that the platform-specific user-data slot of `attestation_document` (e.g., `user_data` for Nitro, `REPORTDATA` for TDX/SEV-SNP) contains the same nonce. This slot-projection rule lives in the verifier kit, not in this schema, because each platform has a different slot name and a different concatenation convention with other user-data components. + +The nonce is the freshness signal. The platform document is signed by the platform vendor's root, so a captured-and-replayed document is otherwise indistinguishable from a fresh one. The nonce in the user-data slot is what makes that capture observable. + +### 4. Binding rule (load-bearing) + +> The JWK in `signing_key` MUST appear bound in the platform user-data slot of `attestation_document` alongside the nonce. Verifiers MUST reject if the bound public key doesn't byte-match the envelope's `signing_key` (after canonical JWK serialization — RFC 7638 thumbprint comparison is acceptable). + +This is the rule that makes attestation cryptographically meaningful for the existing `X-AdCP-Signature`/`X-AdCP-Key-Id` per-provider signature path. Without this rule, a router could attest a binary and then sign requests with a different key — the verifier would have no protocol-level way to detect the swap. With this rule: + +- The provider already verifies per-provider request signatures against the publisher's trust anchor (`agent-signing-key.json`, fetched per `specification.mdx:601`). +- The provider periodically (or on first contact, or per its `min_freshness_sec` policy) verifies the router's attestation envelope and checks the binding. +- Because the bound key is *the same key* that signs the per-provider requests, every verified per-provider signature inherits the attestation property: "this preimage was signed by a key the attested enclave committed to." + +The "no swap" property is the entire reason this RFC exists. It is the bare-minimum cryptographic statement attestation can offer for an existing-signature world, and it is the one statement the wire shape MUST make unambiguous. + +How the JWK is rendered into the user-data slot is verifier-kit territory (RFC 7638 thumbprint is the recommended carrier; the platform kit can elect a different canonical form provided it is consistent). The wire envelope carries the JWK directly, not the thumbprint, so the verifier can re-derive the thumbprint and compare. Carrying only the thumbprint would force every consumer to also fetch the JWK from the trust anchor before checking — doubling discovery cost for no security benefit. + +### 5. Provider-side opt-in: `attestation_requirement` + +Added to `provider-registration.json` (additive, optional): + +```jsonc +"attestation_requirement": { + "required": true, + "acceptable_formats": ["aws_nitro_cose_sign1_v1", "gcp_confidential_space_v1"], + "min_freshness_sec": 3600 +} +``` + +When `required: true`, the router MUST attach an `X-TMP-Attestation: ` header to **every** outbound `/context` and `/identity` request to that provider. The provider verifies it on each request (or, sensibly, caches a verified envelope by signing-key thumbprint for the freshness window and only re-verifies on rotation/expiry — the wire spec does not constrain the implementation). + +**Why every request, not "once at handshake".** TMP is over HTTP/2 with connection reuse and stateless providers. There is no "session" the provider can pin attestation state onto without inventing one. The per-request header lets the verifier be entirely stateless: it sees an attestation, it caches by `signing_key.kid + thumbprint`, it serves from cache until the freshness budget expires. Per-request is the simplest fit for the existing topology. + +**Cost.** Attestation documents are not small. AWS Nitro attestation documents are commonly 4-6 KB; TDX/SEV-SNP quotes are smaller (~1-2 KB); GCP Confidential Space tokens are similar. The envelope adds ~5-10 KB per request to providers with `required: true`. This is operationally significant — TMP messages are otherwise 200-600 bytes (`router-architecture.mdx:112`) — and operators should account for the bandwidth and the parsing cost. Providers that don't need attestation pay nothing (no `attestation_requirement` block, or `required: false`). The per-provider opt-in is the lever that keeps the cost contained. + +**Why a header, not a body field.** The body schema is `additionalProperties: false`. Widening it for an out-of-band envelope would conflate request payload with deployment-level proof. Headers are the right HTTP layer for transport-level deployment metadata. + +### 6. Caching + +Envelope cache discipline mirrors the existing TMP signing-key cache discipline at [`specification.mdx#key-rotation`](../docs/trusted-match/specification.mdx) — the same convention already shared by TMP signing keys and TMPX HPKE encryption keys ("5-minute cache TTL, kid prefix for versioning"). The attestation envelope is, in operational terms, *the same artifact* as the signing key it binds; aligning its cache rules avoids inventing a parallel discipline. + +**Concretely:** + +- Verifiers cache verified envelopes keyed by the RFC 7638 thumbprint of `signing_key`. +- The cache TTL is `attestation_requirement.min_freshness_sec` (default **300 seconds** — same as the existing signing-key TTL). +- On a `kid` change, the verifier MUST treat the cached envelope as stale and re-fetch eagerly, the same way the existing rule at `specification.mdx:581` already says: "When a signature fails verification, the router SHOULD re-fetch the key before rejecting — the agent may have rotated." The eager re-fetch is what handles rotation inside the freshness window without inventing a separate dual-cache rule. +- Revocation flows through the existing `revoked_at` mechanism on the trust anchor: a revoked signing key is also a revoked envelope, because the envelope's `signing_key` field carries the same `kid` the verifier already looks up against `agent-signing-key.json`. + +**Why default 300, not longer.** Attestation document production is more expensive than signing-key fetch (vendor-side cryptographic operations in the enclave, tens of milliseconds), which is an argument for caching longer. But operational alignment with the rest of the protocol's cache discipline is a stronger argument than per-fetch cost — real deployments batch envelope production behind a refresh loop, so the production cost amortizes regardless of TTL choice. Providers that genuinely need a different value can raise `min_freshness_sec` to up to 86400 seconds on their registration; the schema permits it. The default just matches the rest of the protocol. + +### 7. No measurement allowlist in the spec + +The spec defines what an attestation envelope **is**. It does not define what a verifier should treat as an *acceptable* measurement (e.g., which PCR values constitute "the audited router binary"). That decision is publisher-side / verifier-side deployment policy: + +- Different operators publish different builds. +- The "audited binary" reference shifts with each release. +- A reproducible-build artifact registry is a separate, larger project (explicitly out of scope here). + +The verifier kit corresponding to `attestation_format` extracts the measurements; what the verifier *does* with them — a local allowlist, a remote registry lookup, a co-signed manifest — is local to that verifier. The spec stops at "here is the envelope; here is the nonce + key binding; the rest is yours." + +### 8. Feature gate: `trusted_match.router_attestation` + +Declared in `experimental_features` alongside (separate from) `trusted_match.core` and `trusted_match.verified_identity`. Buyers / sellers / providers / routers can support core TMP without committing to attestation. Adding attestation later is purely additive — no existing TMP behavior changes for participants that don't opt in. + +The gating language matches verified-identity's: feature gate, opt-in, no silent-experimental. Sellers (and, here, routers and providers) that implement this surface MUST list it in `experimental_features`. A provider whose registration carries `attestation_requirement: { required: true }` is implicitly making this declaration; the experimental gate is the explicit version. + +## Security analysis + +### What attestation proves (with this design) + +- The binary running inside the enclave matches the published, audited source code (assuming the verifier's measurement allowlist resolves to that binary). +- The structurally-separate context and identity code paths in that binary have not been modified by the operator, the hosting provider, or any runtime process. +- **The router's per-provider request signatures are produced by a key the attested binary committed to** — the load-bearing binding rule. A captured signing key cannot be re-used from outside the enclave without breaking the binding check. + +### What attestation does not prove + +- That buyer agents handle the data they receive responsibly. TMP limits what providers receive; the spec does not control what they do with it. +- That the publisher's join logic — combining context responses with identity responses on the publisher's own infrastructure — is correct. The publisher is the first party and is not constrained by the router's separation model. +- That the published source is free of bugs. Attestation proves the binary matches the published build artifacts. Whether *those* are correct is an open-source audit question, addressed by the existing audit posture in `privacy-architecture.mdx:139-141`. +- That the publisher's measurement allowlist is the right one. A verifier that allowlists a malicious build measurement defeats the entire system; this is a deploy-time, not a wire-time, concern. + +### Replay / freshness model + +The nonce + `expires_at` + `min_freshness_sec` triad provides: + +- **Per-fetch freshness for the envelope itself.** A captured envelope cannot be reused: the verifier-generated nonce is in the user-data slot, and a replay arrives with a stale nonce or wrong user-data slot. +- **A bounded staleness window for per-request reuse.** Once a verifier has verified an envelope, it caches by `signing_key.kid` (+ thumbprint) for at most `min_freshness_sec`. After expiry the verifier re-fetches. +- **No per-impression liveness.** Verifying attestation per request would amortize across roughly the same request stream the existing signatures are amortized over (one Ed25519 verify per provider per epoch via sample-verification, `specification.mdx:575`). Verifying *every* attestation envelope per request would be expensive (~milliseconds for COSE_Sign1 verification + measurement parsing) and bandwidth-prohibitive. The freshness-window cache makes attestation cost-proportional to envelope rotation, not request volume. + +### Compromise model + +- **Compromised signing key (no enclave attestation):** the operator can sign anything. `X-AdCP-Signature` signatures verify; nothing flags the swap. This is today's posture. +- **Compromised signing key (with enclave attestation, binding rule enforced):** the binding rule is what catches this. The compromised key, if held outside the enclave, signs requests that verify cryptographically but cannot produce an attestation envelope whose user-data slot binds to it (because the enclave that does produce envelopes is bound to a *different* key — the in-enclave key). The provider rejects on `signing_key_not_bound`. +- **Compromised platform vendor root:** outside this RFC's threat model. If the AWS Nitro / Intel TDX / AMD SEV-SNP / GCP Confidential Space root CA is compromised, every attestation in that format is forgeable. This is the floor under all TEE attestation systems and is not improvable at the protocol layer. + +### Interaction with key rotation and revocation + +Router signing-key rotation already follows `specification.mdx:579-599`: 5-minute cache TTL on `agent-signing-key.json`, new `kid` per rotation, ~48-hour replay window via daily epoch. Attestation must follow the same rotation: + +- When the router rotates its signing key, the next envelope binds the new key, and the publisher's trust anchor publishes the new key. Both propagations happen on the same 5-minute TTL. +- Until both have propagated, providers using a cached old-key envelope continue to verify against the old key; once the new key reaches them, the next envelope fetch returns the new key bound in the new attestation document. No re-signing of historical requests is required; the binding rule is per-key, not per-request. + +Revocation (`revoked_at` on a key in the trust anchor) interacts the same way: a revoked signing key is *also* a revoked attestation envelope, because the envelope's `signing_key` field carries the same `kid`. Verifiers that see `revoked_at` on the bound key MUST reject the envelope and any signature produced after the revocation. + +### Open security topics deferred to the verifier kit + +- The exact byte layout of the user-data slot (Nitro `user_data` vs TDX/SEV-SNP `REPORTDATA` vs GCP claim format) and the concatenation convention with other slot fields. +- The on-the-wire form of the JWK-to-slot binding (recommended: RFC 7638 thumbprint, but each verifier kit decides). +- Measurement allowlists / reference values per build. +- Revocation lists for compromised enclave images. + +## Alternatives considered + +### A. Per-request attestation (no signing-key binding) + +Attach the full attestation envelope to every request body. Verifier verifies per-request. + +Rejected: bandwidth (~5-10 KB per request × every fan-out × every impression) is prohibitive for a 200-600 byte protocol. Verifier CPU cost (COSE_Sign1 verify + measurement parse per request) blows the 50ms latency budget. And the binding-rule approach gets the same security property — every signature inherits the attestation via key binding — at the cost of one attestation per freshness window. + +### B. No nonce, only `expires_at` + +Drop the verifier-supplied nonce. Rely solely on the envelope's `expires_at` for freshness. + +Rejected: `expires_at` is *the router's* claim about freshness, not a verifier-controlled one. A router producing a fresh envelope on a schedule (every hour) can have its envelope captured and replayed for the rest of that hour against any verifier. The verifier-supplied nonce is the only mechanism that ties the envelope to *this* verifier's *this* fetch, and the only mechanism that distinguishes "freshly produced by an enclave" from "captured from another verifier's recent fetch." + +### C. Measurement allowlist in the spec + +Define a normative list of acceptable image measurements per `attestation_format`. + +Rejected: AdCP would become a downstream of every operator's build pipeline. Reference values shift per release. Different operators run different builds (some operators may even fork the published router). The measurement allowlist is fundamentally a deploy-time, audit-time, publisher-side decision; baking it into the spec creates a perpetual maintenance burden for the working group and a perpetual fight between operators who want their build allowlisted and the WG that owns the list. The verifier kit + publisher-side allowlist is the right factoring. + +### D. Vendor-neutral URN scheme for `attestation_format` + +Use a URN scheme like `urn:attest:aws:nitro:v1` or `urn:tcg:dice:tdx:v4` instead of a flat enum. + +Rejected: enum is the same shape AdCP uses for every other externally-defined-format field (`feed-format.json`, `distribution-identifier-type.json`, `identifier-types.json`). The enum is per-format-version, which already encodes the registry pivot a URN would have provided. URN parsing is extra surface area for no concrete benefit. If a registry-style discoverable identifier becomes important later (a registry of approved attestation formats), the enum can grow into it (each enum value gets a registry entry); this is forward-compatible. + +### E. Attestation under `ext.tee` instead of a normative envelope + +Treat attestation as a vendor extension under `ext.tee.{nitro,tdx,...}`. + +Rejected: the binding rule is normative (it's load-bearing — without it the whole exercise is theater). Normative invariants do not belong under `ext.*` by spec convention (`spec-guidelines.md:288-322`). And there are multiple verifier-kit implementations across multiple buyers; vendor-namespacing makes the cross-implementation contract incoherent. + +## Open questions + +1. **Revocation list distribution.** Compromised enclave images need to be revoked across the verifier ecosystem. Per-vendor revocation channels (AWS, Intel PCS, AMD KDS, GCP) exist but vary in latency, freshness, and signature format. Whether AdCP should publish a thin aggregator (a `/.well-known/tmp-attestation-revocation`) or punt entirely to verifier-kit responsibility is an open WG decision. Recommendation: punt to verifier kits in v1; revisit if the cross-kit experience proves to be a stumbling block. + +2. **Multi-region routers with rotating keys.** A router deployment with N regional instances (e.g., one per PoP, per `router-architecture.mdx:280`) running N distinct signing keys produces N envelopes, all valid for the same publisher. The trust anchor model already accommodates this (multiple keys in `agent-signing-key.json`). The open question is whether a single envelope can attest multiple bound keys (a JWK array under `signing_key`) for one-trip discovery, or whether one-envelope-per-key is the right factoring. Recommendation: one-envelope-per-key in v1; the verifier already fetches per-key on rotation. + +3. **Deprecation flow for a format.** When `aws_nitro_cose_sign1_v1` is superseded by `aws_nitro_cose_sign1_v2` (real, hypothetical or otherwise), the enum gains an entry. Providers that allowlist only `v1` reject `v2`; routers that emit only `v2` are unreachable by those providers. The migration window (during which both must be accepted) is operationally complex. v1 spec leaves this to deployment policy and the experimental contract's break-policy; a future RFC may codify a dual-emit envelope shape if the migration pain is real. + +4. **Issued-time semantics for `min_freshness_sec`.** The schema says `min_freshness_sec` is the maximum age, but neither `attestation_document` (opaque to AdCP) nor the envelope carries an issuance timestamp in a uniform way. In practice, each platform document exposes issuance via a format-specific field (Nitro `timestamp`, TDX `quote_timestamp`-equivalent, etc.). The wire spec should either carry an explicit `issued_at` at the envelope layer (duplicating across formats) or accept that "age" is verifier-kit-derived. Recommendation: verifier-kit-derived; document the rule per kit, surface the result in a `verified_age_sec` field of the parsed result for higher-layer consumers. + +## Non-goals + +- **Not a reproducible-build pipeline.** This RFC defines the wire shape of an attestation envelope. It does not define the build system, the source attestation, the SBOM format, or the registry of acceptable measurements. Those are separate (larger) projects. +- **Not a KMS specification.** How the router's signing key is generated, stored, rotated, attested-at-rest, and protected against extraction inside the enclave is out of scope. The protocol cares about the *public* key and the binding; the private-key lifecycle is operator responsibility. +- **Not an `adcp-go` implementation.** Schema and spec only. +- **Not a conformance test bundle.** Verifying that a given verifier kit correctly parses Nitro / TDX / SEV-SNP / GCP documents is conformance territory; this RFC does not define the test vectors. +- **Not a substitute for code audit.** Attestation proves the deployed binary matches a known artifact. Whether *that* artifact is correct is an audit question (`privacy-architecture.mdx:139-141`). +- **Not a privacy enhancement on its own.** The privacy model is already structural (`privacy-architecture.mdx:9`). Attestation is an *integrity* enhancement — it removes the operator from the trust chain for the structural-separation invariant. The privacy properties hold or fail by code; attestation tells you which. +- **Not measurement-allowlist policy.** See Design §6. +- **Not per-impression attestation.** See Alternatives §A. + +## Summary + +| | No attestation (today) | With router attestation (this RFC) | +|---|---|---| +| Trust root for structural separation | Operator deployed published binary | Enclave attestation, verifiable by the platform vendor root | +| Trust root for per-provider signatures | Key in `agent-signing-key.json` belongs to the deployment | Key in `agent-signing-key.json` is *bound* to the attested binary | +| Per-impression cost | 0 | 0 (binding amortizes via freshness window) | +| Operator can swap signing key without detection | Yes | No (binding rule) | +| Verifier sees binary measurements | No | Yes (via verifier kit) | +| Required to participate in TMP | n/a | No (experimental, opt-in via `trusted_match.router_attestation`) | + +The durable protocol contribution is the **binding rule** (Design §4): a one-sentence invariant that turns the existing `X-AdCP-Signature`/`X-AdCP-Key-Id` signature path from "trust the deployed key" into "trust the attested binary that committed to the key." Everything else in the RFC — the endpoint, the envelope, the per-provider opt-in — is the minimum wire shape required to make that binding inspectable. diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index e5b85850e5..eceb2108cc 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -1468,7 +1468,7 @@ "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$", - "description": "Experimental feature id (dot-separated lowercase identifiers, e.g., 'brand.rights_lifecycle', 'governance.campaign', 'measurement.core', 'trusted_match.core')" + "description": "Experimental feature id (dot-separated lowercase identifiers, e.g., 'brand.rights_lifecycle', 'governance.campaign', 'measurement.core', 'trusted_match.core', 'trusted_match.router_attestation')" }, "uniqueItems": true }, diff --git a/static/schemas/source/trusted-match/provider-registration.json b/static/schemas/source/trusted-match/provider-registration.json index 9ab4350424..8d5a11f984 100644 --- a/static/schemas/source/trusted-match/provider-registration.json +++ b/static/schemas/source/trusted-match/provider-registration.json @@ -82,6 +82,41 @@ "description": "Provider lifecycle status. Active providers receive requests. Inactive providers are skipped entirely. Draining providers stop receiving new requests but in-flight requests complete normally.", "enum": ["active", "inactive", "draining"], "default": "active" + }, + "attestation_requirement": { + "type": "object", + "description": "Experimental (`trusted_match.router_attestation`). When the provider requires the calling router to be TEE-attested, this block declares the provider's policy. When `required` is true, the router MUST attach an `X-TMP-Attestation` header (base64url-encoded JSON of the envelope at /schemas/trusted-match/router-attestation.json) to every outbound POST /context and POST /identity request to this provider, and the provider MUST reject requests that fail the verification flow defined in docs/trusted-match/router-attestation.mdx. Attestation envelopes are not small (Nitro attestation documents alone are ~5KB); operators should account for the per-request size and the cost of re-fetching the envelope on the router side when planning capacity. When this block is absent or `required` is false, providers MUST accept unattested routers — the spec preserves the core TMP trust posture for participants that don't opt into attestation.", + "properties": { + "required": { + "type": "boolean", + "description": "When true, the router MUST attach an `X-TMP-Attestation` header to every outbound /context and /identity request to this provider, and the provider rejects requests whose attestation does not verify. When false, the provider accepts requests without attestation; the field MUST be present whenever this block is — callers declare attestation policy explicitly, since the presence of the block itself signals an intent to make a policy statement." + }, + "acceptable_formats": { + "type": "array", + "description": "Allowed values of the envelope's `attestation_format` for this provider. Enumerated against the same allowlist as `router-attestation.json#/properties/attestation_format`. Required when `required` is true — providers MUST NOT accept arbitrary formats, since the verifier kit is format-specific. Each value names a single externally-defined attestation format and version.", + "items": { + "type": "string", + "enum": [ + "aws_nitro_cose_sign1_v1", + "intel_tdx_quote_v4", + "amd_sev_snp_attestation_v1", + "gcp_confidential_space_v1" + ] + }, + "minItems": 1 + }, + "min_freshness_sec": { + "type": "integer", + "description": "Maximum age, in seconds, the provider accepts for an envelope. Age is verifier-kit-derived from the platform document's format-specific issuance timestamp (Nitro `timestamp`, TDX/SEV-SNP quote issuance, GCP token `iat`); the envelope itself does not carry a uniform `issued_at` field — see specs/tmp-router-attestation.md Open Question §4. Tighter than the router's suggested `expires_at` ceiling. Default: 300 (five minutes), matching the existing TMP signing-key cache TTL at docs/trusted-match/specification#key-rotation. Lower values increase round-trip pressure on /.well-known/tmp-router-attestation; higher values widen the post-revocation window during which a compromised binary's envelopes still verify against cached state.", + "minimum": 60, + "maximum": 86400, + "default": 300 + } + }, + "required": ["required"], + "if": { "properties": { "required": { "const": true } }, "required": ["required"] }, + "then": { "required": ["acceptable_formats"] }, + "additionalProperties": false } }, "required": [ diff --git a/static/schemas/source/trusted-match/router-attestation.json b/static/schemas/source/trusted-match/router-attestation.json new file mode 100644 index 0000000000..a3fe2ed894 --- /dev/null +++ b/static/schemas/source/trusted-match/router-attestation.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/trusted-match/router-attestation.json", + "title": "TMP Router Attestation Envelope", + "description": "Wire-level envelope returned by a TEE-attested TMP Router at GET /.well-known/tmp-router-attestation?nonce=. Carries a platform-specific attestation document, the public signing key the router uses for per-provider `X-AdCP-Signature` re-signing, and a verifier-supplied nonce echoed from the request. The envelope is opaque format-binding — AdCP does not parse the platform `attestation_document`; format-specific parsing and measurement-allowlist policy live in the verifier kit corresponding to `attestation_format`. The load-bearing invariant is that the JWK in `signing_key` MUST appear bound in the platform document's user-data slot alongside the nonce, so that the existing per-provider `X-AdCP-Signature` signatures defined in docs/trusted-match/specification#identity-match-signed-fields are cryptographically anchored to the attested binary rather than to an out-of-band-deployed key. See docs/trusted-match/router-attestation.mdx for the normative endpoint, nonce, binding, and verification flow.", + "x-status": "experimental", + "type": "object", + "properties": { + "attestation_format": { + "type": "string", + "description": "Canonical identifier of the platform attestation format carried in `attestation_document`. Each value names a single externally-defined attestation format and version (parallel to how `enums/feed-format.json` names externally-defined feed formats); the verifier kit for the named format is responsible for parsing the document and mapping the envelope's generic `nonce` onto the format-specific user-data slot. The v1 set is intentionally small — extension is via the `ext` namespace plus a future enum addition through the normal change process.", + "enum": [ + "aws_nitro_cose_sign1_v1", + "intel_tdx_quote_v4", + "amd_sev_snp_attestation_v1", + "gcp_confidential_space_v1" + ] + }, + "attestation_document": { + "type": "string", + "description": "Base64url-encoded (RFC 4648 §5, unpadded) attestation document in the format declared by `attestation_format`. Opaque to AdCP: this schema neither parses nor validates its contents. The verifier kit corresponding to `attestation_format` performs signature verification against the platform vendor's root, extracts measurements, and projects the format-specific user-data slot to compare against the envelope's `nonce` and `signing_key`." + }, + "nonce": { + "type": "string", + "description": "Base64url-encoded (RFC 4648 §5, unpadded) echo of the `nonce` query parameter on the originating GET. The verifier MUST have generated this from at least 16 bytes of cryptographically random data per verification (never reused) and MUST compare the response value byte-for-byte against the value it sent. The verifier MUST also confirm that the same nonce appears in the platform-specific user-data slot of `attestation_document` — the slot-projection rule is part of the verifier kit, not this schema. Length range matches the 16–32 raw bytes specified for the request parameter (22–43 base64url-no-pad chars).", + "pattern": "^[A-Za-z0-9_-]{22,43}$" + }, + "signing_key": { + "$ref": "/schemas/core/agent-signing-key.json", + "description": "JWK shape of the public key the router uses to sign outbound provider requests (`X-AdCP-Signature`/`X-AdCP-Key-Id` per-provider re-signing — see docs/trusted-match/specification#identity-match-signed-fields). This is the same key shape that appears in `agent-signing-key.json`. The MUST-BIND invariant: this key MUST also appear bound in the platform user-data slot of `attestation_document` alongside `nonce`. Verifiers MUST reject the envelope when the key bound in the attestation does not byte-match this field after canonical JWK serialization; RFC 7638 thumbprint comparison is the acceptable comparison form. Without this binding, the router's existing per-provider signatures only attest to a deployed key, not to an attested binary." + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 timestamp the router suggests as the freshness ceiling for this envelope. Advisory only — verifiers MAY enforce a shorter window (`min_freshness_sec` on the consumer's `attestation_requirement`), but MUST reject when current time is past `expires_at`. Re-fetching `/.well-known/tmp-router-attestation` with a fresh nonce produces a new envelope." + }, + "ext": { + "$ref": "/schemas/core/ext.json", + "description": "Vendor-namespaced extension surface for fields not yet promoted to the protocol (e.g., platform-specific debug aids, deployment metadata). MUST NOT be used to carry alternative attestation documents or shadow the binding rule." + } + }, + "required": [ + "attestation_format", + "attestation_document", + "nonce", + "signing_key", + "expires_at" + ], + "additionalProperties": false +} diff --git a/tests/check-platform-agnostic.cjs b/tests/check-platform-agnostic.cjs index 8f381b4c20..66893504cd 100644 --- a/tests/check-platform-agnostic.cjs +++ b/tests/check-platform-agnostic.cjs @@ -140,6 +140,27 @@ const ENUM_VALUE_ALLOWLIST = [ // enums/metro-system.json — Nielsen DMA is the industry-standard geographic // division (same justification as FIELD_ALLOWLIST entry). { value: 'nielsen_dma', pathContains: 'enums/metro-system.json' }, + + // trusted-match/router-attestation.json — `attestation_format` enum members + // each name a single externally-defined attestation document format and + // version (canonical-external-identifier pattern, parallel to + // google_merchant_center in enums/feed-format.json). The verifier kit for + // each value parses the format-specific user-data slot; AdCP itself does + // not parse the document. Listed here for path-qualified discoverability + // and forward-compatibility against any vendor-token check. + { value: 'aws_nitro_cose_sign1_v1', pathContains: 'trusted-match/router-attestation.json' }, // AWS Nitro Enclaves attestation document, COSE_Sign1, v1 — canonical externally-defined format. + { value: 'intel_tdx_quote_v4', pathContains: 'trusted-match/router-attestation.json' }, // Intel Trust Domain Extensions quote, v4 — canonical externally-defined format. + { value: 'amd_sev_snp_attestation_v1', pathContains: 'trusted-match/router-attestation.json' }, // AMD SEV-SNP attestation report, v1 — canonical externally-defined format. + { value: 'gcp_confidential_space_v1', pathContains: 'trusted-match/router-attestation.json' }, // GCP Confidential Space attestation token, v1 — canonical externally-defined format. + + // trusted-match/provider-registration.json — `attestation_requirement.acceptable_formats` + // mirrors the router-attestation.json enum (the provider declares which + // formats it accepts from a calling router). Same canonical-external-identifier + // justification applies; entries duplicated for path-qualification. + { value: 'aws_nitro_cose_sign1_v1', pathContains: 'trusted-match/provider-registration.json' }, + { value: 'intel_tdx_quote_v4', pathContains: 'trusted-match/provider-registration.json' }, + { value: 'amd_sev_snp_attestation_v1', pathContains: 'trusted-match/provider-registration.json' }, + { value: 'gcp_confidential_space_v1', pathContains: 'trusted-match/provider-registration.json' }, ]; function findJSONFiles(dir) {