Skip to content

spec(trusted-match): TMP router attestation RFC (experimental)#5770

Draft
ohalushchak-exadel wants to merge 4 commits into
mainfrom
ohalushchak-exadel/tmp-router-attestation-rfc
Draft

spec(trusted-match): TMP router attestation RFC (experimental)#5770
ohalushchak-exadel wants to merge 4 commits into
mainfrom
ohalushchak-exadel/tmp-router-attestation-rfc

Conversation

@ohalushchak-exadel

@ohalushchak-exadel ohalushchak-exadel commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Draft / RFC — opening this for review on the wire shape; not a ship-me-now PR.

Summary

Promotes the existing one-paragraph "TEE upgrade path" mention in docs/trusted-match/router-architecture.mdx into a normative, experimentally-gated wire shape:

  • New endpoint: GET /.well-known/tmp-router-attestation?nonce=<base64url, 16-32 raw bytes>
  • New schema: static/schemas/source/trusted-match/router-attestation.json (envelope returned by the endpoint)
  • New header: X-TMP-Attestation attached to per-provider outbound requests when the provider requires it
  • New optional block: attestation_requirement on provider-registration.json so providers declare their policy
  • New experimental gate: trusted_match.router_attestation (separate from trusted_match.core and trusted_match.verified_identity)

The trust gap this closes (in one sentence)

Today, the TMP Router's privacy claim depends on operational trust that the operator deployed the audited binary; with this RFC, providers can require an attestation envelope whose binding rule cryptographically anchors the router's existing per-provider RFC 9421 signature path to an attested enclave.

The binding rule (the load-bearing piece — read this first)

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 doesn't byte-match the envelope's signing_key (RFC 7638 thumbprint comparison is the acceptable form).

Without this rule, a router could attest a clean binary and then sign requests with a different (deployment-side) key — every per-provider signature would verify, and no protocol mechanism would detect the swap. With it, every per-provider signature inherits the attestation.

Cache discipline is aligned with existing TMP key-rotation rules

The envelope binds a signing key, so caching the envelope is functionally the same problem as caching the key. Verifier cache discipline therefore reuses the convention already shared by TMP signing keys and TMPX HPKE keys at specification.mdx#key-rotation — 5-minute default TTL, eager re-fetch on kid change, revoked_at flow-through. attestation_requirement.min_freshness_sec default is 300 seconds to match.

Design rationale

See specs/tmp-router-attestation.md — Problem framing, Design (8 numbered decisions including a Caching subsection cross-referencing the existing key-rotation rules), Security Analysis, Alternatives Considered (per-request attestation / no nonce / measurement allowlist in spec / vendor-neutral URN scheme / ext.tee-only), Open Questions, Non-goals.

Scope (wire shape only)

In scope: endpoint, envelope schema, nonce mechanic, binding rule, per-request header carrier, provider-registration policy block, experimental capability gate, allowlist entries for the four canonical externally-defined attestation_format enum values (Nitro / TDX / SEV-SNP / Confidential Space).

Out of scope: reproducible-build pipeline, KMS provisioning, adcp-go implementation, conformance scenarios, verifier-kit implementations, measurement allowlists. All called out explicitly in the rationale's Non-goals section.

Open questions raised in the rationale

  1. Revocation list distribution (per-vendor channels vs AdCP-aggregator). Recommendation: punt to verifier kits in v1.
  2. Multi-region routers with rotating keys (one envelope per key vs JWK array under signing_key). Recommendation: one-envelope-per-key in v1.
  3. Deprecation flow when an attestation_format is superseded — dual-emit envelope vs. coordinated rollout.
  4. Issued-time semantics — envelope has expires_at but no uniform issued_at; "age" is verifier-kit-derived from the platform document. Recommendation: verifier-kit-derived; surface in parsed result.

(A fifth open question — attestation caching by long-lived providers — was removed after the cache discipline was aligned with the existing TMP key-rotation rules; see the new Caching subsection in the design doc.)

Files changed

