diff --git a/.changeset/docs-rfc9421-signing-guide.md b/.changeset/docs-rfc9421-signing-guide.md new file mode 100644 index 0000000000..0314cc09ed --- /dev/null +++ b/.changeset/docs-rfc9421-signing-guide.md @@ -0,0 +1,4 @@ +--- +--- + +Add RFC 9421 request signing guide: new `docs/building/implementation/request-signing.mdx` covering key generation, JWKS/brand.json publication, buyer-side signing, seller-side verification with `requireAuthenticatedOrSigned` + `mcpToolNameResolver`, webhook signing, key rotation, and conformance testing (39 vectors: 12 positive, 27 negative). Adds a Request Signing section to `build-an-agent.mdx` and cross-links from the `security.mdx` quickstart. Ported from adcontextprotocol/adcp-client#914. diff --git a/docs.json b/docs.json index a7bdb4f8f7..4504aa800d 100644 --- a/docs.json +++ b/docs.json @@ -149,6 +149,7 @@ "docs/building/implementation/mcp-response-extraction", "docs/building/implementation/a2a-response-extraction", "docs/building/implementation/security", + "docs/building/implementation/request-signing", "docs/building/implementation/webhook-verifier-tuning", "docs/building/implementation/seller-integration", "docs/building/implementation/storyboard-troubleshooting", diff --git a/docs/building/build-an-agent.mdx b/docs/building/build-an-agent.mdx index 66000a0c9d..958ad82c9b 100644 --- a/docs/building/build-an-agent.mdx +++ b/docs/building/build-an-agent.mdx @@ -188,6 +188,50 @@ Each skill includes variant storyboards for different business models — non-gu See **[Validate Your Agent](/docs/building/validate-your-agent)** for the full testing workflow — debugging failing steps, running compliance checks, and validating interactively through Addie. +## Request signing + +If your seller agent receives signed requests from buyers, use `requireAuthenticatedOrSigned` to compose signature verification with existing bearer or API key auth. It handles the full matrix: signature-only when headers are present, fallback-only when absent, and `request_signature_required` 401 for unsigned requests on operations listed in `requiredFor`. + +```typescript +import { createAdcpServer, serve } from '@adcp/client'; +import { + verifyApiKey, + verifySignatureAsAuthenticator, + requireAuthenticatedOrSigned, + mcpToolNameResolver, + MUTATING_TASKS, +} from '@adcp/client/server'; +import { BrandJsonJwksResolver, InMemoryReplayStore, InMemoryRevocationStore } from '@adcp/client/signing/server'; + +serve(() => createAdcpServer({ name: 'My Seller', version: '1.0.0', mediaBuy: { /* ... */ } }), { + authenticate: requireAuthenticatedOrSigned({ + signature: verifySignatureAsAuthenticator({ + capability: { supported: true, required_for: ['create_media_buy'], covers_content_digest: 'either' }, + jwks: new BrandJsonJwksResolver(), + replayStore: new InMemoryReplayStore(), + revocationStore: new InMemoryRevocationStore(), + resolveOperation: mcpToolNameResolver, + }), + fallback: verifyApiKey({ keys: { /* ... */ } }), + requiredFor: [...MUTATING_TASKS], + resolveOperation: mcpToolNameResolver, + }), +}); +``` + +For outbound webhook signing, pass a `signerKey` to `createAdcpServer`: + +```typescript +createAdcpServer({ + webhooks: { + signerKey: { keyid: 'my-webhook-key-2026', alg: 'ed25519', privateKey: webhookPrivateJwk }, + }, + // ... +}); +``` + +See **[Request Signing Guide](/docs/building/implementation/request-signing)** for the full walkthrough: key generation, JWKS publication, brand.json setup, buyer-side signing, and conformance testing. + ## Additional resources The JS/TS SDK includes documentation designed for both humans and coding agents: diff --git a/docs/building/implementation/request-signing.mdx b/docs/building/implementation/request-signing.mdx new file mode 100644 index 0000000000..ed3675c572 --- /dev/null +++ b/docs/building/implementation/request-signing.mdx @@ -0,0 +1,398 @@ +--- +title: Request Signing Guide +description: "Step-by-step guide to RFC 9421 request signing in AdCP: key generation, JWKS publication, brand.json setup, client-side signing, server-side verification, webhook signing, key rotation, and conformance testing." +"og:title": "AdCP — Request Signing Guide" +--- + +AdCP 3.0 supports [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421) for cryptographic request authentication. A buyer signs outbound requests so the seller can verify who sent them and that the payload wasn't tampered with. A seller signs outbound webhooks so the buyer can verify authenticity. + +Signing is **optional in AdCP 3.0** and becomes **mandatory in AdCP 4.0** for all spend-committing operations. Agents that don't sign yet must still tolerate signature headers (`Signature`, `Signature-Input`, `Content-Digest`) on inbound requests without breaking. + + +This is the practical implementation guide. For the normative specification — covered components, canonicalization rules, the full verifier checklist, replay dedup sizing, and the complete error taxonomy — see [Security: Signed Requests](/docs/building/implementation/security#signed-requests-transport-layer). + + +## When you need this + +| You are a... | You need to... | Why | +|---|---|---| +| **Buyer** (calls seller tools) | Sign outbound requests | Sellers may require proof the request came from you | +| **Buyer** (receives webhooks) | Verify inbound webhook signatures | Confirm the webhook came from the seller | +| **Seller** (receives tool calls) | Verify inbound request signatures | Confirm the buyer is who they claim to be | +| **Seller** (sends webhooks) | Sign outbound webhooks | Let buyers verify webhook authenticity | +| **Orchestrator** (proxies to sellers) | Sign outbound requests + verify inbound webhooks | You're the buyer from the seller's perspective | + +## Key concepts + +### Signature coverage + +The AdCP signing profile covers these request components: + +- `@method` — HTTP method +- `@target-uri` — full canonicalized request URL +- `@authority` — lowercased host header +- `content-type` — media type +- `content-digest` — SHA-256 or SHA-512 hash of the request body (see `covers_content_digest` capability) + +If any covered component changes after signing, verification fails. + +### Key separation + +Every agent needs **separate keys per purpose**, each with a distinct `kid` and `adcp_use` tag: + +- `adcp_use: "request-signing"` — for signing outbound tool calls +- `adcp_use: "webhook-signing"` — for signing outbound webhooks + +Reusing a key across purposes is forbidden by the spec. + +### Discovery chain + +Verifiers find your public key through a three-step chain: + +``` +Your domain (e.g., agent.example.com) + -> /.well-known/brand.json # brand manifest with agent declarations + -> agents[].jwks_uri # pointer to your key store + -> /.well-known/jwks.json # JSON Web Key Set with public keys +``` + +The `@adcp/client` SDK provides `BrandJsonJwksResolver` which handles this chain automatically with caching and refresh. + +## Step 1: Generate a signing key + +### CLI + +```bash +adcp signing generate-key --alg ed25519 --kid my-agent-2026 \ + --private-out ./private.jwk --public-out ./public-jwks.json +``` + +This generates an Ed25519 keypair and writes: +- `private.jwk` — the private key (JWK with `d` field). Keep this secret. +- `public-jwks.json` — the public key in JWKS format. Publish this. + +### Programmatic (TypeScript) + +```typescript +import { generateKeyPair, exportJWK } from 'jose'; + +const { publicKey, privateKey } = await generateKeyPair('EdDSA', { crv: 'Ed25519' }); +const publicJwk = await exportJWK(publicKey); +const privateJwk = await exportJWK(privateKey); + +const kid = 'my-agent-2026'; +publicJwk.kid = kid; +publicJwk.use = 'sig'; +publicJwk.key_ops = ['verify']; +publicJwk.adcp_use = 'request-signing'; +``` + +### Supported algorithms + +| Algorithm | `alg` value | Key type | Notes | +|---|---|---|---| +| Ed25519 | `ed25519` (RFC 9421) / `EdDSA` (JWK) | `OKP` / `Ed25519` | Preferred. Fast, small signatures. | +| ECDSA P-256 | `ecdsa-p256-sha256` (RFC 9421) / `ES256` (JWK) | `EC` / `P-256` | Edge-runtime friendly (Cloudflare Workers, Vercel Edge). | + + +The algorithm name differs between the JWK entry (`"alg": "EdDSA"`) and the RFC 9421 `Signature-Input` parameter (`alg="ed25519"`). See the [algorithm naming table](/docs/building/implementation/security#adcp-rfc-9421-profile) in the spec. + + +### Storing the private key + +- **Environment variable**: `ADCP_SIGNING_PRIVATE_KEY='{"kid":"...","kty":"OKP",...}'` +- **Secret manager** (GCP Secret Manager, AWS Secrets Manager, etc.): load at boot, keep in memory for the process lifetime +- **File**: for development only. Never commit to version control. + +## Step 2: Publish your public keys + +### JWKS endpoint + +Serve a JSON Web Key Set at a stable HTTPS URL (defaults to `/.well-known/jwks.json`): + +```json +{ + "keys": [ + { + "kid": "my-agent-2026", + "kty": "OKP", + "crv": "Ed25519", + "x": "", + "use": "sig", + "key_ops": ["verify"], + "adcp_use": "request-signing" + } + ] +} +``` + +Only public keys go here — no `d` field. Set `Cache-Control: max-age=3600` or similar. If you serve both request-signing and webhook-signing keys, include both in the same JWKS with different `kid` values and `adcp_use` tags. + +### brand.json + +Serve at `/.well-known/brand.json` on your brand domain. The `jwks_uri` is how verifiers find your keys: + +```json +{ + "name": "My Company", + "domain": "example.com", + "agents": [ + { + "url": "https://agent.example.com", + "jwks_uri": "https://agent.example.com/.well-known/jwks.json", + "capabilities": ["media-buy"], + "adcp_use": ["request-signing"] + } + ] +} +``` + +## Step 3: Sign outbound requests (buyer / orchestrator) + +### Wrapping fetch + +`createSigningFetch` wraps any `fetch`-compatible function to sign outbound requests automatically: + +```typescript +import { createSigningFetch } from '@adcp/client/signing'; + +const privateJwk = JSON.parse(process.env.ADCP_SIGNING_PRIVATE_KEY); + +const signingFetch = createSigningFetch(fetch, { + keyid: 'my-agent-2026', + alg: 'ed25519', + privateKey: privateJwk, +}); + +// Use signingFetch anywhere you'd use fetch. +// Signature, Signature-Input, and Content-Digest headers are added automatically. +await signingFetch('https://seller.example.com/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), +}); +``` + +### Capability-aware signing + +`buildAgentSigningFetch` checks whether the target seller supports `signed-requests` and only signs when supported. This is the recommended approach for production: + +```typescript +import { buildAgentSigningFetch, CapabilityCache } from '@adcp/client/signing/client'; + +const capabilityCache = new CapabilityCache(); + +const signingFetch = buildAgentSigningFetch({ + upstream: fetch, + signing: { + kid: 'my-agent-2026', + alg: 'ed25519', + private_key: privateJwk, + agent_url: 'https://agent.example.com', + sign_supported: true, + }, + getCapability: () => capabilityCache.get('https://seller.example.com'), +}); +``` + +This avoids sending signatures to agents that don't expect them and caches capability lookups. + +## Step 4: Verify inbound signatures (seller) + +### Express middleware + +For raw Express routes, mount `createExpressVerifier` after a raw-body middleware. Use `mcpToolNameResolver` for the `resolveOperation` callback — it parses the JSON-RPC envelope and returns the MCP tool name: + +```typescript +import { + createExpressVerifier, + StaticJwksResolver, + InMemoryReplayStore, + InMemoryRevocationStore, +} from '@adcp/client/signing'; +import { mcpToolNameResolver } from '@adcp/client/server'; + +app.post( + '/mcp', + rawBodyMiddleware(), // req.rawBody must hold the byte-exact body + 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, + }), + handler +); +// On verify: req.verifiedSigner = { keyid, agent_url?, verified_at }. +// On reject: 401 with WWW-Authenticate: Signature error="". +``` + +### Composing signature + bearer auth with `requireAuthenticatedOrSigned` + +`requireAuthenticatedOrSigned` bundles the full composition: presence-gated routing (signature auth when headers present, fallback otherwise) plus `requiredFor` enforcement — unauthenticated requests for signing-required operations get `401 request_signature_required` even when no credentials at all are supplied. + +```typescript +import { + serve, + verifyApiKey, + verifySignatureAsAuthenticator, + requireAuthenticatedOrSigned, + mcpToolNameResolver, + MUTATING_TASKS, +} from '@adcp/client/server'; +import { BrandJsonJwksResolver, InMemoryReplayStore, InMemoryRevocationStore } from '@adcp/client/signing/server'; + +serve(createAgent, { + authenticate: requireAuthenticatedOrSigned({ + signature: verifySignatureAsAuthenticator({ + capability: { supported: true, required_for: ['create_media_buy'], covers_content_digest: 'either' }, + jwks: new BrandJsonJwksResolver(), + replayStore: new InMemoryReplayStore(), + revocationStore: new InMemoryRevocationStore(), + resolveOperation: mcpToolNameResolver, + }), + fallback: verifyApiKey({ keys: { 'sk_live_abc': { principal: 'acct_42' } } }), + requiredFor: [...MUTATING_TASKS], + resolveOperation: mcpToolNameResolver, + }), +}); +``` + +`MUTATING_TASKS` is the full list of spend-committing and state-changing operations exported from `@adcp/client/server` — use it rather than maintaining your own list. + +### JWKS resolver options + +| Resolver | Use case | +|---|---| +| `StaticJwksResolver` | Fixed set of known buyer keys. Good for dev/testing. | +| `HttpsJwksResolver` | Fetches JWKS from a URL with caching and refresh. | +| `BrandJsonJwksResolver` | Full discovery chain: brand.json → jwks_uri → JWKS. Recommended for production. | + +## Step 5: Verify inbound webhooks (buyer / orchestrator) + +When sellers send webhooks, verify the signature to confirm authenticity: + +```typescript +import { + verifyWebhookSignature, + BrandJsonJwksResolver, + InMemoryReplayStore, +} from '@adcp/client/signing/server'; + +const jwks = new BrandJsonJwksResolver(); +const replayStore = new InMemoryReplayStore(); + +app.post('/webhook', async (req, res) => { + try { + await verifyWebhookSignature(req, { jwks, replayStore }); + } catch { + return res.status(401).json({ error: 'invalid webhook signature' }); + } + + // Process the verified webhook... +}); +``` + +## Step 6: Sign outbound webhooks (seller) + +Pass a `signerKey` to `createAdcpServer` and the framework signs every outbound webhook automatically: + +```typescript +serve(() => createAdcpServer({ + name: 'My Seller', + version: '1.0.0', + webhooks: { + signerKey: { + keyid: 'my-seller-webhook-2026', + alg: 'ed25519', + privateKey: webhookPrivateJwk, + }, + }, + mediaBuy: { /* ... */ }, +})); +``` + +Publish a separate JWK with `"adcp_use": "webhook-signing"` in your JWKS alongside your request-signing key. + +## Step 7: Declare the capability + +If your seller verifies inbound signatures, declare `signed_requests` in your capabilities so buyers know to sign: + +```typescript +createAdcpServer({ + capabilities: { + overrides: { + signed_requests: { + supported: true, + required_for: ['create_media_buy', 'update_media_buy'], + supported_for: ['sync_creatives', 'sync_audiences'], + covers_content_digest: 'either', + }, + }, + }, + mediaBuy: { /* ... */ }, +}); +``` + +Buyers call `get_adcp_capabilities` and read `request_signing.required_for` and `supported_for` to know which operations you expect them to sign. + +## Key rotation + +The JWKS endpoint supports multiple keys simultaneously for zero-downtime rotation: + +1. Generate a new keypair with a new `kid` +2. Add the new public key to JWKS (both old and new are published) +3. Update signing configuration to use the new private key +4. After 24–48 hours, remove the old public key from JWKS + +For emergency rotation (key compromise), add the old `kid` to `revoked_kids` in your revocation list and rotate to a new key immediately. See [Revocation](/docs/building/implementation/security#revocation) for the revocation list format. + +## Testing + +### Conformance vectors + +The spec ships **39 test vectors** at `compliance/cache/3.0.0/test-vectors/request-signing/` (source at `static/compliance/source/test-vectors/request-signing/`): + +- **12 positive vectors**: valid signed requests your verifier must accept (non-4xx) +- **27 negative vectors**: invalid requests your verifier must reject with `401` and the correct error code + +```bash +# Debug a single vector +adcp signing verify-vector \ + --vector compliance/cache/3.0.0/test-vectors/request-signing/positive/001-basic-post.json +``` + +### Grade your verifier + +```bash +adcp grade request-signing https://agent.example.com/mcp --auth-token $TOKEN +``` + +### Error codes + +When verification fails, return `401` with `WWW-Authenticate: Signature error=""`: + +| Code | Meaning | +|---|---| +| `missing_signature` | Signature headers not present when required | +| `invalid_signature` | Signature doesn't verify against the public key | +| `expired_signature` | Signature timestamp too old | +| `replayed_nonce` | Nonce was already used | +| `revoked_key` | Key has been revoked | +| `unknown_key` | Key ID not found in JWKS | +| `unsupported_algorithm` | Algorithm not in allowlist | + +For the full error code taxonomy, see [Transport error taxonomy](/docs/building/implementation/security#transport-error-taxonomy). + +## Related + +- [Security: Signed Requests](/docs/building/implementation/security#signed-requests-transport-layer) — normative spec with verifier checklist, canonicalization rules, and replay dedup sizing +- [Push Notifications](/docs/building/implementation/webhooks) — webhook setup including signature verification +- [Validate Your Agent](/docs/building/validate-your-agent) — full compliance validation including signing conformance +- [Build an Agent](/docs/building/build-an-agent) — SDK setup and storyboard validation +- [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) — HTTP Message Signatures specification diff --git a/docs/building/implementation/security.mdx b/docs/building/implementation/security.mdx index 44d7a1d4af..47a2b2d08f 100644 --- a/docs/building/implementation/security.mdx +++ b/docs/building/implementation/security.mdx @@ -795,6 +795,10 @@ Read calls remain bearer-authenticated. Signing read traffic adds verification c #### Quickstart: opt into request signing in 3.0 + +Looking for step-by-step setup with code examples? See the **[Request Signing Guide](/docs/building/implementation/request-signing)** — key generation, JWKS publication, brand.json, SDK signing/verification patterns, and conformance testing. This section is the normative specification that guide implements. + + For implementers who want to pilot signing in 3.0 before the 4.0 flip: **As an agent that signs requests:**