Skip to content

docs: add RFC 9421 request signing guide#3064

Draft
benminer wants to merge 6 commits intomainfrom
docs/rfc-9421-signing-guide
Draft

docs: add RFC 9421 request signing guide#3064
benminer wants to merge 6 commits intomainfrom
docs/rfc-9421-signing-guide

Conversation

@benminer
Copy link
Copy Markdown
Collaborator

Summary

Ported documentation from adcontextprotocol/adcp-client#914, which was accidentally opened against the wrong repo.

  • Adds docs/building/implementation/request-signing.mdx — practical step-by-step guide covering key generation, JWKS/brand.json publication, buyer-side signing, seller-side verification, webhook signing, capability declaration, key rotation, and conformance testing
  • Adds a Request Signing section to docs/building/build-an-agent.mdx showing the requireSignatureWhenPresent + verifySignatureAsAuthenticator composition pattern and webhook signer config
  • Adds request-signing to the Implementation Patterns nav in docs.json
  • Adds a cross-link <Note> in the security.mdx quickstart section pointing to the new guide (framing the spec as the normative source the guide implements)

Notes

The normative RFC 9421 spec already lives in docs/building/implementation/security.mdx. This guide is the approachable complement — what you actually run and configure — pointing back to the spec for the verifier checklist, canonicalization rules, and error taxonomy.

- Adds docs/building/implementation/request-signing.mdx — practical step-by-step guide covering key generation, JWKS/brand.json publication, client-side signing, server-side verification, webhook signing, capability declaration, key rotation, and conformance testing.
- Adds a Request Signing section to docs/building/build-an-agent.mdx showing requireSignatureWhenPresent composition and webhook signer config.
- Adds request-signing to the Implementation Patterns nav in docs.json.
- Adds a cross-link Note in the security.mdx quickstart section pointing to the new guide.

Ported from adcontextprotocol/adcp-client#914.
- Replace requireSignatureWhenPresent manual composition with requireAuthenticatedOrSigned in Step 4 and build-an-agent.mdx — the higher-level helper also enforces request_signature_required for the unsigned-no-credentials path
- Use mcpToolNameResolver instead of inline JSON-RPC resolver boilerplate throughout
- Fix resolveOperation in the Express middleware example (was incorrectly using req.body.method)
- Add conformance vector counts: 39 total (12 positive, 27 negative)

Addresses feedback from adcontextprotocol/adcp-client#914 comment by bokelley; tracks mcpToolNameResolver landing in adcp-client#916.
@benminer benminer requested a review from bokelley April 24, 2026 21:20
@bokelley
Copy link
Copy Markdown
Contributor

Heads-up: two SDK ergonomics PRs landed on adcp-client main that this guide can take advantage of once a new @adcp/client release ships them:

  • adcp-client#916 (merged) — mcpToolNameResolver exported from @adcp/client/server. Already adopted in this guide.
  • adcp-client#917 (merged) — defaults replayStore / revocationStore on both verifySignatureAsAuthenticator AND createExpressVerifier; defaults upstream to globalThis.fetch on buildAgentSigningFetch; new createAgentSignedFetch({ signing, sellerAgentUri }) preset bundling buildAgentSigningFetch + CapabilityCache for single-seller buyers.

Specific tightening once the release ships

Step 4 — Express middleware (createExpressVerifier): drop the two store lines.

createExpressVerifier({
  capability: { supported: true, covers_content_digest: 'required', required_for: ['create_media_buy', 'update_media_buy'] },
  jwks: new StaticJwksResolver(buyerPublicKeys),
- replayStore: new InMemoryReplayStore(),
- revocationStore: new InMemoryRevocationStore(),
  resolveOperation: mcpToolNameResolver,
})

Step 4 — requireAuthenticatedOrSigned: same drop on the verifySignatureAsAuthenticator block. Imports for InMemoryReplayStore / InMemoryRevocationStore from @adcp/client/signing/server can come out too.

Step 3 — Capability-aware signing: the buildAgentSigningFetch + CapabilityCache + getCapability example collapses to:

import { createAgentSignedFetch } from '@adcp/client/signing';

export const signedFetch = createAgentSignedFetch({
  signing: {
    kid: 'my-agent-2026',
    alg: 'ed25519',
    private_key: privateJwk,
    agent_url: 'https://agent.example.com',
  },
  sellerAgentUri: 'https://seller.example.com',
});

Worth keeping the longer buildAgentSigningFetch form too, as a section labeled "multi-seller" — it's still the right shape when one buyer talks to N sellers. The preset is explicitly the single-seller shortcut.

No need to wait — happy for this to merge as-is and follow up with a tightening PR after the next @adcp/client release. Or I can push the tightening directly into this branch if you'd prefer one-and-done. Whichever's easier.

@bokelley
Copy link
Copy Markdown
Contributor

we should also have examples for python and go

@bokelley
Copy link
Copy Markdown
Contributor

SDK ergonomics PRs are now out across all three official SDKs — happy to update this guide once they ship in a release. Posting the cross-SDK status so the docs can cite them in lockstep.

SDK PR Status
TypeScript (`@adcp/client`) adcp-client#917 ✅ merged
Python (`adcp`) adcp-client-python#272 open
Go (`adcp-go`) adcp-go#88 open