Type File Purpose
NEW specs/tmp-router-attestation.md Design rationale
NEW docs/trusted-match/router-attestation.mdx Normative spec
NEW static/schemas/source/trusted-match/router-attestation.json Envelope schema
NEW .changeset/tmp-router-attestation-rfc.md Minor bump
MOD static/schemas/source/trusted-match/provider-registration.json attestation_requirement block (min_freshness_sec default 300, matching signing-key TTL)
MOD docs/trusted-match/router-architecture.mdx Points at normative page
MOD docs/trusted-match/privacy-architecture.mdx Same; keeps informative framing
MOD docs/protocol/get_adcp_capabilities.mdx + schema New experimental feature id in examples
MOD tests/check-platform-agnostic.cjs Allowlist + per-line justifications for the four canonical formats
MOD docs.json Nav entry

Additive change to an experimental surface

Existing TMP behavior is unchanged for any participant that does 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. Sellers and routers that do support this surface MUST declare trusted_match.router_attestation in experimental_features — no silent-experimental, matching the verified-identity gating pattern.

Test plan

  • node tests/check-platform-agnostic.cjs — 0 violations
  • npm run test:json-schema — 284 examples validated, 19 tests passed
  • npm run test:schemas — all schema validation passed
  • npm run test:docs-nav — 21 tests passed
  • npm run test:schema-utf8 — passed
  • npm run test:examples — 55 passed
  • npm run test:extensions / test:extension-schemas — 31 passed
  • Full pre-commit suite (npm test + typecheck) — 4352 tests passed, 30 skipped
  • Mintlify link checker (pre-push) — no broken links
  • Reviewer sanity-check the binding-rule security argument and the per-request-header freshness story (skipped nonce-echo on the per-request path; freshness via min_freshness_sec + RFC 9421 signature on the request payload)
  • Reviewer review the four attestation_format enum values as canonical externally-defined formats (docs/spec-guidelines.md:264-279)
  • Confirm RFC 8615 /.well-known/ registration is the right discovery layer

🤖 Generated with Claude Code

@mintlify

mintlify Bot commented Jun 30, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
adcp 🟢 Ready View Preview Jun 30, 2026, 1:24 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@bokelley

Copy link
Copy Markdown
Contributor

Review — right direction, not yet normative

The instinct here is right, and so is the framing question of "TEE in the spec vs. adcp-go": a TEE isn't a spec object. But there is a legitimate, TEE-agnostic protocol contribution in this PR, and it mostly finds the correct seam. This is a strong DRAFT/RFC; it's not mergeable as a normative minor-bump yet — one structural flaw plus a couple of factual fixes stand in the way. (Verified against the branch.)

What's actually being signed — three objects, three signers, three homes