All three land the same security-by-default story:

  • Replay store defaults to in-memory when omitted on the seller-side verifier surface. Previously `nil`/`None`/`undefined` silently disabled replay protection — the regression AdCP verifier checklist step 12/13 (and conformance vector `016-replayed-nonce`) exists to prevent.
  • Single-seller buyer preset. Each SDK exposes its idiomatic equivalent: TS `createAgentSignedFetch({ signing, sellerAgentUri })` returning a `FetchLike`, Python `install_signing_event_hook(client, signing=, capability_provider=)` plus `signing_operation()` context manager, Go `NewSignedHTTPClient(SignedHTTPClientOptions{..., CapabilityProvider})` returning an `*http.Client` with redirect-following disabled.
  • Capability-aware signing. Buyer presets can take a `capability_provider` / `getCapability` / `CapabilityProvider` callable that returns the seller's `request_signing` capability per request — only signs operations the seller listed in `required_for` / `warn_for` / `supported_for`, and honors `covers_content_digest` per-call.

Once Python and Go merge and release-please cuts new versions, the docs here can:

  1. Drop the explicit `new InMemoryReplayStore()` / `new InMemoryRevocationStore()` lines from Step 4's `createExpressVerifier` and `requireAuthenticatedOrSigned` examples (TS).
  2. Collapse the `buildAgentSigningFetch` + `CapabilityCache` + `getCapability` setup into a single `createAgentSignedFetch` call (TS).
  3. Optionally add a "Python" tab under Step 3/4/5/6 showing `install_signing_event_hook` + `signing_operation` and `verify_starlette_request` with default stores. Python migration walkthrough is at docs/request-signing-migration.md (mirrors adcp-go's existing `MIGRATION.md` shape).
  4. Optionally add a "Go" tab showing `NewSignedHTTPClient` + capability-aware signing.

Happy to send a follow-up tightening PR after the Python/Go releases ship, or to push the rewrite directly into this branch — your call. Either way no need to block this PR on it.

cc @benminer

@bokelley
Copy link
Copy Markdown
Contributor

Heads up — three SDK PRs landed (or are in flight) on @adcp/client 5.20.0 that touch sections of this guide. Worth either pulling the additions into this PR or filing a follow-up. Each is small (~10 lines of guide each), and each fills a real gap a reader landing on this page in 5.20.0+ will hit.

1. Step 1.3 "Storing the private key" — add KMS option.

adcp-client#1017 (merged) ships SigningProvider, an async signer interface so private keys can live in GCP KMS / AWS KMS / Azure Key Vault / Vault Transit instead of process memory. The current advice ("load at boot, keep in memory for the process lifetime") is now the non-recommended path for production. Suggested addition right under the existing storage list:

Production: KMS-backed signers. @adcp/client 5.20.0+ supports a pluggable SigningProvider so the private key never leaves your managed key store. See the SDK guide § Production Key Storage for the GCP KMS reference adapter and the IAM / JWKS-publication walkthrough.

2. Step 3 "Sign outbound requests" — show the kind: 'provider' shape.

AgentRequestSigningConfig is now a discriminated union on kind. Existing private_key literals still work (kind defaults to 'inline'), but production callers should show:

const provider = await createGcpKmsSigningProvider({
  versionName: process.env.ADCP_KMS_VERSION!,
  kid: 'my-agent-2026',
  algorithm: 'ed25519',
  client: kmsClient,
});

const signingFetch = buildAgentSigningFetch({
  upstream: fetch,
  signing: { kind: 'provider', provider, agent_url: 'https://agent.example.com' },
  getCapability: () => capabilityCache.get('https://seller.example.com'),
});

Wire format unchanged — counterparties can't tell the difference between in-process and KMS-backed signing.

3. Step 4 "Verify inbound signatures" — call out multi-instance verifier deployments.

adcp-client#1018 (merged) ships PostgresReplayStore. The default InMemoryReplayStore is per-process — multi-instance verifier fleets leak replay protection across the boundary because each box has its own cache. RFC 9421's 5-minute window bounds the gap but it's plenty of time for an in-flight replay. Suggested addition near the JWKS-resolver-options table:

Multi-instance verifier deployments need a shared replay store. The default InMemoryReplayStore is per-process; on a fleet, a captured signed request can be replayed against a sibling instance. Use PostgresReplayStore (5.20.0+) — same ReplayStore interface, one-line swap. See the SDK guide § Verify Inbound Signatures.

4. Step 6 "Sign outbound webhooks" — flag the in-process-only caveat.

createAdcpServer.webhooks.signerKey currently accepts only an in-process SignerKey — KMS-backed webhook signing on the server side is a follow-up. Worth a one-line caveat so readers don't expect symmetry with outbound request signing.

5. Testing — add adcp grade signer next to the existing verifier grader.

adcp-client#1019 (open, all CI green, accumulates into the same 5.20.0 release) ships the signer-side grader:

# KMS-backed signer via signing oracle
adcp grade signer https://addie.example.com \
  --signer-url https://signer.internal/sign --signer-auth "Bearer ${SIGNER_TOKEN}" \
  --kid addie-2026-04 --alg ed25519 \
  --jwks-url https://addie.example.com/.well-known/jwks.json

Pairs with adcp grade request-signing (verifier side) — together they cover both sides of the wire. Surfaces DER-vs-P1363 / kid-mismatch / algorithm-mismatch / missing-Content-Digest as specific verifier error_codes before pushing live signed traffic.

My spec-side PR #3255 (docs(security): production key storage subsection for RFC 9421 signing) adds a "Production key storage" subsection to security.mdx that pairs with this guide — different files, complementary content. They should merge cleanly (your <Note> is at line ~798, mine is in the steps below + a new subsection further down).

Happy to push these as a follow-up commit on this branch, or land yours as-is and I file a separate guide-update PR after #1019 merges and 5.20.0 publishes — whichever fits your release rhythm. Either order works.

cc @bokelley

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