# Object Signed by Asserts Where the code lives
1 the attestation document the cloud/TEE vendor root (Nitro HW root / Intel·AMD quoting enclave / Google Cloud Attestation) "an enclave with measurements M is running, and its user-data slot contains X" per-format verifier kit (consumer)
2 the (nonce + signing-key) commitment in the user-data slot not a separate signature — the running router binary writes it; the vendor signature (#1) covers it "the attested enclave committed to this key and this nonce" adcp-go (byte layout is the contested seam — below)
3 the per-provider request signatures the router's Ed25519 key, held inside the enclave "an authorized router produced this request for this provider/epoch" adcp-go

The envelope JSON itself is unsigned, and that's correct — all integrity flows from the vendor-signed document via nonce-echo + slot-bound-nonce + thumbprint-binding. An envelope signature would only add a second key-management problem.

The durable invention is the binding rule (object #2 ties #3's key to #1's attested binary). That's the part worth making normative.

Two caveats on what this buys:

  • The PR calls Improve documentation framework and remove premature certification references #3 "the existing RFC 9421 per-provider signature path." It isn't. TMP's per-provider signing is a bespoke X-AdCP-Signature / X-AdCP-Key-Id Ed25519 envelope (specification.mdx:512-527); "9421" appears nowhere in docs/trusted-match/ (RFC 9421 is used on other AdCP surfaces — media-buy/webhooks — likely the source). It's repeated in the changeset, the normative mdx, and the schema. An implementer will hunt for Signature-Input headers that don't exist. Straight fix.
  • This proves key-custody-in-an-attested-enclave, not "the binary matches the audited source." Whether measurement M is the audited, structurally-separated router is decided by the measurement allowlist, which is (correctly) out of scope. The PR concedes "a verifier that allowlists a malicious build defeats the entire system." So the binding rule is necessary but not sufficient — sufficiency lives entirely in the deferred allowlist. A few spots overclaim ("confirms the binary running inside the enclave is the binary it expects") and should be hedged the way privacy-architecture.mdx already half-does ("subject to the verifier's local measurement allowlist").

TEE-in-spec vs adcp-go: the four-layer cut

The spec-able artifact isn't "a TEE" — it's a demand → carry → anchor capability: a verifier demands (attestation_requirement), the router carries (/.well-known envelope + header), the binding rule anchors to the key the protocol already trusts. TEE-agnostic by construction (attestation_format opaque enum, attestation_document opaque base64url).

  1. adcp spec — provider demand block, capability gate, the generic envelope fields, verification-flow ordering, failure-code names, and the binding rule. ✅ correctly placed.
  2. per-format verifier kit — vendor-root verification, measurement extraction, document parsing. ✅ correctly deferred.
  3. adcp-go — enclave emit-path, KMS/sealed-key custody, vendor attestation API, cache, conformance vectors. ✅ correctly deferred.
  4. deploy/ops — measurement allowlist, reproducible build, revocation distribution. ✅ correctly deferred.

The line between (1) and (2) is drawn one notch too early — this is the finding I'm most confident in. The PR declares the binding rule a normative MUST, but pushes the slot-projection convention (exactly how nonce + key-thumbprint lay into Nitro user_data vs TDX/SEV-SNP REPORTDATA vs GCP eat_nonce) below the spec into per-kit territory — and explicitly permits each kit to "elect a different canonical form provided it is consistent" — while justifying the whole design by appeal to "multiple verifier-kit implementations across multiple buyers." Those can't both hold. On a 64-byte TDX/SEV-SNP slot (32-byte nonce + 32-byte thumbprint = exactly 64, zero slack), byte order/offset/encoding are unconstrained free choices, so two conformant kits can disagree and an honest, correctly-attested router fails verification. A normative invariant whose compared bytes are defined non-normatively and divergently isn't an interop contract. Fix: pin the per-format projection as a single normative artifact per attestation_format (appendix or registry), even while document parsing stays in kits.

Objections that don't hold up on close reading (so we don't chase them)

  • "The binding rule is impossible on TDX/SEV-SNP — a JWK won't fit in 64 bytes." No. The normative text says "byte-match after canonical JWK serialization — RFC 7638 thumbprint comparison is the acceptable form." A thumbprint is a 32-byte SHA-256 digest; nonce(32)+thumbprint(32) fits 64 exactly, and hash-of-pubkey-into-report-data is the standard pattern on all three smaller formats. Realizable on all four. (Real residual: GCP is an issuer-signed JWT with an eat_nonce echo, not a raw slot — a category mismatch the "user-data slot" prose glosses, which feeds the projection-pinning fix above.)
  • "Provider-pull-with-its-own-nonce wasn't considered." It's the PR's primary verification path (the /.well-known endpoint + the "envelope-fetch" branch of step 1 + cache-by-thumbprint-with-eager-refetch-on-kid-change). The per-request header is an additive carrier for stateless providers. (Narrow point survives: the spec mandates the ~5 KB header on every request when required:true; making pull the default and the header optional/on-kid-change would be cheaper — but pull exists.)
  • "Unusable without reproducible builds." Overstated — an operator can populate an allowlist from self-attested reference values; reproducible builds make it independently trustworthy but aren't a usability precondition. (Verifier kits genuinely are required — the document is opaque.)

What I'd change before this goes normative

  1. RFC 9421 → the actual X-AdCP-Signature/X-AdCP-Key-Id TMP path, everywhere it appears.
  2. Resolve the trust anchor. The base spec is contradictory about whose key the provider verifies against — "the publisher's public key, obtained from the property registry" (specification.mdx:575/:603) vs the agent's agent-signing-key.json (:527/:581). The binding rule currently checks slot == envelope but not envelope == the key the verifier actually resolves for signature verification — that third leg is what closes the key-swap. Make it a three-way equality and name the one true anchor.
  3. Pull the per-format slot-projection up into the spec (the high-confidence finding). Recommend the thumbprint-commitment form, and stop treating GCP's JWT-claims model as a raw hardware slot.
  4. Hedge the overclaim — key-custody-in-an-attested-enclave-with-measurement-M, not source-correspondence.
  5. Sequencing. TMP is design-led/in-build with adcp-go as the implementer and zero shipped verifier kits today — there's no external-interop deadline forcing a frozen wire shape now. The slot bytes and the issued_at/freshness hole (Open Q4) are exactly what you learn by binding against a real Nitro document. I'd prototype the Nitro emit-path + one verifier kit in adcp-go first, then promote the proven envelope. Freezing a schema + 237-line normative page before the binding has touched a real attestation document optimizes protocol-aesthetic over implementation-reality.

Minor: give the envelope its own minimal public-JWK schema instead of $ref-ing the full agent-signing-key.json trust-anchor config (avoids two revocation sources of truth + thumbprint-canonicalization ambiguity); de-dupe the format enum shared across the two schemas.

Net: the map is right — binding-rule/demand-carry-anchor in adcp, TEE plumbing in adcp-go, format parsing in verifier kits, allowlists in deploy. Move the one byte-contract the central MUST depends on up into the spec, fix the RFC-9421 and trust-anchor wording, stop overclaiming, and — since there's no interop deadline — prove the binding in adcp-go before freezing the wire.

bokelley added a commit that referenced this pull request Jun 30, 2026
…dge attestation overclaim

TMP's per-provider request signing is the bespoke X-AdCP-Signature/
X-AdCP-Key-Id Ed25519 envelope (docs/trusted-match/specification.mdx
"Request Authentication"), not RFC 9421. Corrects all mislabeled
references introduced by this PR across the normative doc, the JSON
schema, the changeset, and the design rationale doc.

Also hedges the "binary it expects" attestation-proof overclaim to
match the existing measurement-allowlist hedge already used in
privacy-architecture.mdx.

Per maintainer review: #5770 (comment)
@bokelley

Copy link
Copy Markdown
Contributor

Thanks for the detailed review. Pushed a follow-up commit (23f4d5d) addressing the two items that are straight factual fixes:

RFC 9421 → X-AdCP-Signature/X-AdCP-Key-Id — confirmed against docs/trusted-match/specification.mdx ("Request Authentication" → "Signature envelope"): TMP's per-provider signing is the bespoke Ed25519 header envelope, not RFC 9421 (that label is correct for the media-buy/webhooks path, just not this one). Corrected everywhere it appeared in this PR's diff: router-attestation.mdx, router-attestation.json, the changeset, and specs/tmp-router-attestation.md (this last one wasn't named explicitly but had the same mislabeling 4x, so I treated it as in-scope under "everywhere it appears"). router-architecture.mdx and provider-registration.json were already clean — no edit needed there.

Attestation overclaim hedge — reworded the intro paragraph in router-attestation.mdx from "the binary it expects" to "matches the published, audited source code, subject to the verifier's local measurement allowlist," matching the hedge privacy-architecture.mdx already uses.

On the remaining items — the three-way trust-anchor equality, the slot-projection registry question, and the adcp-go-first sequencing recommendation, plus the minor schema-dedup note — I'm leaving those to you and the author. They're structural/design calls (one touches the security model directly) rather than mechanical fixes, and outside what this routine should be deciding unilaterally.


Generated by Claude Code

ohalushchak-exadel and others added 4 commits July 1, 2026 13:57
…ed_match.router_attestation`)

Promotes the existing one-paragraph "TEE upgrade path" mention in router-architecture
into a normative, experimentally-gated wire shape: a `GET /.well-known/tmp-router-attestation?nonce=...`
endpoint, a JSON envelope schema, an `X-TMP-Attestation` per-request header carrier, and a new
optional `attestation_requirement` block on provider-registration so providers can declare
they require an attested router.

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. This anchors the
router's existing per-provider RFC 9421 signatures to the attested binary rather than to an
out-of-band-deployed key. Verifiers reject when the bound key doesn't byte-match the envelope's
`signing_key` (RFC 7638 thumbprint comparison is the acceptable form).

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 — same canonical-external-identifier pattern as `enums/feed-format.json`; entries
added to the platform-agnostic lint allowlist with path-qualified justifications.

Additive change to an experimental surface: existing TMP behavior is unchanged for
participants that do not opt in.

Design rationale (Open Questions, Alternatives Considered, Security Analysis) lives in
specs/tmp-router-attestation.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto-fix from `npm run fix:schema-links` — bare `/schemas/...` paths break Mintlify's
link checker; the absolute `https://adcontextprotocol.org/schemas/v3/...` form is what
users click. No content changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y-rotation rules

TMP already defines a cache convention shared by signing keys and TMPX HPKE keys
(specification.mdx#key-rotation: 5-minute TTL, eager re-fetch on verification failure,
revoked_at semantics). The attestation envelope binds a signing key, so caching the
envelope is functionally the same problem as caching the key — the envelope's `kid`
IS the signing key's `kid`. Align rather than invent a parallel discipline.

Changes:
- specs/tmp-router-attestation.md: new Design §6 Caching subsection cross-referencing
  the existing key-rotation rules. Open Question §3 (attestation caching) removed — it
  collapses to "follow the existing convention." Remaining open questions renumbered.
- docs/trusted-match/router-attestation.mdx: new Caching section before the RFC 9421
  signing-flow section; same cross-reference and the eager-re-fetch SHOULD.
- provider-registration.json: `attestation_requirement.min_freshness_sec` default
  lowered from 3600 to 300, matching the 5-minute signing-key TTL. Schema range
  (60-86400) unchanged — providers with a defensible reason can still raise it.
- Updated description references from Open Question §5 → §4 after the renumber.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dge attestation overclaim

TMP's per-provider request signing is the bespoke X-AdCP-Signature/
X-AdCP-Key-Id Ed25519 envelope (docs/trusted-match/specification.mdx
"Request Authentication"), not RFC 9421. Corrects all mislabeled
references introduced by this PR across the normative doc, the JSON
schema, the changeset, and the design rationale doc.

Also hedges the "binary it expects" attestation-proof overclaim to
match the existing measurement-allowlist hedge already used in
privacy-architecture.mdx.

Per maintainer review: #5770 (comment)
@ohalushchak-exadel ohalushchak-exadel force-pushed the ohalushchak-exadel/tmp-router-attestation-rfc branch from 23f4d5d to 45ac575 Compare July 1, 2026 18:28
ohalushchak-exadel added a commit to adcontextprotocol/adcp-go that referenced this pull request Jul 2, 2026
…extprotocol/adcp#5770)

Adds a stand-alone Go package that implements both sides of the wire shape
proposed in the spec RFC:

- Emit path: Nsm interface (matches the AWS Nitro NSM AttestationDoc request
  shape) plus a mock Nsm that generates real COSE_Sign1 documents signed by
  a caller-owned test CA. Same wire format as production Nitro, so the
  verifier code path is byte-for-byte identical between mock and prod.
- Verify path: full 9-step verification flow from the normative page —
  nonce echo, expiry, freshness, format support, COSE_Sign1 parse, cert-chain
  to a caller-supplied root, ECDSA-P384 signature check, slot-bound nonce,
  binding rule (RFC 7638 thumbprint), measurement-policy hook. Returns
  typed VerifyError values naming the failure modes the spec defines.

Wired against a `nitro` build tag placeholder for a real /dev/nsm impl;
real wiring lands in a follow-up. Router integration deferred until the
byte layout in the spec is settled — the whole point of building this
prototype is bokelley's finding on the spec PR that the slot bytes are
what you learn by binding against a real Nitro document.

The finding is documented in nitro/PROJECTION.md and is the primary
deliverable: the spec's "same user-data slot" wording is a category error
for Nitro (which has three separate fields: `nonce`, `public_key`,
`user_data`), and the cleanest projection uses those dedicated fields
directly rather than inventing a synthetic packing. That feeds back into
the spec's slot-projection registry item and, transitively, its
GCP-vs-Nitro asymmetry (GCP is a JWT, not a raw slot).

Round-trip test covers the happy path plus all 8 failure modes from the
spec's failure-mode table:
  nonce_mismatch, envelope_expired, unsupported_format,
  platform_verification_failed (tampered document AND wrong root),
  signing_key_not_bound, measurement_disallowed, per-request path
  without nonce echo. All 11 tests pass on the mock; no Nitro hardware
  required.

Not in this prototype (explicit non-goals in the README):
  - Real Nitro NSM wiring
  - TDX / SEV-SNP / GCP Confidential Space
  - Router integration (cmd/router, router/)
  - X-TMP-Attestation per-request header carrier
  - KMS-bound in-enclave key custody

Deps: github.com/veraison/go-cose, github.com/fxamacker/cbor/v2. Both pure
Go, no cgo.
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.

2 participants