From 661a4665d809b30534310e508331b97d52c07940 Mon Sep 17 00:00:00 2001 From: BaiyuScope3 Date: Mon, 15 Jun 2026 13:15:37 -0400 Subject: [PATCH 01/10] feat(security): allow webhook delivery to reuse a request-signing key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webhook verifier checklist step 8 now accepts adcp_use of 'webhook-signing' OR 'request-signing' — a signer may reuse the request-signing key it already publishes instead of minting a dedicated webhook key. Other purposes (response-signing, governance-signing) remain rejected with webhook_mode_mismatch; absent adcp_use with webhook_signature_key_purpose_invalid. Safe and one-directional: domain separation is carried by the RFC 9421 tag (adcp/webhook-signing/v1, step 3) and mandatory content-digest coverage, not by the key-purpose discriminator. The reverse (webhook key verifying a request) stays forbidden. A dedicated webhook key remains RECOMMENDED. Conformance vectors: former negative 008-wrong-adcp-use -> positive 008-request-signing-key-reuse; new negative 008 covers a response-signing key. Co-Authored-By: Claude Opus 4.8 --- ...webhook-allow-request-signing-key-reuse.md | 9 +++++++ docs/building/by-layer/L1/security.mdx | 8 +++---- .../test-vectors/webhook-signing/keys.json | 16 ++++++++++++- .../negative/008-wrong-adcp-use.json | 16 ++++++------- .../008-request-signing-key-reuse.json | 24 +++++++++++++++++++ 5 files changed, 60 insertions(+), 13 deletions(-) create mode 100644 .changeset/webhook-allow-request-signing-key-reuse.md create mode 100644 static/compliance/source/test-vectors/webhook-signing/positive/008-request-signing-key-reuse.json diff --git a/.changeset/webhook-allow-request-signing-key-reuse.md b/.changeset/webhook-allow-request-signing-key-reuse.md new file mode 100644 index 0000000000..16b4627a09 --- /dev/null +++ b/.changeset/webhook-allow-request-signing-key-reuse.md @@ -0,0 +1,9 @@ +--- +"adcontextprotocol": minor +--- + +Webhook delivery MAY reuse a signer's request-signing key. The webhook verifier checklist (step 8) now accepts a JWK whose `adcp_use` is either `"webhook-signing"` or `"request-signing"`; a dedicated webhook-signing key remains RECOMMENDED for blast-radius isolation but is no longer REQUIRED. Any other purpose (`"response-signing"`, `"governance-signing"`) is still rejected with `webhook_mode_mismatch`, and absent `adcp_use` with `webhook_signature_key_purpose_invalid`. + +The relaxation is one-directional and safe: cross-protocol confusion is prevented by the RFC 9421 `tag` (`adcp/webhook-signing/v1`, part of the signed base, checked at step 3) and mandatory `content-digest` coverage — not by the key-purpose discriminator. A captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3, so it can never be replayed as a webhook. The reverse remains forbidden: a webhook-signing key MUST NOT verify a request signature (request verification still requires `adcp_use == "request-signing"` exactly). + +Conformance vectors updated: former negative `webhook-signing/negative/008-wrong-adcp-use` (request-signing key rejected) becomes positive `webhook-signing/positive/008-request-signing-key-reuse` (accepted); a new negative `008-wrong-adcp-use` covers a `response-signing` key, still rejected. diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index 5e188b9392..68e992bff7 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -952,7 +952,7 @@ Reference implementations: `@adcp/sdk` (TypeScript) ships a `SigningProvider` in **Lifecycle: lazy init, not eager.** Calling `getPublicKey` (or any KMS warm-up call) before the process binds its listener looks clean in review but has a dangerous failure mode: if KMS auth is misconfigured, gRPC / TLS retries inside the KMS client can block indefinitely, the process never opens its port, and the infrastructure health-check times out — surfacing a "service unreachable" alarm rather than the underlying KMS error. The correct lifecycle is lazy init on first sign: call the store the first time a request needs signing, cache the result only on success (never cache errors), and deduplicate concurrent first-call requests with an in-flight promise. Fail-fast misconfig detection belongs in a CI/CD pre-deploy probe that exercises the KMS path with the deployment target's credentials before cutover — not at process startup. -**One JWK per `adcp_use` — publication shape.** The single-purpose rule applies to key material **and** to JWKS publication. An operator signing both AdCP requests and webhooks needs distinct key material and must publish two entries with the same JWK shape, distinct `x`, distinct `kid`, and distinct `adcp_use`. The value is a **string**, not an array — publishing `"adcp_use": ["request-signing","webhook-signing"]` on a single entry is a schema error that receivers will reject: +**One JWK per `adcp_use` — publication shape.** The single-purpose rule applies to key material **and** to JWKS publication, with one explicit exception: an operator MAY reuse its `"request-signing"` key to sign webhooks (see [Webhook callbacks](#webhook-callbacks), step 8), in which case it publishes a single `"request-signing"` entry and no separate webhook-signing entry. An operator that instead wants a dedicated webhook-signing key (RECOMMENDED for blast-radius isolation) needs distinct key material and publishes two entries with the same JWK shape, distinct `x`, distinct `kid`, and distinct `adcp_use`. The `adcp_use` value is always a **string**, not an array — publishing `"adcp_use": ["request-signing","webhook-signing"]` on a single entry is a schema error that receivers will reject: ```json { @@ -1423,7 +1423,7 @@ The buyer opts into the legacy HMAC-SHA256 scheme by populating `push_notificati **Mode selection is a switch, not both.** The presence of `push_notification_config.authentication` or `accounts[].notification_configs[].authentication` selects exactly one signing mode for every webhook delivered to that URL: `authentication` present → legacy HMAC-SHA256 (or Bearer); `authentication` absent → 9421. Sellers MUST NOT sign the same webhook both ways. Buyers MUST NOT attempt "try 9421 first, fall back to HMAC" verification — that pattern creates downgrade oracle behavior and accepts signatures the buyer did not ask for. Verifiers key the verification path strictly off whether the receiver has a configured HMAC secret for the webhook registration. -**Key publication.** Webhook-signing keys are published by the seller in its **own brand.json** `agents[]` entry at the signing agent's operator domain, at the `jwks_uri` member of that entry — the same publication pattern as any other AdCP agent key. An agent that signs both outgoing requests and outgoing webhooks publishes one JWKS with two distinct JWKs differentiated by `adcp_use`. Each webhook-signing JWK MUST declare: +**Key publication.** Webhook-signing keys are published by the seller in its **own brand.json** `agents[]` entry at the signing agent's operator domain, at the `jwks_uri` member of that entry — the same publication pattern as any other AdCP agent key. An agent that signs both outgoing requests and outgoing webhooks MAY publish one JWKS with two distinct JWKs differentiated by `adcp_use` (a dedicated webhook-signing key, RECOMMENDED for blast-radius isolation and independent rotation), OR reuse its existing `"request-signing"` key for webhooks and publish no separate webhook-signing entry. A dedicated webhook-signing JWK MUST declare: | Member | Value | |---|---| @@ -1433,7 +1433,7 @@ The buyer opts into the legacy HMAC-SHA256 scheme by populating `push_notificati | `kid` | distinct within the JWKS; MUST NOT collide with any other `kid` regardless of `adcp_use` | | `alg` | `"EdDSA"` or `"ES256"` | -Cross-purpose reuse is forbidden and locally enforceable: a request-signing key MUST NOT verify a webhook signature, and a webhook-signing key MUST NOT verify a request signature. Buyers verifying a webhook MUST reject any JWK whose `adcp_use` is not exactly `"webhook-signing"` with `webhook_signature_key_purpose_invalid`. +Webhook delivery is the one direction where key reuse is permitted: a buyer verifying a webhook MUST accept a JWK whose `adcp_use` is `"webhook-signing"` OR `"request-signing"`, and MUST reject any other purpose with `webhook_mode_mismatch` (and absent `adcp_use` with `webhook_signature_key_purpose_invalid`). The reverse is still forbidden — a `"webhook-signing"` key MUST NOT verify a request signature (request verification requires `adcp_use == "request-signing"` exactly), and neither `"response-signing"` nor `"governance-signing"` keys are ever valid for webhook delivery. The asymmetry is deliberate: request signing remains strict because it has no compensating `tag` relaxation need, whereas webhook delivery already carries domain separation in the `tag` (`adcp/webhook-signing/v1`) and mandatory `content-digest` coverage, so the key-purpose check adds no confusion resistance there. A signer that reuses its request-signing key accepts that a request-signing-key compromise also compromises its webhooks; a dedicated webhook-signing key preserves that isolation and remains RECOMMENDED. **Trust anchor and blast radius.** The trust anchor for webhook authenticity is **the signer's brand.json origin** — the HTTPS origin that hosts the brand.json declaring the signing agent's `agents[]` entry. A compromise of that origin (sub-path takeover, DNS hijack, CDN cache poisoning of `/.well-known/brand.json` or the `jwks_uri`) compromises every webhook that buyer accepts from that signer until the operator publishes a `revoked_kids` entry and buyer verifiers refresh the revocation list. Buyers SHOULD pin the agent's `jwks_uri` URL learned at integration onboarding and alarm on changes to the URL itself (not just on `kid` rotation within a stable URL) — changes to the URL force re-anchoring and SHOULD require operator attention, not silent adoption. `kid` collisions across `adcp_use` values within the same JWKS are forbidden specifically so a request-signing-key compromise cannot be repurposed as a webhook-signing capability. @@ -1471,7 +1471,7 @@ Buyers MUST NOT derive signer identity from webhook payload fields (`task_id`, ` 5. Reject if `expires ≤ created`, `created > now + 60 s`, `expires < now − 60 s`, or `expires − created > 300 s` (`webhook_signature_window_invalid`). 6. Reject if covered components do not include ALL of: `@method`, `@target-uri`, `@authority`, `content-type`, `content-digest` (`webhook_signature_components_incomplete`). `content-digest` is REQUIRED; there is no policy branch. 7. Resolve `keyid` to a JWK via the JWKS discovery steps above. On `kid` miss, refetch once (30-second cooldown between refetches) before rejecting (`webhook_signature_key_unknown`). Reject if `keyid` cannot be resolved to a specific `agents[]` entry in the signer's brand.json. -8. Verify the JWK's `use` is `"sig"`, `key_ops` includes `"verify"`, and `adcp_use` equals `"webhook-signing"`. Reject on any mismatch, including absent `adcp_use` (`webhook_signature_key_purpose_invalid`). +8. Verify the JWK's `use` is `"sig"`, `key_ops` includes `"verify"`, and `adcp_use` is either `"webhook-signing"` or `"request-signing"` — a signer MAY reuse its request-signing key to sign webhooks, or publish a dedicated webhook-signing key (the signer's choice; see [Key publication](#webhook-callbacks)). Reject absent `adcp_use` or a missing `verify` key_op with `webhook_signature_key_purpose_invalid`. Reject any other `adcp_use` value (e.g. `"response-signing"`, `"governance-signing"`) with `webhook_mode_mismatch` — those purposes are not valid for webhook delivery. This relaxation is safe because cross-protocol confusion is prevented by the `tag` (step 3) and mandatory `content-digest` coverage (step 6), not by the key-purpose discriminator: a captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3. 9. Check the [Transport revocation](#transport-revocation) list (reused across signing purposes). Reject if `keyid ∈ revoked_kids` (`webhook_signature_key_revoked`). Reject with `webhook_signature_revocation_stale` if the verifier has not refreshed within grace. **9a. Per-keyid cap check.** Check the [webhook replay-cache cap](#webhook-replay-dedup-sizing). Reject with `webhook_signature_rate_abuse` if exceeded. Runs before cryptographic verify (step 10) for the same cheap-rejection rationale as request signing. diff --git a/static/compliance/source/test-vectors/webhook-signing/keys.json b/static/compliance/source/test-vectors/webhook-signing/keys.json index 3808815160..d4e460a70a 100644 --- a/static/compliance/source/test-vectors/webhook-signing/keys.json +++ b/static/compliance/source/test-vectors/webhook-signing/keys.json @@ -30,7 +30,7 @@ "_private_d_for_test_only": "xgSwq4Iq3RS8MFP2oXsDP--8uZLoq6Idip2PNM6pCZ4" }, { - "$comment": "Request-signing key (adcp_use='request-signing'). Included so negative vector 008 can demonstrate cross-purpose rejection: the vector presents this key when verifying a webhook signature, and the verifier MUST reject at step 8 because adcp_use is not 'webhook-signing'.", + "$comment": "Request-signing key (adcp_use='request-signing'). Used by positive vector 008-request-signing-key-reuse: the vector presents this key when verifying a webhook signature, and the verifier MUST accept it at step 8 because a signer may reuse its request-signing key for webhook delivery (domain separation is carried by the tag, not the key purpose).", "kid": "test-wrong-purpose-2026", "kty": "OKP", "crv": "Ed25519", @@ -43,6 +43,20 @@ "x": "VgpQd9JRrBf433BcMw6IUNW7tHnAAHAHegsQ5U9I53c", "_private_d_for_test_only": "MdgOQLf-gC6G-vq2GGlDwbNE1Q40FY6SQ20yd3s33Cg" }, + { + "$comment": "Response-signing key (adcp_use='response-signing'). Used by negative vector 008-wrong-adcp-use: presented when verifying a webhook signature, the verifier MUST reject at step 8 with webhook_mode_mismatch because response-signing is neither 'webhook-signing' nor 'request-signing'. Preserves cross-purpose rejection coverage for genuinely non-webhook purposes.", + "kid": "test-response-purpose-2026", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "response-signing", + "x": "aCo2Y5TsWrbE0gpueDK09ScOejqoOCl98QIDqywEAB4", + "_private_d_for_test_only": "hS8wfl8JNYl4QEbTRaX_5GtHelcLMEWx5pOTBqvTFdo" + }, { "$comment": "Revoked webhook-signing key. Dedicated keypair for negative vector 017-key-revoked. adcp_use is 'webhook-signing' so that the verifier's purpose check (step 8) passes and the revocation check (step 9) fires. Runners pre-configure their revocation list to include this keyid before the negative phase runs; see test-kits/webhook-receiver-runner.yaml.", "kid": "test-revoked-webhook-2026", diff --git a/static/compliance/source/test-vectors/webhook-signing/negative/008-wrong-adcp-use.json b/static/compliance/source/test-vectors/webhook-signing/negative/008-wrong-adcp-use.json index d9891589b9..2f96c50a0a 100644 --- a/static/compliance/source/test-vectors/webhook-signing/negative/008-wrong-adcp-use.json +++ b/static/compliance/source/test-vectors/webhook-signing/negative/008-wrong-adcp-use.json @@ -1,6 +1,6 @@ { - "name": "Signing key has adcp_use=request-signing instead of webhook-signing", - "spec_reference": "#webhook-callbacks (key_purpose_invalid at step 8)", + "name": "Signing key has adcp_use=response-signing instead of a webhook-valid purpose", + "spec_reference": "#webhook-callbacks (mode_mismatch at step 8; adcp#2467 split)", "reference_now": 1776520800, "request": { "method": "POST", @@ -8,19 +8,19 @@ "headers": { "Content-Type": "application/json", "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", - "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-wrong-purpose-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", - "Signature": "sig1=:OT_F-yQsYJzp4h1LAigin35vbEOWWuMgOKSHLhzCQHVsTBg5Mx72F5xboy4HiETvUuMiWz8MrW55wSgrjkjwCw:" + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-response-purpose-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:lgEK6AmqBmaLCzOifmcYl1QChTadLemvcXWrQzrHdkmjpC_iMEk68E_-LCw3ojL75-yD6WNCdmVeE78kgMbaCg:" }, "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" }, "jwks_ref": [ - "test-wrong-purpose-2026" + "test-response-purpose-2026" ], - "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-wrong-purpose-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-response-purpose-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", "expected_outcome": { "success": false, - "error_code": "webhook_signature_key_purpose_invalid", + "error_code": "webhook_mode_mismatch", "failed_step": 8 }, - "$comment": "Signature generated by .context/generate-webhook-vectors.mjs from expected_signature_base using the named private key in keys.json. Ed25519 is deterministic; ES256 uses IEEE P1363 (r||s) encoding per RFC 9421 §3.3.2." + "$comment": "Cross-purpose rejection: webhook delivery accepts adcp_use of 'webhook-signing' or 'request-signing' only. A 'response-signing' key MUST be rejected at step 8 with webhook_mode_mismatch. Signature generated from expected_signature_base using test-response-purpose-2026's private key in keys.json. Ed25519 is deterministic." } diff --git a/static/compliance/source/test-vectors/webhook-signing/positive/008-request-signing-key-reuse.json b/static/compliance/source/test-vectors/webhook-signing/positive/008-request-signing-key-reuse.json new file mode 100644 index 0000000000..e1dd873e3a --- /dev/null +++ b/static/compliance/source/test-vectors/webhook-signing/positive/008-request-signing-key-reuse.json @@ -0,0 +1,24 @@ +{ + "name": "Signing key has adcp_use=request-signing — accepted as webhook reuse", + "spec_reference": "#webhook-callbacks (key purpose accepts request-signing reuse at step 8)", + "reference_now": 1776520800, + "request": { + "method": "POST", + "url": "https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc", + "headers": { + "Content-Type": "application/json", + "Content-Digest": "sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:", + "Signature-Input": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-wrong-purpose-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "Signature": "sig1=:OT_F-yQsYJzp4h1LAigin35vbEOWWuMgOKSHLhzCQHVsTBg5Mx72F5xboy4HiETvUuMiWz8MrW55wSgrjkjwCw:" + }, + "body": "{\"idempotency_key\":\"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B\",\"task_id\":\"task_456\",\"operation_id\":\"op_abc\",\"status\":\"completed\",\"result\":{\"media_buy_id\":\"mb_001\"}}" + }, + "jwks_ref": [ + "test-wrong-purpose-2026" + ], + "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-wrong-purpose-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", + "expected_outcome": { + "success": true + }, + "$comment": "Formerly negative vector 008-wrong-adcp-use. A signer MAY reuse its request-signing key to sign webhooks; the verifier MUST accept adcp_use=request-signing at step 8 because domain separation is carried by the tag (adcp/webhook-signing/v1) and mandatory content-digest coverage, not by the key-purpose discriminator. Signature is the original valid Ed25519 signature over expected_signature_base; Ed25519 is deterministic." +} From c737525ae8c44f00aad8146a15c8012ea2394219 Mon Sep 17 00:00:00 2001 From: BaiyuScope3 Date: Mon, 15 Jun 2026 14:22:05 -0400 Subject: [PATCH 02/10] fix(security): make webhook key-purpose error code consistent Per reviewer feedback: resolve the self-contradiction. Step 8 uses webhook_signature_key_purpose_invalid for ALL key-purpose failures (absent adcp_use, missing verify key_op, or adcp_use outside {webhook-signing, request-signing}); webhook_mode_mismatch is left reserved for the HMAC-vs-9421 auth-mode selector. Fixes the taxonomy table row, the blast-radius kid-collision sentence (reuse is now intended; dedicated key is the isolation mechanism), the negative 008 vector error_code, and the conformance README (008 split + new response-signing key). Co-Authored-By: Claude Opus 4.8 --- .../webhook-allow-request-signing-key-reuse.md | 2 +- docs/building/by-layer/L1/security.mdx | 6 +++--- .../source/test-vectors/webhook-signing/README.md | 14 ++++++++------ .../source/test-vectors/webhook-signing/keys.json | 2 +- .../negative/008-wrong-adcp-use.json | 6 +++--- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.changeset/webhook-allow-request-signing-key-reuse.md b/.changeset/webhook-allow-request-signing-key-reuse.md index 16b4627a09..c0b95fd791 100644 --- a/.changeset/webhook-allow-request-signing-key-reuse.md +++ b/.changeset/webhook-allow-request-signing-key-reuse.md @@ -2,7 +2,7 @@ "adcontextprotocol": minor --- -Webhook delivery MAY reuse a signer's request-signing key. The webhook verifier checklist (step 8) now accepts a JWK whose `adcp_use` is either `"webhook-signing"` or `"request-signing"`; a dedicated webhook-signing key remains RECOMMENDED for blast-radius isolation but is no longer REQUIRED. Any other purpose (`"response-signing"`, `"governance-signing"`) is still rejected with `webhook_mode_mismatch`, and absent `adcp_use` with `webhook_signature_key_purpose_invalid`. +Webhook delivery MAY reuse a signer's request-signing key. The webhook verifier checklist (step 8) now accepts a JWK whose `adcp_use` is either `"webhook-signing"` or `"request-signing"`; a dedicated webhook-signing key remains RECOMMENDED for blast-radius isolation but is no longer REQUIRED. Any other key-purpose failure — `"response-signing"`/`"governance-signing"`, absent `adcp_use`, or a missing `verify` key_op — is rejected with `webhook_signature_key_purpose_invalid`. `webhook_mode_mismatch` is unchanged and remains reserved for the HMAC-vs-9421 auth-mode selector mismatch. The relaxation is one-directional and safe: cross-protocol confusion is prevented by the RFC 9421 `tag` (`adcp/webhook-signing/v1`, part of the signed base, checked at step 3) and mandatory `content-digest` coverage — not by the key-purpose discriminator. A captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3, so it can never be replayed as a webhook. The reverse remains forbidden: a webhook-signing key MUST NOT verify a request signature (request verification still requires `adcp_use == "request-signing"` exactly). diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index 68e992bff7..e4dfd04a27 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -1435,7 +1435,7 @@ The buyer opts into the legacy HMAC-SHA256 scheme by populating `push_notificati Webhook delivery is the one direction where key reuse is permitted: a buyer verifying a webhook MUST accept a JWK whose `adcp_use` is `"webhook-signing"` OR `"request-signing"`, and MUST reject any other purpose with `webhook_mode_mismatch` (and absent `adcp_use` with `webhook_signature_key_purpose_invalid`). The reverse is still forbidden — a `"webhook-signing"` key MUST NOT verify a request signature (request verification requires `adcp_use == "request-signing"` exactly), and neither `"response-signing"` nor `"governance-signing"` keys are ever valid for webhook delivery. The asymmetry is deliberate: request signing remains strict because it has no compensating `tag` relaxation need, whereas webhook delivery already carries domain separation in the `tag` (`adcp/webhook-signing/v1`) and mandatory `content-digest` coverage, so the key-purpose check adds no confusion resistance there. A signer that reuses its request-signing key accepts that a request-signing-key compromise also compromises its webhooks; a dedicated webhook-signing key preserves that isolation and remains RECOMMENDED. -**Trust anchor and blast radius.** The trust anchor for webhook authenticity is **the signer's brand.json origin** — the HTTPS origin that hosts the brand.json declaring the signing agent's `agents[]` entry. A compromise of that origin (sub-path takeover, DNS hijack, CDN cache poisoning of `/.well-known/brand.json` or the `jwks_uri`) compromises every webhook that buyer accepts from that signer until the operator publishes a `revoked_kids` entry and buyer verifiers refresh the revocation list. Buyers SHOULD pin the agent's `jwks_uri` URL learned at integration onboarding and alarm on changes to the URL itself (not just on `kid` rotation within a stable URL) — changes to the URL force re-anchoring and SHOULD require operator attention, not silent adoption. `kid` collisions across `adcp_use` values within the same JWKS are forbidden specifically so a request-signing-key compromise cannot be repurposed as a webhook-signing capability. +**Trust anchor and blast radius.** The trust anchor for webhook authenticity is **the signer's brand.json origin** — the HTTPS origin that hosts the brand.json declaring the signing agent's `agents[]` entry. A compromise of that origin (sub-path takeover, DNS hijack, CDN cache poisoning of `/.well-known/brand.json` or the `jwks_uri`) compromises every webhook that buyer accepts from that signer until the operator publishes a `revoked_kids` entry and buyer verifiers refresh the revocation list. Buyers SHOULD pin the agent's `jwks_uri` URL learned at integration onboarding and alarm on changes to the URL itself (not just on `kid` rotation within a stable URL) — changes to the URL force re-anchoring and SHOULD require operator attention, not silent adoption. `kid` collisions across `adcp_use` values within the same JWKS are forbidden so each `kid` carries exactly one declared purpose. Note that a signer MAY deliberately reuse its `request-signing` key for webhook delivery (see step 8) — in that case a request-signing-key compromise does extend to webhooks. Publishing a dedicated `webhook-signing` key is the isolation mechanism that prevents that, and is RECOMMENDED where blast-radius separation matters. **Covered components** are identical to request signing: `@method`, `@target-uri`, `@authority`, `content-type`, and `content-digest`. `content-digest` is REQUIRED on webhook callbacks — the body carries the event, and webhook receivers are buyer-controlled endpoints where body preservation is the buyer's own infrastructure problem. There is no `covers_content_digest: "forbidden"` opt-out for webhooks; transports that cannot preserve webhook body bytes MUST be fixed. @@ -1471,7 +1471,7 @@ Buyers MUST NOT derive signer identity from webhook payload fields (`task_id`, ` 5. Reject if `expires ≤ created`, `created > now + 60 s`, `expires < now − 60 s`, or `expires − created > 300 s` (`webhook_signature_window_invalid`). 6. Reject if covered components do not include ALL of: `@method`, `@target-uri`, `@authority`, `content-type`, `content-digest` (`webhook_signature_components_incomplete`). `content-digest` is REQUIRED; there is no policy branch. 7. Resolve `keyid` to a JWK via the JWKS discovery steps above. On `kid` miss, refetch once (30-second cooldown between refetches) before rejecting (`webhook_signature_key_unknown`). Reject if `keyid` cannot be resolved to a specific `agents[]` entry in the signer's brand.json. -8. Verify the JWK's `use` is `"sig"`, `key_ops` includes `"verify"`, and `adcp_use` is either `"webhook-signing"` or `"request-signing"` — a signer MAY reuse its request-signing key to sign webhooks, or publish a dedicated webhook-signing key (the signer's choice; see [Key publication](#webhook-callbacks)). Reject absent `adcp_use` or a missing `verify` key_op with `webhook_signature_key_purpose_invalid`. Reject any other `adcp_use` value (e.g. `"response-signing"`, `"governance-signing"`) with `webhook_mode_mismatch` — those purposes are not valid for webhook delivery. This relaxation is safe because cross-protocol confusion is prevented by the `tag` (step 3) and mandatory `content-digest` coverage (step 6), not by the key-purpose discriminator: a captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3. +8. Verify the JWK's `use` is `"sig"`, `key_ops` includes `"verify"`, and `adcp_use` is either `"webhook-signing"` or `"request-signing"` — a signer MAY reuse its request-signing key to sign webhooks, or publish a dedicated webhook-signing key (the signer's choice; see [Key publication](#webhook-callbacks)). Reject on any other outcome with `webhook_signature_key_purpose_invalid`: absent `adcp_use`, a missing `verify` key_op, or any `adcp_use` value outside that set (e.g. `"response-signing"`, `"governance-signing"`). This relaxation is safe because cross-protocol confusion is prevented by the `tag` (step 3) and mandatory `content-digest` coverage (step 6), not by the key-purpose discriminator: a captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3. (`webhook_mode_mismatch` is reserved for the HMAC-vs-9421 auth-mode selector mismatch — see [Downgrade and injection resistance](#webhook-callbacks) — and is NOT used for key-purpose failures.) 9. Check the [Transport revocation](#transport-revocation) list (reused across signing purposes). Reject if `keyid ∈ revoked_kids` (`webhook_signature_key_revoked`). Reject with `webhook_signature_revocation_stale` if the verifier has not refreshed within grace. **9a. Per-keyid cap check.** Check the [webhook replay-cache cap](#webhook-replay-dedup-sizing). Reject with `webhook_signature_rate_abuse` if exceeded. Runs before cryptographic verify (step 10) for the same cheap-rejection rationale as request signing. @@ -1553,7 +1553,7 @@ Codes parallel the [request-signing error taxonomy](#transport-error-taxonomy), | Signature window invalid | `webhook_signature_window_invalid` | | Required covered components missing (including `content-digest`) | `webhook_signature_components_incomplete` | | `keyid` not in seller JWKS after one refetch | `webhook_signature_key_unknown` | -| JWK `adcp_use` ≠ `webhook-signing` | `webhook_signature_key_purpose_invalid` | +| JWK `adcp_use` ∉ {`webhook-signing`, `request-signing`}, absent, or `key_ops` lacks `verify` | `webhook_signature_key_purpose_invalid` | | `keyid` ∈ `revoked_kids` | `webhook_signature_key_revoked` | | Revocation list not refreshed within grace | `webhook_signature_revocation_stale` | | Cryptographic verification failed | `webhook_signature_invalid` | diff --git a/static/compliance/source/test-vectors/webhook-signing/README.md b/static/compliance/source/test-vectors/webhook-signing/README.md index d9b836e5ef..c614c283e0 100644 --- a/static/compliance/source/test-vectors/webhook-signing/README.md +++ b/static/compliance/source/test-vectors/webhook-signing/README.md @@ -8,7 +8,7 @@ Specification: [Webhook callbacks](https://adcontextprotocol.org/docs/building/b ## ⚠️ Security — test keys are public -`keys.json` publishes the full private key material for every test keypair in the `_private_d_for_test_only` field so SDKs can exercise both signer and verifier roles against the same material. **Any production verifier that adds `test-ed25519-webhook-2026`, `test-es256-webhook-2026`, `test-wrong-purpose-2026`, or `test-revoked-webhook-2026` to its trust store is exploitable** — anyone who downloads `keys.json` can forge signatures under those kids. These keys are valid ONLY for grading against this conformance suite. Production signers MUST mint and publish their own keypairs under their own `jwks_uri`; production verifiers MUST NOT treat the test kids as trusted in any deployment exposed to live traffic. +`keys.json` publishes the full private key material for every test keypair in the `_private_d_for_test_only` field so SDKs can exercise both signer and verifier roles against the same material. **Any production verifier that adds `test-ed25519-webhook-2026`, `test-es256-webhook-2026`, `test-wrong-purpose-2026`, `test-response-purpose-2026`, or `test-revoked-webhook-2026` to its trust store is exploitable** — anyone who downloads `keys.json` can forge signatures under those kids. These keys are valid ONLY for grading against this conformance suite. Production signers MUST mint and publish their own keypairs under their own `jwks_uri`; production verifiers MUST NOT treat the test kids as trusted in any deployment exposed to live traffic. ## Scope @@ -32,8 +32,9 @@ The distinct surface is the purpose-discriminator chain: `adcp_use` MUST be `"we test-vectors/webhook-signing/ ├── README.md this file ├── keys.json test keypairs (Ed25519 + ES256) with adcp_use: "webhook-signing", -│ plus a wrong-purpose key (adcp_use: "request-signing") for vector 008 -│ and a revoked key for vector 017 +│ plus a request-signing key reused by positive vector +│ 008-request-signing-key-reuse, a response-signing key for negative +│ vector 008-wrong-adcp-use, and a revoked key for vector 017 ├── negative/ vectors that MUST fail verification │ ├── 001-wrong-tag.json → webhook_signature_tag_invalid (step 3; uses request-signing tag) │ ├── 002-expired-signature.json → webhook_signature_window_invalid (step 5; expired) @@ -42,7 +43,7 @@ test-vectors/webhook-signing/ │ ├── 005-missing-authority-component.json → webhook_signature_components_incomplete (step 6; @authority missing) │ ├── 006-missing-content-digest.json → webhook_signature_components_incomplete (step 6; REQUIRED on webhooks) │ ├── 007-unknown-keyid.json → webhook_signature_key_unknown (step 7) -│ ├── 008-wrong-adcp-use.json → webhook_signature_key_purpose_invalid (step 8; adcp_use=request-signing) +│ ├── 008-wrong-adcp-use.json → webhook_signature_key_purpose_invalid (step 8; adcp_use=response-signing — request-signing is now accepted, see positive/008) │ ├── 009-content-digest-mismatch.json → webhook_signature_digest_mismatch (step 11) │ ├── 010-malformed-signature-input.json → webhook_signature_header_malformed (step 1) │ ├── 011-signature-without-input.json → webhook_signature_header_malformed (step 1; bound pair broken, one header without the other) @@ -63,7 +64,8 @@ test-vectors/webhook-signing/ ├── 004-default-port-stripped.json URL has :443; canonical strips it before signing ├── 005-percent-encoded-path.json Path has lowercase %xx; canonical uppercases ├── 006-query-byte-preserved.json Query b=2&a=1&c=3 — preserved byte-for-byte, not alphabetized - └── 007-body-without-idempotency-key.json Body omits idempotency_key; signature still verifies (schema vs. signature separation) + ├── 007-body-without-idempotency-key.json Body omits idempotency_key; signature still verifies (schema vs. signature separation) + └── 008-request-signing-key-reuse.json adcp_use=request-signing key accepted for webhook delivery (signer reusing its request key; step 8) ``` ## Vector shape @@ -102,7 +104,7 @@ Fixed Unix-seconds timestamp representing "now" at vector construction time (202 ### `jwks_ref` -Array of `kid` values the vector expects in the signer JWKS. Verifiers load `keys.json`, filter to the listed `kid`s, and present that subset to their verifier under test. Not all keys in `keys.json` are in every vector's JWKS — for example, vector 008 references only `test-wrong-purpose-2026`, which causes step 8 to reject. +Array of `kid` values the vector expects in the signer JWKS. Verifiers load `keys.json`, filter to the listed `kid`s, and present that subset to their verifier under test. Not all keys in `keys.json` are in every vector's JWKS — for example, negative vector 008 references only `test-response-purpose-2026`, which causes step 8 to reject, while positive vector `008-request-signing-key-reuse` references `test-wrong-purpose-2026` (a request-signing key), which step 8 now accepts. ### `expected_signature_base` diff --git a/static/compliance/source/test-vectors/webhook-signing/keys.json b/static/compliance/source/test-vectors/webhook-signing/keys.json index d4e460a70a..001ccce270 100644 --- a/static/compliance/source/test-vectors/webhook-signing/keys.json +++ b/static/compliance/source/test-vectors/webhook-signing/keys.json @@ -44,7 +44,7 @@ "_private_d_for_test_only": "MdgOQLf-gC6G-vq2GGlDwbNE1Q40FY6SQ20yd3s33Cg" }, { - "$comment": "Response-signing key (adcp_use='response-signing'). Used by negative vector 008-wrong-adcp-use: presented when verifying a webhook signature, the verifier MUST reject at step 8 with webhook_mode_mismatch because response-signing is neither 'webhook-signing' nor 'request-signing'. Preserves cross-purpose rejection coverage for genuinely non-webhook purposes.", + "$comment": "Response-signing key (adcp_use='response-signing'). Used by negative vector 008-wrong-adcp-use: presented when verifying a webhook signature, the verifier MUST reject at step 8 with webhook_signature_key_purpose_invalid because response-signing is neither 'webhook-signing' nor 'request-signing' (webhook_mode_mismatch is reserved for the HMAC-vs-9421 auth-mode selector). Preserves cross-purpose rejection coverage for genuinely non-webhook purposes.", "kid": "test-response-purpose-2026", "kty": "OKP", "crv": "Ed25519", diff --git a/static/compliance/source/test-vectors/webhook-signing/negative/008-wrong-adcp-use.json b/static/compliance/source/test-vectors/webhook-signing/negative/008-wrong-adcp-use.json index 2f96c50a0a..d87b942570 100644 --- a/static/compliance/source/test-vectors/webhook-signing/negative/008-wrong-adcp-use.json +++ b/static/compliance/source/test-vectors/webhook-signing/negative/008-wrong-adcp-use.json @@ -1,6 +1,6 @@ { "name": "Signing key has adcp_use=response-signing instead of a webhook-valid purpose", - "spec_reference": "#webhook-callbacks (mode_mismatch at step 8; adcp#2467 split)", + "spec_reference": "#webhook-callbacks (key_purpose_invalid at step 8)", "reference_now": 1776520800, "request": { "method": "POST", @@ -19,8 +19,8 @@ "expected_signature_base": "\"@method\": POST\n\"@target-uri\": https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc\n\"@authority\": buyer.example.com\n\"content-type\": application/json\n\"content-digest\": sha-256=:dJ2koiIMZIhdGE7tidErCHV13FFvOIowCcXDiwyG54I=:\n\"@signature-params\": (\"@method\" \"@target-uri\" \"@authority\" \"content-type\" \"content-digest\");created=1776520800;expires=1776521100;nonce=\"KXYnfEfJ0PBRZXQyVXfVQA\";keyid=\"test-response-purpose-2026\";alg=\"ed25519\";tag=\"adcp/webhook-signing/v1\"", "expected_outcome": { "success": false, - "error_code": "webhook_mode_mismatch", + "error_code": "webhook_signature_key_purpose_invalid", "failed_step": 8 }, - "$comment": "Cross-purpose rejection: webhook delivery accepts adcp_use of 'webhook-signing' or 'request-signing' only. A 'response-signing' key MUST be rejected at step 8 with webhook_mode_mismatch. Signature generated from expected_signature_base using test-response-purpose-2026's private key in keys.json. Ed25519 is deterministic." + "$comment": "Cross-purpose rejection: webhook delivery accepts adcp_use of 'webhook-signing' or 'request-signing' only. A 'response-signing' key MUST be rejected at step 8 with webhook_signature_key_purpose_invalid (the same code used for absent adcp_use or a missing verify key_op; webhook_mode_mismatch is reserved for the HMAC-vs-9421 auth-mode selector). Signature generated from expected_signature_base using test-response-purpose-2026's private key in keys.json. Ed25519 is deterministic." } From 7f3c54af5911a96893e7dacfad6a46d8d1293a2b Mon Sep 17 00:00:00 2001 From: BaiyuScope3 Date: Mon, 15 Jun 2026 14:43:21 -0400 Subject: [PATCH 03/10] refactor(security): webhooks use the request-signing key; deprecate webhook-signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe to the unified model: webhooks are signed with the agent's request-signing key (differentiated from requests by the RFC 9421 tag), with no separate webhook key purpose. Step 8 requires adcp_use == request-signing; the deprecated webhook-signing value is still accepted for backward compatibility (removed in 4.0). Key isolation, when wanted, is a second request-signing key under a distinct kid — not a distinct adcp_use. Rewrites the webhook key-publication section, the publication-shape note, the blast-radius sentence, the adcp_use enum row, and the conformance README. Co-Authored-By: Claude Opus 4.8 --- .../webhook-allow-request-signing-key-reuse.md | 2 +- docs/building/by-layer/L1/security.mdx | 18 +++++++++++------- .../test-vectors/webhook-signing/README.md | 2 ++ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.changeset/webhook-allow-request-signing-key-reuse.md b/.changeset/webhook-allow-request-signing-key-reuse.md index c0b95fd791..b1fa1dcb31 100644 --- a/.changeset/webhook-allow-request-signing-key-reuse.md +++ b/.changeset/webhook-allow-request-signing-key-reuse.md @@ -2,7 +2,7 @@ "adcontextprotocol": minor --- -Webhook delivery MAY reuse a signer's request-signing key. The webhook verifier checklist (step 8) now accepts a JWK whose `adcp_use` is either `"webhook-signing"` or `"request-signing"`; a dedicated webhook-signing key remains RECOMMENDED for blast-radius isolation but is no longer REQUIRED. Any other key-purpose failure — `"response-signing"`/`"governance-signing"`, absent `adcp_use`, or a missing `verify` key_op — is rejected with `webhook_signature_key_purpose_invalid`. `webhook_mode_mismatch` is unchanged and remains reserved for the HMAC-vs-9421 auth-mode selector mismatch. +Webhooks are signed with the agent's `request-signing` key — there is no separate webhook key purpose. The webhook verifier checklist (step 8) now requires `adcp_use == "request-signing"` (with the deprecated `"webhook-signing"` still accepted for backward compatibility, removed in 4.0). Operators that want separate key material for webhooks publish a second `"request-signing"` key with a distinct `kid` and sign webhooks with it — key isolation comes from the `kid`, not a distinct `adcp_use`. Any other key-purpose failure — `"response-signing"`/`"governance-signing"`, absent `adcp_use`, or a missing `verify` key_op — is rejected with `webhook_signature_key_purpose_invalid`. `webhook_mode_mismatch` is unchanged and remains reserved for the HMAC-vs-9421 auth-mode selector mismatch. The relaxation is one-directional and safe: cross-protocol confusion is prevented by the RFC 9421 `tag` (`adcp/webhook-signing/v1`, part of the signed base, checked at step 3) and mandatory `content-digest` coverage — not by the key-purpose discriminator. A captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3, so it can never be replayed as a webhook. The reverse remains forbidden: a webhook-signing key MUST NOT verify a request signature (request verification still requires `adcp_use == "request-signing"` exactly). diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index e4dfd04a27..f98725d322 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -952,7 +952,7 @@ Reference implementations: `@adcp/sdk` (TypeScript) ships a `SigningProvider` in **Lifecycle: lazy init, not eager.** Calling `getPublicKey` (or any KMS warm-up call) before the process binds its listener looks clean in review but has a dangerous failure mode: if KMS auth is misconfigured, gRPC / TLS retries inside the KMS client can block indefinitely, the process never opens its port, and the infrastructure health-check times out — surfacing a "service unreachable" alarm rather than the underlying KMS error. The correct lifecycle is lazy init on first sign: call the store the first time a request needs signing, cache the result only on success (never cache errors), and deduplicate concurrent first-call requests with an in-flight promise. Fail-fast misconfig detection belongs in a CI/CD pre-deploy probe that exercises the KMS path with the deployment target's credentials before cutover — not at process startup. -**One JWK per `adcp_use` — publication shape.** The single-purpose rule applies to key material **and** to JWKS publication, with one explicit exception: an operator MAY reuse its `"request-signing"` key to sign webhooks (see [Webhook callbacks](#webhook-callbacks), step 8), in which case it publishes a single `"request-signing"` entry and no separate webhook-signing entry. An operator that instead wants a dedicated webhook-signing key (RECOMMENDED for blast-radius isolation) needs distinct key material and publishes two entries with the same JWK shape, distinct `x`, distinct `kid`, and distinct `adcp_use`. The `adcp_use` value is always a **string**, not an array — publishing `"adcp_use": ["request-signing","webhook-signing"]` on a single entry is a schema error that receivers will reject: +**One JWK per `adcp_use` — publication shape.** The single-purpose rule applies to key material **and** to JWKS publication. Note that webhooks do not need their own purpose: they are signed with a `"request-signing"` key (see [Webhook callbacks](#webhook-callbacks), step 8), so an operator that signs both requests and webhooks with the same key publishes a single `"request-signing"` entry. An operator that wants separate key material for webhooks (blast-radius isolation) publishes a **second `"request-signing"` key with a distinct `kid`** — isolation comes from the `kid`, not a distinct `adcp_use`. The `adcp_use` value is always a **string**, not an array — publishing `"adcp_use": ["request-signing","webhook-signing"]` on a single entry is a schema error that receivers will reject: ```json { @@ -1070,7 +1070,7 @@ Each request-signing JWK entry MUST declare: |---|---|---| | `use` | `"sig"` | Standard JWK signing use. | | `key_ops` | `["verify"]` | Verifier-visible JWKS declares verify-only. The signing operator holds the corresponding private key locally with `["sign"]` per JWK spec. | -| `adcp_use` | `"request-signing"` | AdCP-specific purpose discriminator. Distinguishes from `"governance-signing"` (JWS profile), `"webhook-signing"` (seller→buyer webhook callbacks), and any future AdCP signing purpose. Verifiers MUST reject any JWK with absent or different `adcp_use` when verifying a request signature. Sellers that also sign webhooks publish a separate `"webhook-signing"` key in their operator JWKS — see [Webhook callbacks](#webhook-callbacks). | +| `adcp_use` | `"request-signing"` | AdCP-specific purpose discriminator. Distinguishes from `"governance-signing"` (JWS profile) and any future AdCP signing purpose. Verifiers MUST reject any JWK with absent or different `adcp_use` when verifying a request signature. The same `"request-signing"` key (or a second one under a distinct `kid`) also signs outbound webhooks — see [Webhook callbacks](#webhook-callbacks). (`"webhook-signing"` is a deprecated purpose, removed in 4.0; still accepted on the webhook path for backward compatibility.) | | `kid` | distinct | Unique within the JWKS. MUST NOT collide with any other entry's `kid` regardless of `adcp_use`. | | `alg` | `"EdDSA"` or `"ES256"` | Must match the signature's `alg` parameter (JWK `alg` uses JWS names; `alg` in `Signature-Input` uses RFC 9421 names). | @@ -1423,19 +1423,23 @@ The buyer opts into the legacy HMAC-SHA256 scheme by populating `push_notificati **Mode selection is a switch, not both.** The presence of `push_notification_config.authentication` or `accounts[].notification_configs[].authentication` selects exactly one signing mode for every webhook delivered to that URL: `authentication` present → legacy HMAC-SHA256 (or Bearer); `authentication` absent → 9421. Sellers MUST NOT sign the same webhook both ways. Buyers MUST NOT attempt "try 9421 first, fall back to HMAC" verification — that pattern creates downgrade oracle behavior and accepts signatures the buyer did not ask for. Verifiers key the verification path strictly off whether the receiver has a configured HMAC secret for the webhook registration. -**Key publication.** Webhook-signing keys are published by the seller in its **own brand.json** `agents[]` entry at the signing agent's operator domain, at the `jwks_uri` member of that entry — the same publication pattern as any other AdCP agent key. An agent that signs both outgoing requests and outgoing webhooks MAY publish one JWKS with two distinct JWKs differentiated by `adcp_use` (a dedicated webhook-signing key, RECOMMENDED for blast-radius isolation and independent rotation), OR reuse its existing `"request-signing"` key for webhooks and publish no separate webhook-signing entry. A dedicated webhook-signing JWK MUST declare: +**Key publication.** Signing keys are published by the seller in its **own brand.json** `agents[]` entry at the signing agent's operator domain, at the `jwks_uri` member of that entry — the same publication pattern as any other AdCP agent key. Webhooks are signed with the agent's **`adcp_use: "request-signing"`** key; there is no separate webhook key purpose. Domain separation between requests and webhooks is carried by the signature `tag` (`adcp/request-signing/v1` vs `adcp/webhook-signing/v1`), not by the key purpose. Each signing JWK MUST declare: | Member | Value | |---|---| | `use` | `"sig"` | | `key_ops` | `["verify"]` | -| `adcp_use` | `"webhook-signing"` | +| `adcp_use` | `"request-signing"` | | `kid` | distinct within the JWKS; MUST NOT collide with any other `kid` regardless of `adcp_use` | | `alg` | `"EdDSA"` or `"ES256"` | -Webhook delivery is the one direction where key reuse is permitted: a buyer verifying a webhook MUST accept a JWK whose `adcp_use` is `"webhook-signing"` OR `"request-signing"`, and MUST reject any other purpose with `webhook_mode_mismatch` (and absent `adcp_use` with `webhook_signature_key_purpose_invalid`). The reverse is still forbidden — a `"webhook-signing"` key MUST NOT verify a request signature (request verification requires `adcp_use == "request-signing"` exactly), and neither `"response-signing"` nor `"governance-signing"` keys are ever valid for webhook delivery. The asymmetry is deliberate: request signing remains strict because it has no compensating `tag` relaxation need, whereas webhook delivery already carries domain separation in the `tag` (`adcp/webhook-signing/v1`) and mandatory `content-digest` coverage, so the key-purpose check adds no confusion resistance there. A signer that reuses its request-signing key accepts that a request-signing-key compromise also compromises its webhooks; a dedicated webhook-signing key preserves that isolation and remains RECOMMENDED. +**Key isolation is optional, via a distinct `kid` — not a distinct purpose.** An operator that wants its webhook traffic signed by separate key material (so a webhook-key compromise does not extend to request signing, or to rotate the two independently) publishes a **second `adcp_use: "request-signing"` key with a distinct `kid`** and signs webhooks with that one. Both keys carry the same `adcp_use`; the verifier resolves the right one by the `kid` in `Signature-Input`. No dedicated webhook key purpose is required to achieve isolation. -**Trust anchor and blast radius.** The trust anchor for webhook authenticity is **the signer's brand.json origin** — the HTTPS origin that hosts the brand.json declaring the signing agent's `agents[]` entry. A compromise of that origin (sub-path takeover, DNS hijack, CDN cache poisoning of `/.well-known/brand.json` or the `jwks_uri`) compromises every webhook that buyer accepts from that signer until the operator publishes a `revoked_kids` entry and buyer verifiers refresh the revocation list. Buyers SHOULD pin the agent's `jwks_uri` URL learned at integration onboarding and alarm on changes to the URL itself (not just on `kid` rotation within a stable URL) — changes to the URL force re-anchoring and SHOULD require operator attention, not silent adoption. `kid` collisions across `adcp_use` values within the same JWKS are forbidden so each `kid` carries exactly one declared purpose. Note that a signer MAY deliberately reuse its `request-signing` key for webhook delivery (see step 8) — in that case a request-signing-key compromise does extend to webhooks. Publishing a dedicated `webhook-signing` key is the isolation mechanism that prevents that, and is RECOMMENDED where blast-radius separation matters. +> **Deprecated:** `adcp_use: "webhook-signing"` is deprecated and will be removed in AdCP 4.0. Verifiers MUST still accept it for backward compatibility (a webhook signed under a `"webhook-signing"` key verifies cleanly), but new signers SHOULD publish and sign with `"request-signing"` keys only. + +A buyer verifying a webhook MUST accept a JWK whose `adcp_use` is `"request-signing"` (or the deprecated `"webhook-signing"`), and MUST reject any other key-purpose failure — any other `adcp_use` value, absent `adcp_use`, or a missing `verify` key_op — with `webhook_signature_key_purpose_invalid`. The reverse is still forbidden: request verification requires `adcp_use == "request-signing"` exactly (a request signature is rejected if the key declares any other purpose), and neither `"response-signing"` nor `"governance-signing"` keys are ever valid for webhook delivery. The webhook path is permissive on key purpose because it already carries domain separation in the `tag` (`adcp/webhook-signing/v1`) and mandatory `content-digest` coverage, so the key-purpose check adds no confusion resistance there. + +**Trust anchor and blast radius.** The trust anchor for webhook authenticity is **the signer's brand.json origin** — the HTTPS origin that hosts the brand.json declaring the signing agent's `agents[]` entry. A compromise of that origin (sub-path takeover, DNS hijack, CDN cache poisoning of `/.well-known/brand.json` or the `jwks_uri`) compromises every webhook that buyer accepts from that signer until the operator publishes a `revoked_kids` entry and buyer verifiers refresh the revocation list. Buyers SHOULD pin the agent's `jwks_uri` URL learned at integration onboarding and alarm on changes to the URL itself (not just on `kid` rotation within a stable URL) — changes to the URL force re-anchoring and SHOULD require operator attention, not silent adoption. `kid` collisions within the same JWKS are forbidden so each `kid` resolves to exactly one key. Webhooks are signed with the agent's `request-signing` key, so by default a request-signing-key compromise extends to webhooks. An operator that needs blast-radius separation publishes a second `request-signing` key with a distinct `kid` dedicated to webhook delivery and signs webhooks with it — isolation comes from separate key material under a separate `kid`, not from a separate `adcp_use`. **Covered components** are identical to request signing: `@method`, `@target-uri`, `@authority`, `content-type`, and `content-digest`. `content-digest` is REQUIRED on webhook callbacks — the body carries the event, and webhook receivers are buyer-controlled endpoints where body preservation is the buyer's own infrastructure problem. There is no `covers_content_digest: "forbidden"` opt-out for webhooks; transports that cannot preserve webhook body bytes MUST be fixed. @@ -1471,7 +1475,7 @@ Buyers MUST NOT derive signer identity from webhook payload fields (`task_id`, ` 5. Reject if `expires ≤ created`, `created > now + 60 s`, `expires < now − 60 s`, or `expires − created > 300 s` (`webhook_signature_window_invalid`). 6. Reject if covered components do not include ALL of: `@method`, `@target-uri`, `@authority`, `content-type`, `content-digest` (`webhook_signature_components_incomplete`). `content-digest` is REQUIRED; there is no policy branch. 7. Resolve `keyid` to a JWK via the JWKS discovery steps above. On `kid` miss, refetch once (30-second cooldown between refetches) before rejecting (`webhook_signature_key_unknown`). Reject if `keyid` cannot be resolved to a specific `agents[]` entry in the signer's brand.json. -8. Verify the JWK's `use` is `"sig"`, `key_ops` includes `"verify"`, and `adcp_use` is either `"webhook-signing"` or `"request-signing"` — a signer MAY reuse its request-signing key to sign webhooks, or publish a dedicated webhook-signing key (the signer's choice; see [Key publication](#webhook-callbacks)). Reject on any other outcome with `webhook_signature_key_purpose_invalid`: absent `adcp_use`, a missing `verify` key_op, or any `adcp_use` value outside that set (e.g. `"response-signing"`, `"governance-signing"`). This relaxation is safe because cross-protocol confusion is prevented by the `tag` (step 3) and mandatory `content-digest` coverage (step 6), not by the key-purpose discriminator: a captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3. (`webhook_mode_mismatch` is reserved for the HMAC-vs-9421 auth-mode selector mismatch — see [Downgrade and injection resistance](#webhook-callbacks) — and is NOT used for key-purpose failures.) +8. Verify the JWK's `use` is `"sig"`, `key_ops` includes `"verify"`, and `adcp_use` is `"request-signing"` — webhooks are signed with the agent's request-signing key (see [Key publication](#webhook-callbacks)); the deprecated `"webhook-signing"` value MUST also be accepted for backward compatibility. Reject on any other outcome with `webhook_signature_key_purpose_invalid`: absent `adcp_use`, a missing `verify` key_op, or any other `adcp_use` value (e.g. `"response-signing"`, `"governance-signing"`). Accepting `"request-signing"` here is safe because cross-protocol confusion is prevented by the `tag` (step 3) and mandatory `content-digest` coverage (step 6), not by the key-purpose discriminator: a captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3. (`webhook_mode_mismatch` is reserved for the HMAC-vs-9421 auth-mode selector mismatch — see [Downgrade and injection resistance](#webhook-callbacks) — and is NOT used for key-purpose failures.) 9. Check the [Transport revocation](#transport-revocation) list (reused across signing purposes). Reject if `keyid ∈ revoked_kids` (`webhook_signature_key_revoked`). Reject with `webhook_signature_revocation_stale` if the verifier has not refreshed within grace. **9a. Per-keyid cap check.** Check the [webhook replay-cache cap](#webhook-replay-dedup-sizing). Reject with `webhook_signature_rate_abuse` if exceeded. Runs before cryptographic verify (step 10) for the same cheap-rejection rationale as request signing. diff --git a/static/compliance/source/test-vectors/webhook-signing/README.md b/static/compliance/source/test-vectors/webhook-signing/README.md index c614c283e0..c4a55a72f9 100644 --- a/static/compliance/source/test-vectors/webhook-signing/README.md +++ b/static/compliance/source/test-vectors/webhook-signing/README.md @@ -4,6 +4,8 @@ Test vectors for the AdCP RFC 9421 webhook-signing profile. These fixtures drive Specification: [Webhook callbacks](https://adcontextprotocol.org/docs/building/by-layer/L1/security#webhook-callbacks) in `docs/building/by-layer/L1/security.mdx`. +**Key purpose.** Webhooks are signed with the agent's `adcp_use: "request-signing"` key (see `positive/008-request-signing-key-reuse`); domain separation from request signatures is carried by the `tag`, not the key purpose. The `adcp_use: "webhook-signing"` value is **deprecated** (removed in 4.0) — the `test-*-webhook-2026` keys and `positive/001`–`007` exercise the still-accepted backward-compatible path. A verifier MUST accept both `request-signing` and `webhook-signing` on the webhook path. + **Canonical URLs.** These vectors are served at `https://adcontextprotocol.org/compliance/{version}/test-vectors/webhook-signing/`, with `{version}` being either a specific release (e.g. `3.0.0`) or `latest` (tracks the most recent GA). Tree preserved — `keys.json`, `negative/*.json`, `positive/*.json` all resolvable. SDKs SHOULD fetch from the versioned CDN path and record the version under test rather than requiring a checkout of the spec repo. Example: `https://adcontextprotocol.org/compliance/latest/test-vectors/webhook-signing/positive/001-basic-post.json`. ## ⚠️ Security — test keys are public From 8e3a41050f6da386b4578d8311bd6d63bd28b0ba Mon Sep 17 00:00:00 2001 From: BaiyuScope3 Date: Mon, 15 Jun 2026 14:59:37 -0400 Subject: [PATCH 04/10] docs(security): finish webhook-signing sweep; reference removal issue not 4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on #5552: update the remaining stale webhook-signing sites missed in the first pass — the request-signing profile prose (lines 870/878), the JWKS publication example (second entry is now a request-signing key under a distinct kid, matching the prose), the five-signing-systems list, and the conformance README scope line. Replace hard-coded 'AdCP 4.0' removal language with a reference to the deprecation tracking issue (#5555), per reviewer note that the removal window is a WG/RFC decision. Co-Authored-By: Claude Opus 4.8 --- .../webhook-allow-request-signing-key-reuse.md | 2 +- docs/building/by-layer/L1/security.mdx | 16 ++++++++-------- .../test-vectors/webhook-signing/README.md | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.changeset/webhook-allow-request-signing-key-reuse.md b/.changeset/webhook-allow-request-signing-key-reuse.md index b1fa1dcb31..0b10be1881 100644 --- a/.changeset/webhook-allow-request-signing-key-reuse.md +++ b/.changeset/webhook-allow-request-signing-key-reuse.md @@ -2,7 +2,7 @@ "adcontextprotocol": minor --- -Webhooks are signed with the agent's `request-signing` key — there is no separate webhook key purpose. The webhook verifier checklist (step 8) now requires `adcp_use == "request-signing"` (with the deprecated `"webhook-signing"` still accepted for backward compatibility, removed in 4.0). Operators that want separate key material for webhooks publish a second `"request-signing"` key with a distinct `kid` and sign webhooks with it — key isolation comes from the `kid`, not a distinct `adcp_use`. Any other key-purpose failure — `"response-signing"`/`"governance-signing"`, absent `adcp_use`, or a missing `verify` key_op — is rejected with `webhook_signature_key_purpose_invalid`. `webhook_mode_mismatch` is unchanged and remains reserved for the HMAC-vs-9421 auth-mode selector mismatch. +Webhooks are signed with the agent's `request-signing` key — there is no separate webhook key purpose. The webhook verifier checklist (step 8) now requires `adcp_use == "request-signing"` (with the deprecated `"webhook-signing"` still accepted for backward compatibility; removal tracked in adcontextprotocol/adcp#5555). Operators that want separate key material for webhooks publish a second `"request-signing"` key with a distinct `kid` and sign webhooks with it — key isolation comes from the `kid`, not a distinct `adcp_use`. Any other key-purpose failure — `"response-signing"`/`"governance-signing"`, absent `adcp_use`, or a missing `verify` key_op — is rejected with `webhook_signature_key_purpose_invalid`. `webhook_mode_mismatch` is unchanged and remains reserved for the HMAC-vs-9421 auth-mode selector mismatch. The relaxation is one-directional and safe: cross-protocol confusion is prevented by the RFC 9421 `tag` (`adcp/webhook-signing/v1`, part of the signed base, checked at step 3) and mandatory `content-digest` coverage — not by the key-purpose discriminator. A captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3, so it can never be replayed as a webhook. The reverse remains forbidden: a webhook-signing key MUST NOT verify a request signature (request verification still requires `adcp_use == "request-signing"` exactly). diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index f98725d322..c0b730e523 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -867,7 +867,7 @@ AdCP 3.0 defines this profile as **optional and capability-advertised** via `req **Roles:** - **Agents** sign requests with a key published at their own `jwks_uri` in their operator's brand.json `agents[]` entry. The operator (the domain hosting brand.json) may be a house buying direct or an authorized third party — this profile does not distinguish. The signer is always an agent. - **Sellers** verify the signature against the signing agent's published key, establishing agent identity. Sellers then perform the separate brand-operator authorization check (outside this profile's scope). -- **Sellers calling agent-side AdCP endpoints** (e.g., buyer-hosted mutation callbacks that are themselves AdCP protocol calls) sign their outgoing requests symmetrically; the receiving agent verifies against the seller's keys published under the seller operator's brand.json `agents[]` entry. Push-notification webhook callbacks (`push_notification_config.url` and similar asynchronous one-way notifications) are covered by the symmetric [Webhook callbacks](#webhook-callbacks) variant of this profile — the seller signs outbound with an `adcp_use: "webhook-signing"` key and the buyer verifies. +- **Sellers calling agent-side AdCP endpoints** (e.g., buyer-hosted mutation callbacks that are themselves AdCP protocol calls) sign their outgoing requests symmetrically; the receiving agent verifies against the seller's keys published under the seller operator's brand.json `agents[]` entry. Push-notification webhook callbacks (`push_notification_config.url` and similar asynchronous one-way notifications) are covered by the symmetric [Webhook callbacks](#webhook-callbacks) variant of this profile — the seller signs outbound with its `adcp_use: "request-signing"` key (the deprecated `"webhook-signing"` value is still accepted) and the buyer verifies. **Dependencies:** - Shares JWKS discovery, SSRF rules, alg allowlist, revocation semantics, and key rotation with the [AdCP JWS profile](#adcp-jws-profile) above. Cross-purpose key reuse is forbidden: a request-signing JWK MUST declare `"adcp_use": "request-signing"`, `"use": "sig"`, `"key_ops": ["verify"]`, and a `kid` that does not appear on any other JWKS entry with a different `adcp_use`. Verifiers enforce all four; see [Agent key publication](#agent-key-publication). @@ -875,7 +875,7 @@ AdCP 3.0 defines this profile as **optional and capability-advertised** via `req **Conformance.** Verifier behavior is graded by the universal capability-gated storyboard at [`/compliance/latest/universal/signed-requests`](https://adcontextprotocol.org/compliance/latest/universal/signed-requests), which runs for any agent advertising `request_signing.supported: true`. The storyboard exercises every step in the [verifier checklist](#verifier-checklist-requests) below and every canonicalization-edge rule in this profile, against the test vectors at [`/compliance/latest/test-vectors/request-signing/`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/). To run the CLI grader against your own agent, see [Auth Graders](/docs/building/verification/grading). -**No general-purpose RFC 9421 response-signing profile.** This profile signs the *request*; AdCP 3.x defines no general-purpose paired profile for signing the synchronous response *transport*. Sellers MUST NOT apply RFC 9421 §2.2.9 response signing to synchronous AdCP responses (whether MCP `tools/call` or A2A non-streaming responses including streaming `artifactUpdate` frames), and buyers MUST NOT rely on an RFC 9421 response signature on the synchronous reply. Integrity of the immediate response transport rests on TLS within the authenticated session that carried the request, modulo the standard edge-termination caveats that govern request-side body integrity at body-modifying CDNs. Durable at-rest attestation for artifacts that need to survive past the session — including specialism-scoped payloads (brand-rights, AAO Verified compliance, sales-intelligence relay, governance receipts, bilateral non-repudiation receipts such as `plan_receipt`) — is the job of [signed webhooks](#webhook-callbacks) (`adcp_use: "webhook-signing"`). The split is deliberate — see [Security Model: What gets signed](/docs/building/concepts/security-model#what-gets-signed--and-what-doesnt) for the full rationale and the request-the-webhook pattern for tools whose canonical artifact needs to be attestable. +**No general-purpose RFC 9421 response-signing profile.** This profile signs the *request*; AdCP 3.x defines no general-purpose paired profile for signing the synchronous response *transport*. Sellers MUST NOT apply RFC 9421 §2.2.9 response signing to synchronous AdCP responses (whether MCP `tools/call` or A2A non-streaming responses including streaming `artifactUpdate` frames), and buyers MUST NOT rely on an RFC 9421 response signature on the synchronous reply. Integrity of the immediate response transport rests on TLS within the authenticated session that carried the request, modulo the standard edge-termination caveats that govern request-side body integrity at body-modifying CDNs. Durable at-rest attestation for artifacts that need to survive past the session — including specialism-scoped payloads (brand-rights, AAO Verified compliance, sales-intelligence relay, governance receipts, bilateral non-repudiation receipts such as `plan_receipt`) — is the job of [signed webhooks](#webhook-callbacks) (signed with the `adcp_use: "request-signing"` key). The split is deliberate — see [Security Model: What gets signed](/docs/building/concepts/security-model#what-gets-signed--and-what-doesnt) for the full rationale and the request-the-webhook pattern for tools whose canonical artifact needs to be attestable. @@ -970,14 +970,14 @@ Reference implementations: `@adcp/sdk` (TypeScript) ships a `SigningProvider` in "x": "lHJI-IvBwCE36heDNOyBmCk5UMKRIs4b4BAWJRgao-M", "kid": "acme-webhook-2026-04", "alg": "EdDSA", "use": "sig", - "adcp_use": "webhook-signing", + "adcp_use": "request-signing", "key_ops": ["verify"] } ] } ``` -Distinct `kid` values also mean counterparties can cache and rotate the two keys independently. +This second entry is the optional webhook-isolation key from the [Key publication](#webhook-callbacks) section: same `adcp_use: "request-signing"`, distinct `kid`, used to sign webhooks so a webhook-key compromise doesn't extend to request signing. Distinct `kid` values also mean counterparties can cache and rotate the two keys independently. #### AdCP RFC 9421 profile @@ -1070,7 +1070,7 @@ Each request-signing JWK entry MUST declare: |---|---|---| | `use` | `"sig"` | Standard JWK signing use. | | `key_ops` | `["verify"]` | Verifier-visible JWKS declares verify-only. The signing operator holds the corresponding private key locally with `["sign"]` per JWK spec. | -| `adcp_use` | `"request-signing"` | AdCP-specific purpose discriminator. Distinguishes from `"governance-signing"` (JWS profile) and any future AdCP signing purpose. Verifiers MUST reject any JWK with absent or different `adcp_use` when verifying a request signature. The same `"request-signing"` key (or a second one under a distinct `kid`) also signs outbound webhooks — see [Webhook callbacks](#webhook-callbacks). (`"webhook-signing"` is a deprecated purpose, removed in 4.0; still accepted on the webhook path for backward compatibility.) | +| `adcp_use` | `"request-signing"` | AdCP-specific purpose discriminator. Distinguishes from `"governance-signing"` (JWS profile) and any future AdCP signing purpose. Verifiers MUST reject any JWK with absent or different `adcp_use` when verifying a request signature. The same `"request-signing"` key (or a second one under a distinct `kid`) also signs outbound webhooks — see [Webhook callbacks](#webhook-callbacks). (`"webhook-signing"` is a deprecated purpose pending removal — see [#5555](https://github.com/adcontextprotocol/adcp/issues/5555); still accepted on the webhook path for backward compatibility.) | | `kid` | distinct | Unique within the JWKS. MUST NOT collide with any other entry's `kid` regardless of `adcp_use`. | | `alg` | `"EdDSA"` or `"ES256"` | Must match the signature's `alg` parameter (JWK `alg` uses JWS names; `alg` in `Signature-Input` uses RFC 9421 names). | @@ -1435,7 +1435,7 @@ The buyer opts into the legacy HMAC-SHA256 scheme by populating `push_notificati **Key isolation is optional, via a distinct `kid` — not a distinct purpose.** An operator that wants its webhook traffic signed by separate key material (so a webhook-key compromise does not extend to request signing, or to rotate the two independently) publishes a **second `adcp_use: "request-signing"` key with a distinct `kid`** and signs webhooks with that one. Both keys carry the same `adcp_use`; the verifier resolves the right one by the `kid` in `Signature-Input`. No dedicated webhook key purpose is required to achieve isolation. -> **Deprecated:** `adcp_use: "webhook-signing"` is deprecated and will be removed in AdCP 4.0. Verifiers MUST still accept it for backward compatibility (a webhook signed under a `"webhook-signing"` key verifies cleanly), but new signers SHOULD publish and sign with `"request-signing"` keys only. +> **Deprecated:** `adcp_use: "webhook-signing"` is deprecated and scheduled for removal in a future major version (tracked in [#5555](https://github.com/adcontextprotocol/adcp/issues/5555); the exact window is a WG/RFC decision). Verifiers MUST still accept it for backward compatibility (a webhook signed under a `"webhook-signing"` key verifies cleanly), but new signers SHOULD publish and sign with `"request-signing"` keys only. A buyer verifying a webhook MUST accept a JWK whose `adcp_use` is `"request-signing"` (or the deprecated `"webhook-signing"`), and MUST reject any other key-purpose failure — any other `adcp_use` value, absent `adcp_use`, or a missing `verify` key_op — with `webhook_signature_key_purpose_invalid`. The reverse is still forbidden: request verification requires `adcp_use == "request-signing"` exactly (a request signature is rejected if the key declares any other purpose), and neither `"response-signing"` nor `"governance-signing"` keys are ever valid for webhook delivery. The webhook path is permissive on key purpose because it already carries domain separation in the `tag` (`adcp/webhook-signing/v1`) and mandatory `content-digest` coverage, so the key-purpose check adds no confusion resistance there. @@ -1585,8 +1585,8 @@ Codes parallel the [request-signing error taxonomy](#transport-error-taxonomy), **TMP keys MUST declare a distinct `adcp_use` value** (or omit it entirely) so verifiers reject them for request signing via step 8. Publishing TMP keys at the same `jwks_uri` as request-signing and webhook-signing keys is permitted and encouraged — one publication pattern, five signing systems, each `kid`-scoped: - governance JWS — `adcp_use: "governance-signing"` -- request signing (RFC 9421) — `adcp_use: "request-signing"` -- webhook signing (RFC 9421) — `adcp_use: "webhook-signing"` +- request signing (RFC 9421) — `adcp_use: "request-signing"` (also signs webhooks; see [Webhook callbacks](#webhook-callbacks)) +- webhook signing (RFC 9421) — uses the `request-signing` key; the legacy `adcp_use: "webhook-signing"` value is **deprecated** (still accepted, pending removal — see follow-up issue in the deprecation note) - designated-task response-payload JWS — `adcp_use: "response-signing"` (see [Designated-task payload-envelope response signing](#designated-task-response-signing) above) - TMP envelope — TMP's own future `adcp_use` value diff --git a/static/compliance/source/test-vectors/webhook-signing/README.md b/static/compliance/source/test-vectors/webhook-signing/README.md index c4a55a72f9..d34739049a 100644 --- a/static/compliance/source/test-vectors/webhook-signing/README.md +++ b/static/compliance/source/test-vectors/webhook-signing/README.md @@ -4,7 +4,7 @@ Test vectors for the AdCP RFC 9421 webhook-signing profile. These fixtures drive Specification: [Webhook callbacks](https://adcontextprotocol.org/docs/building/by-layer/L1/security#webhook-callbacks) in `docs/building/by-layer/L1/security.mdx`. -**Key purpose.** Webhooks are signed with the agent's `adcp_use: "request-signing"` key (see `positive/008-request-signing-key-reuse`); domain separation from request signatures is carried by the `tag`, not the key purpose. The `adcp_use: "webhook-signing"` value is **deprecated** (removed in 4.0) — the `test-*-webhook-2026` keys and `positive/001`–`007` exercise the still-accepted backward-compatible path. A verifier MUST accept both `request-signing` and `webhook-signing` on the webhook path. +**Key purpose.** Webhooks are signed with the agent's `adcp_use: "request-signing"` key (see `positive/008-request-signing-key-reuse`); domain separation from request signatures is carried by the `tag`, not the key purpose. The `adcp_use: "webhook-signing"` value is **deprecated** (pending removal — adcontextprotocol/adcp#5555) — the `test-*-webhook-2026` keys and `positive/001`–`007` exercise the still-accepted backward-compatible path. A verifier MUST accept both `request-signing` and `webhook-signing` on the webhook path. **Canonical URLs.** These vectors are served at `https://adcontextprotocol.org/compliance/{version}/test-vectors/webhook-signing/`, with `{version}` being either a specific release (e.g. `3.0.0`) or `latest` (tracks the most recent GA). Tree preserved — `keys.json`, `negative/*.json`, `positive/*.json` all resolvable. SDKs SHOULD fetch from the versioned CDN path and record the version under test rather than requiring a checkout of the spec repo. Example: `https://adcontextprotocol.org/compliance/latest/test-vectors/webhook-signing/positive/001-basic-post.json`. @@ -26,7 +26,7 @@ Webhook signing reuses most of the RFC 9421 profile from request signing: - **Signature parameters** (`created`, `expires`, `nonce`, `keyid`, `alg`) share semantics with request signing. The only divergence is `tag`: webhooks MUST use `adcp/webhook-signing/v1`. - **Binary value encoding** (`Signature`, `Content-Digest`) uses the same base64url-no-padding override as request signing. -The distinct surface is the purpose-discriminator chain: `adcp_use` MUST be `"webhook-signing"` on the verifying JWK, `tag` MUST be `"adcp/webhook-signing/v1"`, and `content-digest` MUST be covered (no `covers_content_digest: "forbidden"` opt-out — the body is the event). +The distinct surface is the purpose-discriminator chain: `adcp_use` MUST be `"request-signing"` on the verifying JWK (the deprecated `"webhook-signing"` is also accepted for backward compatibility), `tag` MUST be `"adcp/webhook-signing/v1"`, and `content-digest` MUST be covered (no `covers_content_digest: "forbidden"` opt-out — the body is the event). ## File layout From e33b619d6639c4036513812c63aea8fe0d29557d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 16 Jun 2026 08:04:13 +0200 Subject: [PATCH 05/10] fix(security): clarify webhook signing key discovery --- docs/building/by-layer/L1/security.mdx | 24 +++++++++---------- .../get-adcp-capabilities-response.json | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index c0b730e523..8acea109e6 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -870,7 +870,7 @@ AdCP 3.0 defines this profile as **optional and capability-advertised** via `req - **Sellers calling agent-side AdCP endpoints** (e.g., buyer-hosted mutation callbacks that are themselves AdCP protocol calls) sign their outgoing requests symmetrically; the receiving agent verifies against the seller's keys published under the seller operator's brand.json `agents[]` entry. Push-notification webhook callbacks (`push_notification_config.url` and similar asynchronous one-way notifications) are covered by the symmetric [Webhook callbacks](#webhook-callbacks) variant of this profile — the seller signs outbound with its `adcp_use: "request-signing"` key (the deprecated `"webhook-signing"` value is still accepted) and the buyer verifies. **Dependencies:** -- Shares JWKS discovery, SSRF rules, alg allowlist, revocation semantics, and key rotation with the [AdCP JWS profile](#adcp-jws-profile) above. Cross-purpose key reuse is forbidden: a request-signing JWK MUST declare `"adcp_use": "request-signing"`, `"use": "sig"`, `"key_ops": ["verify"]`, and a `kid` that does not appear on any other JWKS entry with a different `adcp_use`. Verifiers enforce all four; see [Agent key publication](#agent-key-publication). +- Shares JWKS discovery, SSRF rules, alg allowlist, revocation semantics, and key rotation with the [AdCP JWS profile](#adcp-jws-profile) above. Request verification never accepts another key purpose: a request-signing JWK MUST declare `"adcp_use": "request-signing"`, `"use": "sig"`, `"key_ops": ["verify"]`, and a `kid` that does not appear on any other JWKS entry with a different `adcp_use`. Verifiers enforce all four; see [Agent key publication](#agent-key-publication). The webhook path has its own explicit relaxation because the webhook `tag` provides domain separation. - Resolves the identity-bootstrapping dependency in [Buyer identity resolution](#buyer-identity-resolution) for governance: a seller that verifies a request signature has a cryptographically established signing agent identity and MAY use the signing agent's operator domain as the brand.json resolution input for the governance verification step. **Conformance.** Verifier behavior is graded by the universal capability-gated storyboard at [`/compliance/latest/universal/signed-requests`](https://adcontextprotocol.org/compliance/latest/universal/signed-requests), which runs for any agent advertising `request_signing.supported: true`. The storyboard exercises every step in the [verifier checklist](#verifier-checklist-requests) below and every canonicalization-edge rule in this profile, against the test vectors at [`/compliance/latest/test-vectors/request-signing/`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/). To run the CLI grader against your own agent, see [Auth Graders](/docs/building/verification/grading). @@ -1060,7 +1060,7 @@ Buyers reading capability blocks in 3.x MUST NOT assume protocol-method coverage #### Agent key publication -Request-signing and webhook-signing keys live at the signing agent's own `jwks_uri` in its operator's brand.json `agents[]` entry. Every agent that signs — of any `type` — uses the same publication pattern. Publisher `adagents.json` may additionally pin allowed seller keys through `authorized_agents[].signing_keys[]`; when present, that pin is authoritative for the scoped sell-side authorization. +Request-signing keys live at the signing agent's own `jwks_uri` in its operator's brand.json `agents[]` entry, and outbound webhooks are signed with `"request-signing"` keys (optionally with distinct webhook-only key material under a separate `kid`). Every agent that signs — of any `type` — uses the same publication pattern. Publisher `adagents.json` may additionally pin allowed seller keys through `authorized_agents[].signing_keys[]`; when present, that pin is authoritative for the scoped sell-side authorization. **Publisher pin precedence.** When a publisher's `adagents.json` entry for an authorized agent carries a `signing_keys` pin (see [`adagents.json` §`signing_keys`](/docs/governance/property/adagents#signing_keys)), that pin is authoritative: verifiers MUST reject any signature whose `keyid` is not in the pinned set, regardless of `jwks_uri` contents. The agent-hosted JWKS is advisory whenever a publisher pin exists. This closes the agent-domain-compromise window — an attacker who takes over the agent's domain cannot silently swap both the endpoint and its advertised keys because the publisher's pin still governs acceptance. Publishers are required to pin for any agent whose delegated scopes include mutating operations; see the adagents.json rule for rotation and cache semantics. @@ -1074,14 +1074,14 @@ Each request-signing JWK entry MUST declare: | `kid` | distinct | Unique within the JWKS. MUST NOT collide with any other entry's `kid` regardless of `adcp_use`. | | `alg` | `"EdDSA"` or `"ES256"` | Must match the signature's `alg` parameter (JWK `alg` uses JWS names; `alg` in `Signature-Input` uses RFC 9421 names). | -Cross-purpose key reuse is forbidden and **locally enforceable** via `adcp_use`: a single JWK entry can only declare one `adcp_use` value, so a publisher cannot accidentally (or deliberately) present a governance-signing key as a valid request-signing key. Verifiers check `adcp_use` on the JWK they fetched, not across other JWKS endpoints — no cross-endpoint lookup is required or permitted. +Cross-purpose key reuse is forbidden and **locally enforceable** via `adcp_use`, except for the explicit webhook relaxation above: a `"request-signing"` key may sign either requests or webhooks because the RFC 9421 `tag` separates those profiles. A single JWK entry can only declare one `adcp_use` value, so a publisher cannot accidentally (or deliberately) present a governance-signing key as a valid request-signing key. Verifiers check `adcp_use` on the JWK they fetched, not across other JWKS endpoints — no cross-endpoint lookup is required or permitted. -**Origin separation (MUST for governance, SHOULD for others).** `adcp_use` is an in-band discriminator — it prevents cross-purpose verification, but it does not defend the publishing origin. An origin compromise on a shared JWKS endpoint simultaneously compromises every signing purpose it publishes. Because a governance-signing key is the highest blast-radius key in the system (its compromise is a multi-tenant breach), governance signing keys MUST be served from a separate origin than transport-signing and webhook-signing keys. The canonical pattern is: +**Origin separation (MUST for governance, SHOULD for others).** `adcp_use` is an in-band discriminator — it prevents cross-purpose verification, but it does not defend the publishing origin. An origin compromise on a shared JWKS endpoint simultaneously compromises every signing purpose it publishes. Because a governance-signing key is the highest blast-radius key in the system (its compromise is a multi-tenant breach), governance signing keys MUST be served from a separate origin than transport/webhook RFC 9421 keys. The canonical pattern is: - `governance-keys.{org}.example/.well-known/jwks.json` — governance-signing JWKs only -- `keys.{org}.example/.well-known/jwks.json` — request-signing, webhook-signing, TMP keys +- `keys.{org}.example/.well-known/jwks.json` — request-signing keys (including webhook-only `kid`s), deprecated webhook-signing keys during the backward-compatibility window, and TMP keys -Operators SHOULD go further and serve each signing purpose from a distinct subdomain (up to four origins). Defense-in-depth: governance keys SHOULD be on offline-rotation (HSM/KMS with manual rotation and human approval), while transport and webhook keys MAY use automated rotation. Operators advertise their separation scheme by publishing an `identity.key_origins` map in `get_adcp_capabilities`; the schema defines `governance_signing`, `request_signing`, `webhook_signing`, and `tmp_signing` origin URIs. Implementers SHOULD populate the field so counterparties can verify origin separation at onboarding. **When the field is present, verifiers MUST check that the declared governance-signing origin differs from the declared transport-signing and webhook-signing origins at onboarding and reject onboarding with a user-actionable error on co-tenancy.** The MUST on origin separation is otherwise unverifiable on the wire — the whole point of publishing the advertisement is to let counterparties enforce it programmatically; accepting a declaration that violates the normative rule would defeat the control. Verifiers MAY additionally fetch each declared JWKS and confirm its `jwks_uri` origin matches the advertised value. +Operators SHOULD go further and serve each signing surface from a distinct subdomain. Defense-in-depth: governance keys SHOULD be on offline-rotation (HSM/KMS with manual rotation and human approval), while transport and webhook keys MAY use automated rotation. Operators advertise their separation scheme by publishing an `identity.key_origins` map in `get_adcp_capabilities`; the schema defines `governance_signing`, `request_signing`, `webhook_signing`, and `tmp_signing` origin URIs. `webhook_signing` names the webhook delivery surface, not a required live `adcp_use: "webhook-signing"` purpose; it may point at the same origin as `request_signing` when webhooks use request-signing keys. Implementers SHOULD populate the field so counterparties can verify origin separation at onboarding. **When the field is present, verifiers MUST check that the declared governance-signing origin differs from the declared request/webhook signing origins at onboarding and reject onboarding with a user-actionable error on co-tenancy.** The MUST on origin separation is otherwise unverifiable on the wire — the whole point of publishing the advertisement is to let counterparties enforce it programmatically; accepting a declaration that violates the normative rule would defeat the control. Verifiers MAY additionally fetch each declared JWKS and confirm its `jwks_uri` origin matches the advertised value. **Implementer note:** `adcp_use` is a custom JWK member. Major JOSE libraries (`jose`, `node-jose`, `python-jose`, `go-jose`) preserve unknown members on parse. Strict JWK validators (some modes of `PyJWT`, and Web Crypto API's `SubtleCrypto.importKey`) may reject unknown members. When handing a JWK to `SubtleCrypto.importKey` or equivalent strict consumers, strip `adcp_use` from the JWK object but retain it for the step-8 policy check. The field is for AdCP verifier policy, not for cryptographic libraries. @@ -1102,10 +1102,10 @@ The `identity.brand_json_url` field on `get_adcp_capabilities` (added in 3.x, se 3. **Origin binding.** The agent URL `A`'s host eTLD+1 MUST equal the `brand_json_url`'s host eTLD+1. eTLD+1 computation MUST use a pinned, dated [Public Suffix List](https://publicsuffix.org/list/public_suffix_list.dat) snapshot (ICANN+PRIVATE sections both in scope so platforms like `vercel.app`, `pages.dev`, `github.io` are treated as suffixes); two verifiers running different PSL versions are non-conformant against each other. If eTLD+1 mismatches, fetch brand.json and check that `authorized_operators[]` lists `A`'s eTLD+1. If neither holds, reject with `request_signature_brand_origin_mismatch`. This closes the shared-tenancy spoofing vector where an attacker stands up an agent on `attacker.example/mcp` and points its `brand_json_url` at an unrelated operator's brand.json that happens to legitimately list `attacker.example/mcp` (e.g., a SaaS multi-tenant deployment). 4. Fetch brand.json at `brand_json_url` with SSRF validation per [Webhook URL validation](#webhook-url-validation-ssrf). Verifiers MUST NOT follow redirects on this fetch (the single-redirect carve-out for `authoritative_location` documented elsewhere in this profile is scoped to that field and MUST NOT be inherited by the brand.json bootstrap). Recommended budgets: connect 5 s, total deadline 10 s, body cap 256 KiB. Cache TTL on a successful fetch MUST be bounded above by the JWKS revocation polling interval (so a key rotation cannot be masked by a stale brand.json). Negative responses (404, network failure) MUST NOT be cached for more than 60 s — operators fixing a misconfiguration must not be locked out for a full revocation cycle. 5. Find the entry in `agents[]` whose `url` **byte-equals** `A` (no canonicalization at this step — same rule as the `iss`-to-brand.json match for governance JWS, see [Buyer identity resolution](#buyer-identity-resolution); the most common failure mode is a trailing-slash or scheme mismatch, e.g. `https://x.com/mcp` ≠ `https://x.com/mcp/`). If none matches, reject with `request_signature_agent_not_in_brand_json`. If multiple match (operator misconfig — the brand.json schema does not currently constrain `agents[]` to be unique-by-URL), reject with `request_signature_brand_json_ambiguous`. -6. Resolve the JWKS source by **purpose AND role** (sender-vs-receiver position, not just signing purpose): - - **Sell-side webhook-signing only** — i.e., the seller signing an outbound webhook to the buyer about media-buy delivery: the publisher's `adagents.json signing_keys` pin (when present) is authoritative per the publisher-pin precedence rule above and overrides everything below. The pin is scoped to (agent, `webhook-signing` purpose, sell-side role) — it does NOT override operator-side webhook-signing (e.g., a buyer-hosted webhook receiving operator status callbacks). - - **All other (purpose, role) tuples** — request-signing (any direction), operator-side webhook-signing, governance-signing, TMP-signing: use the matched `agents[]` entry's `jwks_uri`, defaulting to `/.well-known/jwks.json` at the origin of `A` when absent. -7. **`identity.key_origins` consistency check (mandatory when signing).** For every `purpose` declared under `identity.key_origins` on the capabilities response **whose JWKS source in step 6 was the operator brand.json** (i.e., not a publisher `adagents.json signing_keys` pin), the host of the resolved `jwks_uri` MUST equal the declared origin for that purpose. Mismatch on any purpose → reject with `request_signature_key_origin_mismatch` carrying `{ purpose, expected_origin, actual_origin }`. Skip the check **only** for the specific (agent, purpose, role) tuple whose source was a publisher pin — operator-side use of the same purpose is still checked. If the agent declares signing without a corresponding `identity.key_origins.{purpose}` entry, reject with `request_signature_key_origin_missing` carrying `{ purpose, posture }`. +6. Resolve the JWKS source by **signing surface AND role** (sender-vs-receiver position, not just `adcp_use`): + - **Sell-side webhook delivery only** — i.e., the seller signing an outbound webhook to the buyer about media-buy delivery: the publisher's `adagents.json signing_keys` pin (when present) is authoritative per the publisher-pin precedence rule above and overrides everything below. The pin is scoped to (agent, webhook delivery surface, sell-side role) — it does NOT override operator-side webhook deliveries (e.g., a buyer-hosted webhook receiving operator status callbacks), and it does not imply a separate `adcp_use: "webhook-signing"` key purpose. + - **All other (surface, role) tuples** — request signing (any direction), operator-side webhook delivery, governance signing, TMP signing: use the matched `agents[]` entry's `jwks_uri`, defaulting to `/.well-known/jwks.json` at the origin of `A` when absent. +7. **`identity.key_origins` consistency check (mandatory when signing).** For every surface/purpose declared under `identity.key_origins` on the capabilities response **whose JWKS source in step 6 was the operator brand.json** (i.e., not a publisher `adagents.json signing_keys` pin), the host of the resolved `jwks_uri` MUST equal the declared origin for that surface/purpose. Mismatch on any surface/purpose → reject with `request_signature_key_origin_mismatch` carrying `{ purpose, expected_origin, actual_origin }`. Skip the check **only** for the specific (agent, surface/role) tuple whose source was a publisher pin — operator-side use of the same surface is still checked. If the agent declares signing without a corresponding `identity.key_origins.{purpose}` entry, reject with `request_signature_key_origin_missing` carrying `{ purpose, posture }`. 8. Fetch JWKS, find the `kid`, verify per the existing RFC 9421 profile (steps 7+ of the [verifier checklist](#verifier-checklist-requests)). **Trust roots.** brand.json is operator-attested ("this agent is mine, here are its keys"). `adagents.json` is publisher-attested ("this agent may sell my inventory; optionally, here is its pinned `signing_keys`"). For sell-side webhook signatures, the publisher pin is authoritative (publisher > operator). For request signatures and operator-side webhook signatures, the operator brand.json `jwks_uri` is authoritative. The agent never self-attests its own keys — a `jwks_uri` field is deliberately NOT carried on the capabilities response; the operator publishes the keys out-of-band via brand.json. @@ -1144,8 +1144,8 @@ Mirrors the [request-signing quickstart](#quickstart-opt-into-request-signing-in 3. **eTLD+1 origin binding.** Compute `eTLD+1(A)` and `eTLD+1(brand_json_url)` using a pinned PSL snapshot. Use [`tldts`](https://www.npmjs.com/package/tldts) (TS), [`publicsuffixlist`](https://pypi.org/project/publicsuffixlist/) (Python), or [`golang.org/x/net/publicsuffix`](https://pkg.go.dev/golang.org/x/net/publicsuffix) (Go) with a vendored, dated snapshot. Do NOT fetch the PSL at runtime — a runtime fetch creates a denial-of-service oracle and a non-deterministic eTLD+1 across deployments. If they match, proceed. Otherwise fetch `brand.json` and check `authorized_operators[]` — if `eTLD+1(A)` is delegated, proceed. Else reject `request_signature_brand_origin_mismatch`. Origin comparisons throughout this algorithm MUST canonicalize both sides: ASCII-lowercase the host, then convert to IDNA-2008 A-label form (Punycode) before byte-equality. A non-canonical comparison (e.g., raw `Example.COM` vs `example.com`, or U-label vs A-label) silently rejects legitimate traffic. 4. **Fetch `brand.json`** with the same SSRF rules + no redirects, body cap `MAX_BRAND_JSON_BYTES`, connect 5 s, total 10 s. Parse with a strict JSON parser that rejects duplicate keys (e.g., [`secure-json-parse`](https://www.npmjs.com/package/secure-json-parse) in TS, the stdlib `json.JSONDecoder` in Python with an `object_pairs_hook` that raises on duplicates, [`encoding/json`](https://pkg.go.dev/encoding/json) `Decoder.DisallowUnknownFields` paired with a duplicate-key check in Go) — duplicate keys are the parser-differential vector that step 14 closes on the request surface, and the same trust-root document MUST NOT parse to two different shapes across verifiers. On duplicate-key detection, reject `request_signature_brand_json_malformed`. Cache successful responses up to (but no longer than) the JWKS revocation polling interval; cache failures for at most 60 s. 5. **Find the `agents[]` entry** whose `url` byte-equals `A` (no canonicalization). Reject `request_signature_agent_not_in_brand_json` on miss; `request_signature_brand_json_ambiguous` on multiple matches. -6. **Resolve `jwks_uri`** from the matched entry — for sell-side webhook-signing only, prefer the publisher's `adagents.json signing_keys` pin (when present) over the operator's `jwks_uri`. For all other (purpose, role) tuples, use the matched entry's `jwks_uri` (default: `/.well-known/jwks.json` at the origin of `A`). -7. **Consistency check.** For every purpose declared under capabilities `identity.key_origins`, apply `canonicalizeOrigin()` (ASCII-lowercase + IDNA-2008 A-label) to both the resolved `jwks_uri` host and the declared origin, then byte-compare (skip only the specific (agent, purpose, role) tuple sourced from a publisher pin). Reject `request_signature_key_origin_mismatch` / `_missing` as appropriate. +6. **Resolve `jwks_uri`** from the matched entry — for sell-side webhook delivery only, prefer the publisher's `adagents.json signing_keys` pin (when present) over the operator's `jwks_uri`. For all other (surface, role) tuples, use the matched entry's `jwks_uri` (default: `/.well-known/jwks.json` at the origin of `A`). +7. **Consistency check.** For every surface/purpose declared under capabilities `identity.key_origins`, apply `canonicalizeOrigin()` (ASCII-lowercase + IDNA-2008 A-label) to both the resolved `jwks_uri` host and the declared origin, then byte-compare (skip only the specific (agent, surface/role) tuple sourced from a publisher pin). Reject `request_signature_key_origin_mismatch` / `_missing` as appropriate. 8. **Hand off to step 8+ of the [verifier checklist](#verifier-checklist-requests)** — fetch the JWKS (with the same byte budget `MAX_JWKS_BYTES` and 5/10 s connect/total deadlines), find the `kid` (already resolved here in step 7's preamble — the verifier checklist's step 7 is the discovery preamble itself), verify per RFC 9421. Pseudocode (TypeScript-flavored; SDK helpers below collapse this to a single call): diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 1968b71343..998cf921e7 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -1269,7 +1269,7 @@ }, "key_origins": { "type": "object", - "description": "Map of signing-key purpose → publishing origin, so counterparties can verify origin separation (e.g., governance keys served from a separate origin than transport/webhook keys) at onboarding. Absent means the operator has not declared a separation scheme; receivers SHOULD assume shared-origin. Every purpose listed MUST have a corresponding signing posture declared elsewhere — `request_signing` requires non-empty `request_signing.supported_for`/`required_for`/`protocol_methods_supported_for`/`protocol_methods_required_for`; `webhook_signing` requires `webhook_signing.supported === true` — otherwise the consistency check at signature-verification time has nothing to anchor against. See `x-adcp-validation` and docs/building/implementation/security.mdx §Origin separation.", + "description": "Map of signing-key surface/purpose → publishing origin, so counterparties can verify origin separation (e.g., governance keys served from a separate origin than transport/webhook keys) at onboarding. Absent means the operator has not declared a separation scheme; receivers SHOULD assume shared-origin. Every entry listed MUST have a corresponding signing posture declared elsewhere — `request_signing` requires non-empty `request_signing.supported_for`/`required_for`/`protocol_methods_supported_for`/`protocol_methods_required_for`; `webhook_signing` requires `webhook_signing.supported === true` and names the webhook delivery surface, not a required live `adcp_use: \"webhook-signing\"` key purpose — otherwise the consistency check at signature-verification time has nothing to anchor against. See `x-adcp-validation` and docs/building/implementation/security.mdx §Origin separation.", "properties": { "governance_signing": { "type": "string", @@ -1284,7 +1284,7 @@ "webhook_signing": { "type": "string", "format": "uri", - "description": "Origin (scheme + host) serving the webhook-signing JWKS." + "description": "Origin (scheme + host) serving the JWKS used for webhook delivery. Webhooks are signed with `adcp_use: \"request-signing\"` keys; the deprecated `adcp_use: \"webhook-signing\"` value remains accepted during the backward-compatibility window." }, "tmp_signing": { "type": "string", From e584aea93664dc2be1a22485a991a8da48f0f941 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 16 Jun 2026 08:21:19 +0200 Subject: [PATCH 06/10] ci: force-refresh 3.0.x drift ref --- .github/workflows/build-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index 15b1a38bee..afe86d95db 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -104,7 +104,7 @@ jobs: - name: Fetch 3.0.x for error-code drift comparison if: github.ref != 'refs/heads/3.0.x' && github.base_ref != '3.0.x' - run: git fetch --depth=1 origin 3.0.x:refs/remotes/origin/3.0.x + run: git fetch --depth=1 origin +3.0.x:refs/remotes/origin/3.0.x - name: Error-code drift vs 3.0.x if: github.ref != 'refs/heads/3.0.x' && github.base_ref != '3.0.x' From e24d230ceede1183b6be0e06876d56f8c4843503 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 16 Jun 2026 08:50:07 +0200 Subject: [PATCH 07/10] docs: align webhook signing guidance --- docs/building/by-layer/L1/request-signing.mdx | 19 ++++++++++--------- docs/building/concepts/security-model.mdx | 16 ++++++++-------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/docs/building/by-layer/L1/request-signing.mdx b/docs/building/by-layer/L1/request-signing.mdx index 65f46f0255..e9ce7c8c59 100644 --- a/docs/building/by-layer/L1/request-signing.mdx +++ b/docs/building/by-layer/L1/request-signing.mdx @@ -42,12 +42,12 @@ 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: +Every agent publishes signing keys 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 +- `adcp_use: "request-signing"` — for signing outbound tool calls and outbound webhooks +- `adcp_use: "webhook-signing"` — deprecated; still accepted on the webhook path for backward compatibility -Reusing a key across purposes is forbidden by the spec. +For blast-radius isolation, publish a second `request-signing` key under a separate `kid` for webhook delivery rather than reusing the same key material for tool calls and webhooks. ### Discovery chain @@ -122,7 +122,7 @@ if err != nil { /* handle */ } // fields (kid, kty, crv, alg, use, key_ops, adcp_use) are set. ``` -Use `signing.ProfileWebhookSigning` for webhook keys — never reuse a request-signing key for webhook signing (per `adcp_use` purpose separation). +Use `signing.ProfileWebhookSigning` for the webhook RFC 9421 profile. Publish the corresponding public key with `adcp_use: "request-signing"`; if you want webhook-only key material, use a separate `kid`. @@ -172,7 +172,7 @@ Serve a JSON Web Key Set at a stable HTTPS URL (defaults to `/.well-known/jwks.j } ``` -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. +Only public keys go here — no `d` field. Set `Cache-Control: max-age=3600` or similar. If you use separate key material for webhook delivery, publish a second `request-signing` JWK with a distinct `kid`. Deprecated `webhook-signing` JWKs remain accepted on the webhook path for backward compatibility. ### brand.json @@ -545,8 +545,9 @@ import ( ) // Mount the same Middleware on your webhook receiver, but configure it for -// the webhook profile — adcp_use="webhook-signing", Content-Digest required, -// no required_for gating (webhooks always carry signatures). +// the webhook profile — adcp_use="request-signing" (deprecated +// "webhook-signing" also accepted), Content-Digest required, no required_for +// gating (webhooks always carry signatures). webhookMW := signing.Middleware(signing.MiddlewareOptions{ Resolver: signing.NewBrandJSONJWKSResolver(), Replay: signing.NewMemoryReplayStore(0), @@ -632,7 +633,7 @@ webhookClient := &http.Client{ -Publish a separate JWK with `"adcp_use": "webhook-signing"` in your JWKS alongside your request-signing key. Never reuse the same key for both purposes — receivers enforce purpose at the JWK `adcp_use` level, not the RFC 9421 tag. +Publish the webhook signing public key as a JWK with `"adcp_use": "request-signing"`. The webhook verifier still accepts deprecated `"webhook-signing"` keys for backward compatibility, but new signers should use `request-signing`. If you want independent webhook rotation or blast-radius isolation, publish a separate `request-signing` JWK with a webhook-specific `kid`. ## Step 7: Declare the capability diff --git a/docs/building/concepts/security-model.mdx b/docs/building/concepts/security-model.mdx index ab6c1b6062..b348c4ff72 100644 --- a/docs/building/concepts/security-model.mdx +++ b/docs/building/concepts/security-model.mdx @@ -133,16 +133,16 @@ Key properties: - **Revocation.** Governance agents publish a signed revocation list at a well-known path. Compromised keys and rescinded plans can be invalidated without trusting the CDN serving the list. - **Retention.** Revoked public keys remain discoverable for 7+ years so historical tokens remain verifiable after rotation. -- **Approval provenance.** Because the governance attestation is signed and public-key-verifiable, any party holding the artifact can verify it was approved by the holder of the signing key at the stated time. This approaches non-repudiation — but only conditionally. The buyer cannot later claim the plan was *never approved* so long as (a) the signing key was not compromised at time-of-signing (revocation lists bound this — a post-hoc claim of "the key was already stolen when I signed" is falsifiable against the revocation timeline) and (b) the signer retains ordinary custody of its signing key. The seller side is *weaker*: an attestation proves the plan existed, not that it was delivered or acknowledged. For full bilateral non-repudiation, the seller should emit a signed `plan_receipt` binding `{plan_id, received_at, plan_sha256}` as a signed webhook on the `adcp_use: "webhook-signing"` surface — durable seller-side acknowledgement flows through the same at-rest signing path as every other webhook artifact (see [What gets signed](#what-gets-signed--and-what-doesnt) below). Absent a signed receipt, "never received" remains deniable. +- **Approval provenance.** Because the governance attestation is signed and public-key-verifiable, any party holding the artifact can verify it was approved by the holder of the signing key at the stated time. This approaches non-repudiation — but only conditionally. The buyer cannot later claim the plan was *never approved* so long as (a) the signing key was not compromised at time-of-signing (revocation lists bound this — a post-hoc claim of "the key was already stolen when I signed" is falsifiable against the revocation timeline) and (b) the signer retains ordinary custody of its signing key. The seller side is *weaker*: an attestation proves the plan existed, not that it was delivered or acknowledged. For full bilateral non-repudiation, the seller should emit a signed `plan_receipt` binding `{plan_id, received_at, plan_sha256}` as a signed webhook using an `adcp_use: "request-signing"` key — durable seller-side acknowledgement flows through the same at-rest webhook path as every other webhook artifact (see [What gets signed](#what-gets-signed--and-what-doesnt) below). Absent a signed receipt, "never received" remains deniable. **What this defends against.** After-the-fact tampering. Claim drift between parties in a dispute. Regulatory inquiries that arrive long after the credentials have rotated. ## What gets signed — and what doesn't -Five application-layer signing systems exist in 3.x — four sharing the JWKS publication pattern, plus TMP's own envelope: +Five application-layer signing surfaces exist in 3.x — four sharing the JWKS publication pattern, plus TMP's own envelope: - **Inbound request signing.** Buyers (and sellers acting as buyer-side clients) sign their outbound tool calls with [RFC 9421](/docs/building/by-layer/L1/security#request-signing). Key purpose `adcp_use: "request-signing"`. -- **Outbound webhook signing.** Sellers sign asynchronous [webhook deliveries](/docs/building/by-layer/L1/security#webhook-callbacks) — task completion, status changes, downstream events, and any specialism-scoped durable artifact (brand-rights, AAO Verified compliance, sales-intelligence relay, governance receipts). RFC 9421. Key purpose `adcp_use: "webhook-signing"`. +- **Outbound webhook signing.** Sellers sign asynchronous [webhook deliveries](/docs/building/by-layer/L1/security#webhook-callbacks) — task completion, status changes, downstream events, and any specialism-scoped durable artifact (brand-rights, AAO Verified compliance, sales-intelligence relay, governance receipts). RFC 9421. Key purpose `adcp_use: "request-signing"`; deprecated `adcp_use: "webhook-signing"` keys remain accepted on the webhook path for backward compatibility. - **Governance attestation signing.** Governance agents sign the JWS tokens that authorize spend (Layer 4 above). Distinct profile from RFC 9421, with its own key purpose (`adcp_use: "governance-signing"`), JWKS, and revocation list. - **Designated-task response payload signing.** A closed list of tasks — currently `verify_brand_claim` and its bulk variant `verify_brand_claims` — sign their response *payload* as a [JWS envelope](/docs/building/by-layer/L1/security#request-signing) carried inside the response body. Key purpose `adcp_use: "response-signing"`. The signature is load-bearing for the brand-protocol direction-asymmetric trust model; receivers parse the response body and verify the JWS against the responding agent's published key. This is payload-envelope JWS, not RFC 9421 §2.2.9 transport response signing — the latter is not defined in 3.x for any task, including the designated ones. See [Brand Protocol: trust model](/docs/brand-protocol/tasks/verify_brand_claim#trust-model). - **Trusted Match Protocol envelope.** TMP signs match-time requests with its own Ed25519 envelope, scaled to TMP's per-request budget (sample-verify at ~5%); it shares JWKS publication with the four surfaces above but is its own profile. See [TMP signing model](/docs/trusted-match/specification#signing-model). @@ -151,21 +151,21 @@ Five application-layer signing systems exist in 3.x — four sharing the JWKS pu ### Why the split is deliberate -This is two surfaces with two purposes, not one surface with a coverage gap. +This is two delivery surfaces with one RFC 9421 signing purpose, not a synchronous-response coverage gap. - **TLS-scoped synchronous + signed-webhook async is the design.** The synchronous reply is consumed inside the authenticated session that carried the request — the buyer holds TLS-bound proof that the seller's authenticated edge answered, with the same edge-termination caveats that govern request-side body integrity at body-modifying CDNs (see [Transport security: edge-termination](/docs/building/by-layer/L1/security#what-this-section-does-not-replace)). A signature on the body would protect against post-edge tampering but does not protect against anything the authenticated TLS session hasn't already established between the parties' authenticated edges. At-rest integrity, by contrast, is what webhooks are for: the artifact survives past the original transport and verifies long after the session has closed, against keys that long-outlive the TCP connection. - **Webhook-only is a forcing function for sellers.** Making "this artifact needs at-rest integrity" an *explicit modeling decision* — emit a webhook — rather than a free rider on every reply pushes operators toward intentional design. Sellers who would otherwise reach for a generic response-signing primitive "for completeness," without asking *which* artifacts actually warrant attestation, are pushed to make the call up front. -- **Doubling the signing surface is operationally fragile.** Every additional `adcp_use` purpose is another key in the JWKS, another rotation cycle, another verifier code path, another conformance grader, another revocation entry to monitor. The cost is borne by every adopter for every deployment; the benefit accrues to a narrow set of audit and forwarding flows that have a cleaner path through webhooks. Net negative across the ecosystem. +- **Doubling the signing surface is operationally fragile.** Every additional signing purpose or profile is another key declaration in the JWKS, another rotation cycle, another verifier code path, another conformance grader, another revocation entry to monitor. The cost is borne by every adopter for every deployment; the benefit accrues to a narrow set of audit and forwarding flows that have a cleaner path through webhooks. Net negative across the ecosystem. ### Cases that look like response signing but aren't - **Audit and forensics on tool-call replies.** A buyer that needs to attest "the seller said X at time T" requests the artifact via a webhook-emitting tool path, not via the synchronous reply. The asymmetry forces the right design question: which replies actually need at-rest integrity? In practice, fewer than instinct suggests. -- **Cross-agent forwarding** (sales-intelligence relay, brand-rights handoff, AAO Verified compliance attestations). The durable artefact in each of these flows rides the standard `adcp_use: "webhook-signing"` surface — there is no per-specialism `adcp_use` value in 3.x, and no general-purpose response-signing primitive (the closed designated-task list above is the only response-payload-signing surface, and these flows aren't on it). The specialism delivers its attestable payload as a signed webhook from its own agent; that's already the answer. -- **Bilateral non-repudiation receipts** (e.g., a seller's signed `plan_receipt` binding `{plan_id, received_at, plan_sha256}`). Where the spec recommends the seller emit such a receipt, it is delivered as a signed webhook on the same `adcp_use: "webhook-signing"` surface — not on the synchronous reply that acknowledged the inbound governance attestation. +- **Cross-agent forwarding** (sales-intelligence relay, brand-rights handoff, AAO Verified compliance attestations). The durable artifact in each of these flows rides the standard signed-webhook path using `adcp_use: "request-signing"` — there is no per-specialism `adcp_use` value in 3.x, and no general-purpose response-signing primitive (the closed designated-task list above is the only response-payload-signing surface, and these flows aren't on it). The specialism delivers its attestable payload as a signed webhook from its own agent; that's already the answer. +- **Bilateral non-repudiation receipts** (e.g., a seller's signed `plan_receipt` binding `{plan_id, received_at, plan_sha256}`). Where the spec recommends the seller emit such a receipt, it is delivered as a signed webhook using the same `adcp_use: "request-signing"` key purpose — not on the synchronous reply that acknowledged the inbound governance attestation. ### The request-the-webhook pattern -If you genuinely need an attestable artifact from a tool that today returns it synchronously, the spec-supported path is to structure the tool to emit a signed webhook carrying the canonical version, and treat the synchronous reply as transport-only acknowledgement. The buyer registers a webhook on the request; the seller delivers the durable artifact via that webhook with `adcp_use: "webhook-signing"`. Verification is uniform with every other at-rest seller→buyer message, no new specialism, no new grader. +If you genuinely need an attestable artifact from a tool that today returns it synchronously, the spec-supported path is to structure the tool to emit a signed webhook carrying the canonical version, and treat the synchronous reply as transport-only acknowledgement. The buyer registers a webhook on the request; the seller delivers the durable artifact via that webhook with `adcp_use: "request-signing"` (deprecated `webhook-signing` keys are still accepted during the compatibility window). Verification is uniform with every other at-rest seller-to-buyer message, no new specialism, no new grader. Some 3.x tools that today return durable artifacts synchronously (e.g., `acquire_rights` returning `rights_constraint` and `generation_credentials` on the synchronous reply, or any tool whose seller-side receipt is currently delivered inline) are candidates to either restructure under this pattern or accept that their durable-integrity path is the webhook variant — not a future synchronous-response signature. From 19f50815ac778814a01906e380f99a4fcb3dedf7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 16 Jun 2026 09:04:28 +0200 Subject: [PATCH 08/10] fix(compliance): accept request-signing webhook keys --- .../test-vectors/webhook-signing/README.md | 6 +- .../source/universal/webhook-emission.yaml | 58 +++++++++++-------- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/static/compliance/source/test-vectors/webhook-signing/README.md b/static/compliance/source/test-vectors/webhook-signing/README.md index d34739049a..7eaeebd7d3 100644 --- a/static/compliance/source/test-vectors/webhook-signing/README.md +++ b/static/compliance/source/test-vectors/webhook-signing/README.md @@ -14,7 +14,7 @@ Specification: [Webhook callbacks](https://adcontextprotocol.org/docs/building/b ## Scope -These vectors exercise the [webhook verifier checklist](https://adcontextprotocol.org/docs/building/by-layer/L1/security#verifier-checklist-for-webhooks) and the RFC 9421 profile constraints specific to webhooks: required covered components (content-digest is REQUIRED, no policy branch), the distinct `tag="adcp/webhook-signing/v1"`, the `adcp_use: "webhook-signing"` key-purpose discriminator, and the `webhook_signature_*` error taxonomy. They do not exercise live JWKS fetch, brand.json discovery, or revocation-list polling — those require live endpoints and belong in integration suites. +These vectors exercise the [webhook verifier checklist](https://adcontextprotocol.org/docs/building/by-layer/L1/security#verifier-checklist-for-webhooks) and the RFC 9421 profile constraints specific to webhooks: required covered components (content-digest is REQUIRED, no policy branch), the distinct `tag="adcp/webhook-signing/v1"`, the webhook-valid `adcp_use` purpose set (`"request-signing"` plus deprecated `"webhook-signing"`), and the `webhook_signature_*` error taxonomy. They do not exercise live JWKS fetch, brand.json discovery, or revocation-list polling — those require live endpoints and belong in integration suites. Vectors cover the receiver side (buyer verifying inbound webhooks). Sender-side grading — does the agent-under-test emit conformant signatures on live traffic — is handled by the [`webhook-emission` universal](https://adcontextprotocol.org/compliance/latest/universal/webhook-emission) via a runner that hosts a receiver during storyboard execution. @@ -33,8 +33,8 @@ The distinct surface is the purpose-discriminator chain: `adcp_use` MUST be `"re ``` test-vectors/webhook-signing/ ├── README.md this file -├── keys.json test keypairs (Ed25519 + ES256) with adcp_use: "webhook-signing", -│ plus a request-signing key reused by positive vector +├── keys.json test keypairs (Ed25519 + ES256) with webhook-valid adcp_use values, +│ including a request-signing key reused by positive vector │ 008-request-signing-key-reuse, a response-signing key for negative │ vector 008-wrong-adcp-use, and a revoked key for vector 017 ├── negative/ vectors that MUST fail verification diff --git a/static/compliance/source/universal/webhook-emission.yaml b/static/compliance/source/universal/webhook-emission.yaml index 41710c54db..bc5d61f536 100644 --- a/static/compliance/source/universal/webhook-emission.yaml +++ b/static/compliance/source/universal/webhook-emission.yaml @@ -66,11 +66,14 @@ narrative: | token such as `{{runner.webhook_url:...}}` to the agent under test. **9421 is the on-ramp.** Any agent advertising webhook-emitting operations - MUST publish a `webhook-signing` JWKS at its `brand.json` `agents[].jwks_uri` - and MUST emit valid 9421 signatures when triggered with no `authentication` - block on `push_notification_config`. Agents without published signing keys - fail the `signing_keys_published` precheck before the signature phase runs; - agents with keys but invalid signatures fail `signature_validity`. The + MUST publish a webhook-valid signing key at its `brand.json` + `agents[].jwks_uri` and MUST emit valid 9421 signatures when triggered with + no `authentication` block on `push_notification_config`. The canonical key + purpose is `adcp_use: "request-signing"`; deprecated + `adcp_use: "webhook-signing"` keys remain accepted on the webhook path for + backward compatibility. Agents without published signing keys fail the + `signing_keys_published` precheck before the signature phase runs; agents + with keys but invalid signatures fail `signature_validity`. The deprecated HMAC fallback remains a buyer-side registration option in 3.x (`push_notification_config.authentication`), but it is not a path that exempts the agent from publishing 9421 keys — the runner registers as a @@ -115,9 +118,11 @@ prerequisites: skipped cleanly, not graded as failures. Agents advertising webhook-emitting operations MUST publish a JWKS at the - `jwks_uri` on their brand.json `agents[]` entry containing a key with - `adcp_use: "webhook-signing"`. The `signing_keys_published` precheck - asserts this directly; agents without published signing keys fail + `jwks_uri` on their brand.json `agents[]` entry containing a webhook-valid + signing key. The canonical purpose is `adcp_use: "request-signing"`; + deprecated `adcp_use: "webhook-signing"` keys remain accepted for backward + compatibility. The `signing_keys_published` precheck asserts this + directly; agents without published signing keys fail `webhook_signing_keys_unpublished` before the signature phase runs. test_kit: "test-kits/webhook-receiver-runner.yaml" @@ -589,21 +594,24 @@ phases: registered operation_id, it will not match this payload assertion. - id: signing_keys_published - title: "Agent publishes a webhook-signing JWKS at brand.json" + title: "Agent publishes a webhook-valid signing JWKS at brand.json" narrative: | Precheck: any agent advertising webhook-emitting operations MUST publish a JWKS at the `jwks_uri` on its `brand.json` `agents[]` entry containing - at least one key with `adcp_use: "webhook-signing"`. This is the on-ramp - gate — an agent that has only ever signed HMAC and never published a - 9421 signing key fails here, before the signature phase even runs. + at least one webhook-valid signing key: canonical + `adcp_use: "request-signing"`, or deprecated + `adcp_use: "webhook-signing"` for backward compatibility. This is the + on-ramp gate — an agent that has only ever signed HMAC and never + published a 9421 signing key fails here, before the signature phase even + runs. The runner resolves the agent's `brand.json` from its `get_adcp_capabilities` advertisement (or, for agents not yet member- registered, from operator configuration), fetches the JWKS at - `agents[].jwks_uri`, and asserts at least one key has - `adcp_use: "webhook-signing"` and a non-revoked status. Absent or - malformed JWKS produces `webhook_signing_keys_unpublished`; JWKS present - but with no webhook-signing key produces + `agents[].jwks_uri`, and asserts at least one key has a webhook-valid + `adcp_use` value and a non-revoked status. Absent or malformed JWKS + produces `webhook_signing_keys_unpublished`; JWKS present but with no + webhook-valid signing key produces `webhook_signing_keys_wrong_purpose`. This phase is observable-only and stateless — no traffic flows. It runs @@ -628,23 +636,23 @@ phases: `agents_jwks_uri_missing`. - id: assert_webhook_signing_key_present - title: "Assert JWKS contains a webhook-signing key" + title: "Assert JWKS contains a webhook-valid signing key" narrative: | Runner inspects the JWKS and asserts at least one key has + `adcp_use: "request-signing"` or deprecated `adcp_use: "webhook-signing"`. Keys advertising other purposes - (`request-signing`, `webhook-receipts`, etc.) do not satisfy this - check — purpose-separation is enforced per the `adcp_use` rule - (one cryptoKeyVersion per signing purpose; receivers enforce - purpose at JWK `adcp_use`, not RFC 9421 tag). + (`response-signing`, `governance-signing`, `webhook-receipts`, etc.) + do not satisfy this check. task: assert_jwks_purpose stateful: false expected: | At least one JWK in the agent's published JWKS carries + `adcp_use: "request-signing"` or deprecated `adcp_use: "webhook-signing"` and is not marked revoked. Fails with `webhook_signing_keys_unpublished` (no JWKS or empty JWKS), `webhook_signing_keys_wrong_purpose` (JWKS present but no key - with the webhook-signing purpose), or - `webhook_signing_keys_all_revoked` (all webhook-signing keys + with a webhook-valid signing purpose), or + `webhook_signing_keys_all_revoked` (all webhook-valid signing keys revoked). - id: signature_validity @@ -732,7 +740,7 @@ grading: synchronous completion branch set MUST pass for agents that expose get_products and advertise `media_buy.buying_modes` including `"wholesale"`; otherwise the branch steps grade not_applicable. - Agents without a published webhook-signing JWKS fail + Agents without a published webhook-valid signing JWKS fail signing_keys_published — there is no exemption for "HMAC legacy mode." Agents that advertise no webhook-emitting operations grade the entire universal as not_applicable. Runners without a webhook receiver grade the @@ -747,5 +755,5 @@ grading: the full suite. signing_keys_published failures cite the specific gap — `webhook_signing_keys_unpublished`, `webhook_signing_keys_wrong_purpose`, or `webhook_signing_keys_all_revoked` — so operators distinguish "never - set up keys" from "set up keys with the wrong purpose label" without + set up keys" from "set up keys with only non-webhook-valid purpose labels" without reading the JWKS by hand. From bdc5bfb086ee5f38ce4ef036248c804c50ac3cef Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 16 Jun 2026 10:06:25 +0200 Subject: [PATCH 09/10] docs: close webhook signing follow-ups --- ...webhook-allow-request-signing-key-reuse.md | 2 + docs/accounts/tasks/sync_accounts.mdx | 2 +- docs/brand-protocol/tasks/acquire_rights.mdx | 2 +- docs/building/by-layer/L3/webhooks.mdx | 8 +- .../building/operating/seller-integration.mdx | 2 +- .../collection/tasks/collection_lists.mdx | 2 +- docs/intro.mdx | 2 +- docs/reference/known-limitations.mdx | 2 +- .../migration/prerelease-upgrades.mdx | 4 +- docs/reference/release-notes.mdx | 4 +- docs/reference/test-vectors/index.mdx | 4 +- docs/reference/whats-new-in-v3.mdx | 2 +- package.json | 3 +- .../universal/webhook-receiver-envelope.yaml | 2 +- .../source/account/sync-accounts-request.json | 2 +- .../source/core/push-notification-config.json | 2 +- .../source/core/webhook-challenge.json | 4 +- tests/webhook-signing-vectors.test.cjs | 93 +++++++++++++++++++ 18 files changed, 119 insertions(+), 23 deletions(-) create mode 100644 tests/webhook-signing-vectors.test.cjs diff --git a/.changeset/webhook-allow-request-signing-key-reuse.md b/.changeset/webhook-allow-request-signing-key-reuse.md index 0b10be1881..8d6880f64d 100644 --- a/.changeset/webhook-allow-request-signing-key-reuse.md +++ b/.changeset/webhook-allow-request-signing-key-reuse.md @@ -7,3 +7,5 @@ Webhooks are signed with the agent's `request-signing` key — there is no separ The relaxation is one-directional and safe: cross-protocol confusion is prevented by the RFC 9421 `tag` (`adcp/webhook-signing/v1`, part of the signed base, checked at step 3) and mandatory `content-digest` coverage — not by the key-purpose discriminator. A captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3, so it can never be replayed as a webhook. The reverse remains forbidden: a webhook-signing key MUST NOT verify a request signature (request verification still requires `adcp_use == "request-signing"` exactly). Conformance vectors updated: former negative `webhook-signing/negative/008-wrong-adcp-use` (request-signing key rejected) becomes positive `webhook-signing/positive/008-request-signing-key-reuse` (accepted); a new negative `008-wrong-adcp-use` covers a `response-signing` key, still rejected. + +Semver note: this is `minor` because it widens verifier acceptance and deprecates the old key purpose without removing any wire-compatible signer or verifier behavior. The future removal of `"webhook-signing"` from the accepted webhook key-purpose set is tracked in adcontextprotocol/adcp#5555 and will be a major-version change. diff --git a/docs/accounts/tasks/sync_accounts.mdx b/docs/accounts/tasks/sync_accounts.mdx index f790cb6e78..e9bf6b67ec 100644 --- a/docs/accounts/tasks/sync_accounts.mdx +++ b/docs/accounts/tasks/sync_accounts.mdx @@ -204,7 +204,7 @@ Each entry has: Before persisting or echoing an entry as `active: true`, the seller MUST validate the URL, apply the SSRF rules in [Webhook URL validation](/docs/building/by-layer/L1/security#webhook-url-validation-ssrf), and prove that the receiver controls the endpoint. -Proof is required when there is no current valid proof for the tuple `(account_id, subscriber_id, normalized url, authentication mode/credential binding, normalized event_types)`. Changing the subscriber ID, normalized URL, authentication mode/credential binding, or `event_types[]` requires fresh proof before the new set can become active. The challenge POST itself MUST be signed with the seller's RFC 9421 webhook-signing key even when the candidate config selects legacy delivery auth. The receiver MUST verify the RFC 9421 signature and MUST reject the challenge unless `seller_agent_url`, `delivery_auth`, and `event_types` match the pending registration. Sellers MAY re-challenge on their own proof-expiration policy. +Proof is required when there is no current valid proof for the tuple `(account_id, subscriber_id, normalized url, authentication mode/credential binding, normalized event_types)`. Changing the subscriber ID, normalized URL, authentication mode/credential binding, or `event_types[]` requires fresh proof before the new set can become active. The challenge POST itself MUST be signed with the seller's RFC 9421 webhook profile key even when the candidate config selects legacy delivery auth. New signers use `adcp_use: "request-signing"`; deprecated `webhook-signing` keys remain accepted during the compatibility window. The receiver MUST verify the RFC 9421 signature and MUST reject the challenge unless `seller_agent_url`, `delivery_auth`, and `event_types` match the pending registration. Sellers MAY re-challenge on their own proof-expiration policy. The standard challenge is an HTTPS POST to the candidate `url` with a JSON body containing `type`, `challenge`, `account_id`, `subscriber_id`, `seller_agent_url`, `delivery_auth`, and `event_types`. The canonical schemas are [`webhook-challenge.json`](https://adcontextprotocol.org/schemas/v3/core/webhook-challenge.json) and [`webhook-challenge-response.json`](https://adcontextprotocol.org/schemas/v3/core/webhook-challenge-response.json). diff --git a/docs/brand-protocol/tasks/acquire_rights.mdx b/docs/brand-protocol/tasks/acquire_rights.mdx index 94b0f244ac..2f6e57779c 100644 --- a/docs/brand-protocol/tasks/acquire_rights.mdx +++ b/docs/brand-protocol/tasks/acquire_rights.mdx @@ -288,7 +288,7 @@ Partial revocation is supported — if `revoked_uses` is present, only those use Return HTTP `200` immediately upon receiving and validating a revocation notification. The rights holder retries on non-`2xx` responses using exponential backoff (1s, 5s, 30s, 5m, 30m). After 6 failed attempts, the rights holder may escalate through other channels. -All webhook signing follows the AdCP [push notification signing profile](/docs/building/implementation/webhooks#signature-verification) — RFC 9421 by default (rights agent signs with its `adcp_use: "webhook-signing"` key published at its brand.json `agents[]` entry), with the deprecated HMAC-SHA256 fallback available when the rights holder populates `authentication.credentials` on the webhook registration. +All webhook signing follows the AdCP [push notification signing profile](/docs/building/implementation/webhooks#signature-verification) — RFC 9421 by default (rights agent signs with its `adcp_use: "request-signing"` key published at its brand.json `agents[]` entry; deprecated `webhook-signing` keys remain accepted during the compatibility window), with the deprecated HMAC-SHA256 fallback available when the rights holder populates `authentication.credentials` on the webhook registration. ## Impression caps and overage diff --git a/docs/building/by-layer/L3/webhooks.mdx b/docs/building/by-layer/L3/webhooks.mdx index e7571c801e..f1f0584b01 100644 --- a/docs/building/by-layer/L3/webhooks.mdx +++ b/docs/building/by-layer/L3/webhooks.mdx @@ -11,7 +11,7 @@ Push notifications let sellers deliver task status updates to you directly, inst 1. A unique operation ID is generated per task invocation for webhook correlation 2. A webhook URL is built for your receiver. The URL may contain your own routing token, but it is opaque to the seller 3. `push_notification_config` is injected into the task request body with the URL and explicit `operation_id` — no shared secret required -4. The seller POSTs webhook notifications to your URL as the task status changes, signing each POST with its `adcp_use: "webhook-signing"` key published in its own brand.json `agents[]` entry +4. The seller POSTs webhook notifications to your URL as the task status changes, signing each POST with its `adcp_use: "request-signing"` key published in its own brand.json `agents[]` entry; deprecated `webhook-signing` keys remain accepted during the compatibility window 5. You verify the signature against the seller's published JWKS and dedupe by `idempotency_key` 6. Each notification echoes the explicit `operation_id` back in the payload so you can correlate it without parsing the URL @@ -42,7 +42,7 @@ POST https://you.com/adcp/webhook/create_media_buy/agent_123/route_abc123 } ``` -If you're using the `@adcp/sdk` library, this entire flow is handled automatically. As a **buyer**, configure `webhookUrlTemplate` and your agent URL on the client; `push_notification_config` is injected into every outgoing task call, and incoming webhooks are verified against the seller's JWKS automatically. As a **seller emitting webhooks**, publish a webhook-signing JWK at your brand.json `agents[]` entry (with `adcp_use: "webhook-signing"`) and the client signs outgoing webhooks for you. +If you're using the `@adcp/sdk` library, this entire flow is handled automatically. As a **buyer**, configure `webhookUrlTemplate` and your agent URL on the client; `push_notification_config` is injected into every outgoing task call, and incoming webhooks are verified against the seller's JWKS automatically. As a **seller emitting webhooks**, publish a signing JWK at your brand.json `agents[]` entry. New signers use `adcp_use: "request-signing"`; if you want webhook-only key material, publish a second `request-signing` JWK with a distinct `kid`. :::warning Legacy HMAC fallback (deprecated) Buyers integrating with receivers that have not yet adopted the RFC 9421 webhook profile MAY opt into the legacy HMAC-SHA256 scheme by populating `push_notification_config.authentication.credentials`. That path is deprecated and removed in AdCP 4.0 — see [Legacy HMAC-SHA256 fallback](#legacy-hmac-sha256-fallback-deprecated) below. Because the inbound request that registers the webhook is typically not 9421-signed in 3.0, the `authentication` block is susceptible to on-path strip/inject — see [Downgrade and injection resistance](/docs/building/by-layer/L1/security#webhook-callbacks) for the operational mitigations. @@ -80,7 +80,7 @@ Include `push_notification_config` as a task argument, merged with the rest of y } ``` -`authentication` is omitted in the default case — the seller signs with its own `adcp_use: "webhook-signing"` key. Include `authentication.credentials` only if you need the legacy HMAC-SHA256 fallback. +`authentication` is omitted in the default case — the seller signs with its own `adcp_use: "request-signing"` key. Deprecated `webhook-signing` keys remain accepted during the compatibility window. Include `authentication.credentials` only if you need the legacy HMAC-SHA256 fallback. ### A2A @@ -321,7 +321,7 @@ The two channels are independent. A buyer MAY register both for the same task an ## Signature verification -Every AdCP 3.0 webhook is signed under the [RFC 9421 webhook profile](/docs/building/by-layer/L1/security#webhook-callbacks). The seller signs with its `adcp_use: "webhook-signing"` key published in its own brand.json `agents[]` entry; you verify against the seller's published JWKS. No shared secret crosses the wire. +Every AdCP 3.0 webhook is signed under the [RFC 9421 webhook profile](/docs/building/by-layer/L1/security#webhook-callbacks). The seller signs with its `adcp_use: "request-signing"` key published in its own brand.json `agents[]` entry; deprecated `webhook-signing` keys remain accepted during the compatibility window. You verify against the seller's published JWKS. No shared secret crosses the wire. **Publisher sends three headers** (plus `Content-Type`): diff --git a/docs/building/operating/seller-integration.mdx b/docs/building/operating/seller-integration.mdx index 1d3ddd46b6..56ba7ed284 100644 --- a/docs/building/operating/seller-integration.mdx +++ b/docs/building/operating/seller-integration.mdx @@ -98,7 +98,7 @@ For the full sell-side setup pattern, including direct publisher and delegated n } ``` -The `agents[].jwks_uri` lets buyers resolve your public signing keys. The protocol requires verifiable signatures and discoverable public keys; it does not require a specific KMS vendor. Production agents should back private signing keys with KMS/HSM or a managed secret system. Sellers that send AdCP webhooks should publish a webhook-signing JWK with `adcp_use: "webhook-signing"` in that JWKS so buyers can verify outbound webhook signatures. See [request signing](/docs/building/by-layer/L1/request-signing) for the key-publication and webhook-signing profile. +The `agents[].jwks_uri` lets buyers resolve your public signing keys. The protocol requires verifiable signatures and discoverable public keys; it does not require a specific KMS vendor. Production agents should back private signing keys with KMS/HSM or a managed secret system. Sellers that send AdCP webhooks should publish an `adcp_use: "request-signing"` JWK in that JWKS so buyers can verify outbound webhook signatures. Deprecated `adcp_use: "webhook-signing"` keys remain accepted on the webhook path for backward compatibility. See [request signing](/docs/building/by-layer/L1/request-signing) for the key-publication and webhook-signing profile. ### Expose your inventory diff --git a/docs/governance/collection/tasks/collection_lists.mdx b/docs/governance/collection/tasks/collection_lists.mdx index 483be13c74..f9d1cab1da 100644 --- a/docs/governance/collection/tasks/collection_lists.mdx +++ b/docs/governance/collection/tasks/collection_lists.mdx @@ -332,7 +332,7 @@ Collection lists gate delivery decisions, so the `auth_token` and webhook callba **Webhook URL validation.** The `webhook_url` on `update_collection_list` is SSRF-equivalent to any other buyer-provided callback URL. Apply the canonical [Webhook URL validation (SSRF)](/docs/building/implementation/security#webhook-url-validation-ssrf) rules — HTTPS only, validated IP ranges (IPv4 and IPv6 including `::ffff:0:0/96`), connection pinning (not just DNS re-resolution), no redirect following, size and timeout caps. -**Webhook signature algorithm.** The webhook signature MUST follow the [standard webhook signing rules](/docs/building/implementation/security#webhook-security). By default, the RFC 9421 [webhook callbacks profile](/docs/building/implementation/security#webhook-callbacks) applies: the governance agent signs with its `adcp_use: "webhook-signing"` key published at the `jwks_uri` of its `agents[]` entry in its own brand.json; the subscribing seller verifies covered components `@method`, `@target-uri`, `@authority`, `content-type`, `content-digest`, with `tag="adcp/webhook-signing/v1"`. The deprecated HMAC-SHA256 fallback applies only when the subscribing seller populates `authentication.credentials` on the webhook registration; that path follows the [Legacy HMAC-SHA256 fallback](/docs/building/implementation/security#legacy-hmac-sha256-fallback-deprecated-removed-in-40) rules, and any body `signature` field under that path is a convenience copy — recipients MUST verify against the headers and MUST NOT trust the body value. +**Webhook signature algorithm.** The webhook signature MUST follow the [standard webhook signing rules](/docs/building/implementation/security#webhook-security). By default, the RFC 9421 [webhook callbacks profile](/docs/building/implementation/security#webhook-callbacks) applies: the governance agent signs with its `adcp_use: "request-signing"` key published at the `jwks_uri` of its `agents[]` entry in its own brand.json; deprecated `webhook-signing` keys remain accepted during the compatibility window. The subscribing seller verifies covered components `@method`, `@target-uri`, `@authority`, `content-type`, `content-digest`, with `tag="adcp/webhook-signing/v1"`. The deprecated HMAC-SHA256 fallback applies only when the subscribing seller populates `authentication.credentials` on the webhook registration; that path follows the [Legacy HMAC-SHA256 fallback](/docs/building/implementation/security#legacy-hmac-sha256-fallback-deprecated-removed-in-40) rules, and any body `signature` field under that path is a convenience copy — recipients MUST verify against the headers and MUST NOT trust the body value. **Distribution-ID inputs.** Governance agents SHOULD validate identifier format before persisting (IMDb: `^tt\d+$`, EIDR: `10.5240/...`, Gracenote: vendor-prefixed) and SHOULD enforce per-account rate limits on list mutations to prevent list-bloat DoS. Surface unresolved identifiers in `coverage_gaps` rather than silently dropping them. diff --git a/docs/intro.mdx b/docs/intro.mdx index 05ce8a3152..bbea298044 100644 --- a/docs/intro.mdx +++ b/docs/intro.mdx @@ -284,7 +284,7 @@ For sellers that generate creative — AI assistants, conversational ad platform `update_media_buy` handles mid-flight changes: shift budget between packages, adjust flight dates, swap creative assignments. No need to cancel and recreate. -That `idempotency_key` on Sam's request isn't decorative. Pinnacle's buyer agent signs the POST with RFC 9421 HTTP Message Signatures before it leaves the network. StreamHaus verifies Pinnacle's signature against Pinnacle's operator-published JWKS, then accepts the buy. When the campaign moves from `pending_start` to `active`, StreamHaus posts a signed webhook back to Pinnacle's orchestrator — same signature profile, with StreamHaus's webhook-signing key published through its `brand.json` `agents[]` entry and optionally pinned by publisher `adagents.json`. If Sam's laptop drops the response and his agent retries, the `idempotency_key` makes the second call safe — StreamHaus returns the original buy with `replayed: true` instead of charging twice. Governance approvals ride along as signed JWS tokens on `check_governance` so no agent in the chain can forge Jordan's sign-off. See the [Security guide](/docs/building/implementation/security). +That `idempotency_key` on Sam's request isn't decorative. Pinnacle's buyer agent signs the POST with RFC 9421 HTTP Message Signatures before it leaves the network. StreamHaus verifies Pinnacle's signature against Pinnacle's operator-published JWKS, then accepts the buy. When the campaign moves from `pending_start` to `active`, StreamHaus posts a signed webhook back to Pinnacle's orchestrator — same signature profile, with StreamHaus's request-signing key published through its `brand.json` `agents[]` entry and optionally pinned by publisher `adagents.json`. If Sam's laptop drops the response and his agent retries, the `idempotency_key` makes the second call safe — StreamHaus returns the original buy with `replayed: true` instead of charging twice. Governance approvals ride along as signed JWS tokens on `check_governance` so no agent in the chain can forge Jordan's sign-off. See the [Security guide](/docs/building/implementation/security). --- diff --git a/docs/reference/known-limitations.mdx b/docs/reference/known-limitations.mdx index ed08750c4b..e837342dfe 100644 --- a/docs/reference/known-limitations.mdx +++ b/docs/reference/known-limitations.mdx @@ -26,7 +26,7 @@ Surfaces shipped in 3.x but not yet frozen are listed separately — see [Experi - **No protocol-level cross-border transfer mechanism.** AdCP does not carry SCC, IDTA, or adequacy-decision metadata. International-transfer lawfulness is a contract and configuration property of the parties. - **No versioned content-provenance chain.** AdCP carries right-use assertions and the `ai_generated_image` flag — the latter is a boolean marker, not a signed provenance assertion. The protocol does not specify a cryptographically signed provenance graph that accumulates assertions as a creative passes through generation, editing, and adaptation steps. Interoperation with emerging content-authenticity standards (CAI/C2PA) is tracked as future work. - **No retention or deletion SLA.** The protocol does not specify how long parties retain `sync_audiences` inputs, `report_usage` records, or task history. Retention windows and data-subject-request fulfillment live in DPAs between the parties. -- **General-purpose synchronous RPC response signing is not defined (designated-task payload envelopes excepted) — by design, not omission.** AdCP signs five things at the application layer: inbound requests (RFC 9421, `adcp_use: "request-signing"`), outbound webhooks including all specialism-scoped durable artifacts such as brand-rights, AAO Verified compliance, sales-intelligence relay, and bilateral non-repudiation receipts (RFC 9421, `adcp_use: "webhook-signing"`), governance attestations (JWS, `adcp_use: "governance-signing"`), designated-task response payloads — currently only the `verify_brand_claim` family (JWS payload envelope, `adcp_use: "response-signing"`), and the Trusted Match Protocol envelope (TMP's own Ed25519 profile). Synchronous response bodies returned by `tools/call` (or the equivalent A2A non-streaming reply / streaming `artifactUpdate` frames) are **not** signed for tasks outside the designated-task list: integrity of the immediate reply is delivered by TLS within the authenticated session that carried the request, and at-rest integrity for durable artifacts is the job of signed webhooks. The split is deliberate — webhook-only attestation is a forcing function that makes "this artifact needs durable integrity" an explicit modeling decision rather than a free rider on every reply, and avoids the operational cost (extra JWKS entries, rotation cycles, verifier paths, conformance graders, revocation entries) of a general-purpose response-signing surface that overlaps the webhook one. **RFC 9421 §2.2.9 transport response signing is not defined for any task in 3.x.** Buyers MUST NOT rely on response signatures outside the designated-task list; artifacts requiring at-rest attestation MUST be delivered via signed webhooks. If a synchronously-returned artifact needs to be attestable, the spec-supported path is to add the task to the designated list (a normative decision) or restructure the tool to emit a signed webhook with the canonical version. Tracked in [#3737](https://github.com/adcontextprotocol/adcp/issues/3737), resolved as the intended 3.x design and revisitable in 4.0 if the threat model evolves. +- **General-purpose synchronous RPC response signing is not defined (designated-task payload envelopes excepted) — by design, not omission.** AdCP signs five things at the application layer: inbound requests (RFC 9421, `adcp_use: "request-signing"`), outbound webhooks including all specialism-scoped durable artifacts such as brand-rights, AAO Verified compliance, sales-intelligence relay, and bilateral non-repudiation receipts (RFC 9421, `adcp_use: "request-signing"`; deprecated `webhook-signing` keys remain accepted during the compatibility window), governance attestations (JWS, `adcp_use: "governance-signing"`), designated-task response payloads — currently only the `verify_brand_claim` family (JWS payload envelope, `adcp_use: "response-signing"`), and the Trusted Match Protocol envelope (TMP's own Ed25519 profile). Synchronous response bodies returned by `tools/call` (or the equivalent A2A non-streaming reply / streaming `artifactUpdate` frames) are **not** signed for tasks outside the designated-task list: integrity of the immediate reply is delivered by TLS within the authenticated session that carried the request, and at-rest integrity for durable artifacts is the job of signed webhooks. The split is deliberate — webhook-only attestation is a forcing function that makes "this artifact needs durable integrity" an explicit modeling decision rather than a free rider on every reply, and avoids the operational cost (extra verifier paths, conformance graders, revocation entries) of a general-purpose response-signing surface that overlaps the webhook one. **RFC 9421 §2.2.9 transport response signing is not defined for any task in 3.x.** Buyers MUST NOT rely on response signatures outside the designated-task list; artifacts requiring at-rest attestation MUST be delivered via signed webhooks. If a synchronously-returned artifact needs to be attestable, the spec-supported path is to add the task to the designated list (a normative decision) or restructure the tool to emit a signed webhook with the canonical version. Tracked in [#3737](https://github.com/adcontextprotocol/adcp/issues/3737), resolved as the intended 3.x design and revisitable in 4.0 if the threat model evolves. ## Commerce and settlement diff --git a/docs/reference/migration/prerelease-upgrades.mdx b/docs/reference/migration/prerelease-upgrades.mdx index 49076ccd7c..b78dcfd7a5 100644 --- a/docs/reference/migration/prerelease-upgrades.mdx +++ b/docs/reference/migration/prerelease-upgrades.mdx @@ -16,7 +16,7 @@ If you adopted a prerelease version, review the relevant section below before up | Area | rc.3 | 3.0 | What to do | |---|---|---|---| | `idempotency_key` on mutating requests | Optional | **Required** on every mutating request (schema `^[A-Za-z0-9_.:-]{16,255}$`; UUID v4 for Verified). Sellers declare `adcp.idempotency = { supported: true/false }` on capabilities. | Generate fresh key per logical operation. Persist keys across agent instances. Declare `adcp.idempotency` on `get_adcp_capabilities` (sellers). When `supported: true`, handle `IDEMPOTENCY_CONFLICT` and `IDEMPOTENCY_EXPIRED`; conformance probes require a mutated-payload replay to return CONFLICT. When `supported: false`, use natural-key checks instead of blind retries. See [Security § Idempotency](/docs/building/implementation/security). | -| Webhook signing | HMAC-SHA256 with `push_notification_config.authentication` (required) | RFC 9421 profile (baseline-required for sellers); HMAC fallback available through 3.x via `authentication.credentials` | Publish the webhook-signing JWK in your JWKS at `jwks_uri` (referenced from `brand.json` `agents[]`). Set `adcp_use: "webhook-signing"` on the JWK itself (NOT as a field on the `agents[]` entry), and keep `kid` unique across purposes within the JWKS. Drop `push_notification_config.authentication` from new configs; buyers opt into legacy HMAC via `authentication.credentials`. Receivers verify against the sender's JWKS. The entire `authentication` object (HMAC + Bearer) is removed in 4.0. | +| Webhook signing | HMAC-SHA256 with `push_notification_config.authentication` (required) | RFC 9421 profile (baseline-required for sellers); HMAC fallback available through 3.x via `authentication.credentials` | Publish a signing JWK in your JWKS at `jwks_uri` (referenced from `brand.json` `agents[]`). New signers use `adcp_use: "request-signing"` for webhooks; deprecated `adcp_use: "webhook-signing"` keys remain accepted during the compatibility window. If you want webhook-only key material, publish a second `request-signing` JWK with a distinct `kid`. Drop `push_notification_config.authentication` from new configs; buyers opt into legacy HMAC via `authentication.credentials`. Receivers verify against the sender's JWKS. The entire `authentication` object (HMAC + Bearer) is removed in 4.0. | | `idempotency_key` on webhook payloads | Not standardized (fragile `(task_id, status, timestamp)` tuple dedup) | **Required** — sender-generated UUID v4 on every payload | Sellers: generate a cryptographically-random UUID v4 per event. Receivers: dedupe on `idempotency_key` with 24h minimum TTL, sender-scoped cache. Schemas affected: `mcp-webhook-payload`, `collection-list-changed-webhook`, `property-list-changed-webhook`, `artifact-webhook-payload`, `revocation-notification`. | | `revocation-notification.notification_id` | Field name on rights revocation payload | Renamed to `idempotency_key` | Find-and-replace in your rights-revocation receivers. | | `MediaBuy.pending_approval` status | Present | Removed — approvals are explicit approval tasks | Remove `pending_approval` from media-buy state filters. Consume approval tasks from the task surface. | @@ -158,7 +158,7 @@ All request and response schemas across governance, collection, property, sponso ### Additive changes in 3.0 - **RFC 9421 request signing profile (optional in 3.0, mandatory under AdCP Verified)** — Ed25519 HTTP Message Signatures with canonicalized covered-component list. Published test vectors at `static/compliance/source/test-vectors/request-signing/`. sf-binary encoding and URL canonicalization pinned for bit-identical canonical inputs. 15-step verification checklist with `keyid` cap-before-crypto. -- **Webhook signing unified on RFC 9421** — Baseline-required for sellers emitting webhooks. Sellers publish a webhook-signing JWK in their JWKS at `jwks_uri` with `adcp_use: "webhook-signing"` on the JWK, and keep `kid` unique across purposes in the JWKS. 14-step webhook verifier checklist in the [Security guide](/docs/building/implementation/security). HMAC-SHA256 remains a legacy fallback through 3.x (the entire `authentication` object is removed in 4.0). +- **Webhook signing unified on RFC 9421** — Baseline-required for sellers emitting webhooks. Sellers publish a signing JWK in their JWKS at `jwks_uri`; new signers use `adcp_use: "request-signing"` for webhook delivery, while deprecated `webhook-signing` keys remain accepted during the compatibility window. Use a distinct `kid` if you want webhook-only key material. 14-step webhook verifier checklist in the [Security guide](/docs/building/implementation/security). HMAC-SHA256 remains a legacy fallback through 3.x (the entire `authentication` object is removed in 4.0). - **Required `idempotency_key` on every webhook payload** — Sender-generated UUID v4 across all five webhook payload schemas. Replaces fragile `(task_id, status, timestamp)` dedup. `revocation-notification.notification_id` renamed to `idempotency_key` for protocol-wide consistency. - **`check_governance` on every spend-commit** — Governance invocation is required at commit, not just at plan approval. Closes the loophole where partial spends could skip governance. - **Experimental status mechanism** — `status: experimental` marker for fields and tasks in production use but not yet under full stability guarantees. `custom` pricing-model escape hatch on signals. diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index 95d5fe1952..d6a647e78e 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -606,7 +606,7 @@ For the full per-PR change list, see [CHANGELOG.md § 3.0.1](https://github.com/ - **RFC 9421 HTTP Message Signatures** — optional in 3.0, required for AdCP Verified. Ed25519 over a canonicalized covered-component list (including `content-digest`). sf-binary and URL canonicalization pinned so independent implementations produce bit-identical canonical inputs. Verifier follows a 15-step checklist (`keyid` cap-before-crypto, SSRF-validated JWKS fetch, `jti` replay dedup, audience binding) — see the [Security guide](/docs/building/implementation/security). Published test vectors under `static/compliance/source/test-vectors/request-signing/`. (#2323, #2341, #2342, #2343) **Webhooks** — seller → buyer, same profile in reverse: - - **Webhook signing unified on the RFC 9421 profile — baseline-required for sellers emitting webhooks** — Sellers sign outbound webhooks with a key published in their JWKS at `jwks_uri` (discoverable via `brand.json` `agents[]`). The JWK carries `adcp_use: "webhook-signing"` to distinguish it from the request-signing key; `kid` values MUST be unique across purposes within a JWKS. No shared secret crosses the wire. Verification failures return typed `webhook_signature_*` reason codes defined in the Security guide. HMAC-SHA256 remains a legacy fallback through 3.x (opt-in via `push_notification_config.authentication.credentials`); removed in 4.0. (#2423) + - **Webhook signing unified on the RFC 9421 profile — baseline-required for sellers emitting webhooks** — Sellers sign outbound webhooks with a key published in their JWKS at `jwks_uri` (discoverable via `brand.json` `agents[]`). New signers use `adcp_use: "request-signing"` for webhook delivery; deprecated `adcp_use: "webhook-signing"` keys remain accepted during the compatibility window. Operators that want webhook-only key material use a distinct `kid`. No shared secret crosses the wire. Verification failures return typed `webhook_signature_*` reason codes defined in the Security guide. HMAC-SHA256 remains a legacy fallback through 3.x (opt-in via `push_notification_config.authentication.credentials`); removed in 4.0. (#2423) - **Webhook payloads carry a required `idempotency_key`** — Every webhook is dedupable by a sender-generated UUID v4, using the same `^[A-Za-z0-9_.:-]{16,255}$` format as request-side keys. Replaces fragile `(task_id, status, timestamp)` dedup across five webhook payload schemas. `revocation-notification.notification_id` renamed to `idempotency_key` for protocol-wide consistency. (#2416, #2417) **Governance** — signed authority: @@ -690,7 +690,7 @@ Brand schema extensions (`border_radius`, `elevation`, `spacing`, extended color ### Migration Notes For rc.3 Adopters -- **Webhooks** — Migrate from HMAC-SHA256 to RFC 9421 signing. Publish a webhook-signing JWK in your JWKS at `jwks_uri` (JWKS is referenced from `brand.json` `agents[]`) with `adcp_use: "webhook-signing"` on the JWK and a `kid` unique across purposes. Drop `push_notification_config.authentication` from new configs; buyers opt into legacy HMAC via `authentication.credentials`. Receivers verify against the sender's JWKS. Every outbound webhook payload must carry an `idempotency_key` matching `^[A-Za-z0-9_.:-]{16,255}$` (UUID v4 for Verified). Listeners must dedupe keyed by sender identity (signing `keyid` under 9421, or HMAC/Bearer credential under legacy) with a 24h minimum TTL. HMAC fallback remains available through 3.x; the full `authentication` object is removed in 4.0. +- **Webhooks** — Migrate from HMAC-SHA256 to RFC 9421 signing. Publish a signing JWK in your JWKS at `jwks_uri` (JWKS is referenced from `brand.json` `agents[]`). New signers use `adcp_use: "request-signing"` for webhook delivery; deprecated `webhook-signing` keys remain accepted during the compatibility window. Use a distinct `kid` if you want webhook-only key material. Drop `push_notification_config.authentication` from new configs; buyers opt into legacy HMAC via `authentication.credentials`. Receivers verify against the sender's JWKS. Every outbound webhook payload must carry an `idempotency_key` matching `^[A-Za-z0-9_.:-]{16,255}$` (UUID v4 for Verified). Listeners must dedupe keyed by sender identity (signing `keyid` under 9421, or HMAC/Bearer credential under legacy) with a 24h minimum TTL. HMAC fallback remains available through 3.x; the full `authentication` object is removed in 4.0. - **Idempotency** — Generate a fresh key on every mutating request, matching `^[A-Za-z0-9_.:-]{16,255}$` (UUID v4 for Verified). Same key + identical payload on retry → `replayed: true`. Same key + different payload → `IDEMPOTENCY_CONFLICT`. Key older than the seller-declared `replay_ttl_seconds` → `IDEMPOTENCY_EXPIRED` (1h–7d, 24h recommended — no protocol default). Your agent must persist keys across instances. - **Request signing (optional in 3.0, required for Verified)** — If you plan to claim AdCP Verified, implement RFC 9421 Ed25519 signing per the [signing profile](/docs/building/implementation/security#request-signing) and declare your signing key via the account surface. Test against `static/compliance/source/test-vectors/request-signing/` and the runner's `signed_requests` harness. - **Governance context** — Switch from opaque-string `governance_context` to the signed JWS format. Verify using the governance agent's JWKS (resolved via `sync_governance`). Bind signature to `sub` (buyer), `aud` (seller), `phase`, and `exp` before trusting. diff --git a/docs/reference/test-vectors/index.mdx b/docs/reference/test-vectors/index.mdx index 186dfb2c12..b0f69af068 100644 --- a/docs/reference/test-vectors/index.mdx +++ b/docs/reference/test-vectors/index.mdx @@ -29,7 +29,7 @@ SDKs SHOULD fetch versioned paths where available and record the version under t | Set | What it pins | Source | CDN | |---|---|---|---| | [`request-signing`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/request-signing) | RFC 9421 request-signing profile: canonical signature base, covered components, signature params, tag namespace, alg allowlist, `adcp_use` discriminator, replay dedup, revocation, content-digest semantics, and URL canonicalization | `static/compliance/source/test-vectors/request-signing/` | `/compliance/latest/test-vectors/request-signing/` | -| [`webhook-signing`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/webhook-signing) | RFC 9421 webhook-signing profile: required covered components (content-digest mandatory — no `forbidden` opt-out), `adcp/webhook-signing/v1` tag, `adcp_use: "webhook-signing"` discriminator, `webhook_signature_*` error taxonomy; shares `@target-uri` canonicalization with `request-signing` | `static/compliance/source/test-vectors/webhook-signing/` | `/compliance/latest/test-vectors/webhook-signing/` | +| [`webhook-signing`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/webhook-signing) | RFC 9421 webhook-signing profile: required covered components (content-digest mandatory — no `forbidden` opt-out), `adcp/webhook-signing/v1` tag, webhook-valid `adcp_use` set (`request-signing` plus deprecated `webhook-signing`), `webhook_signature_*` error taxonomy; shares `@target-uri` canonicalization with `request-signing` | `static/compliance/source/test-vectors/webhook-signing/` | `/compliance/latest/test-vectors/webhook-signing/` | | [`plan-hash`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/plan-hash) | JCS canonicalization of the `plan_hash` preimage: required-only baseline, full-optional, bookkeeping-stripped, omitted-vs-explicit-null, array-order sensitivity, `ext.trace_id` distinctness, Unicode non-normalization (RFC 8785 §3.2.5) | `static/compliance/source/test-vectors/plan-hash/` | `/compliance/latest/test-vectors/plan-hash/` | | [`webhook-receiver-envelope`](https://github.com/adcontextprotocol/adcp/blob/main/static/compliance/source/test-vectors/webhook-receiver-envelope.json) | Receiver-side replay vectors for full MCP webhook POST envelopes: canonical delivery-report envelope acceptance, retry idempotency preservation, and rejection of bare result payloads or malformed envelopes | `static/compliance/source/test-vectors/webhook-receiver-envelope.json` | `/compliance/latest/test-vectors/webhook-receiver-envelope.json` | | [`catalog-macro-substitution`](https://github.com/adcontextprotocol/adcp/blob/main/static/compliance/source/test-vectors/catalog-macro-substitution.json) | Catalog-item macro substitution safety: NFC normalization, RFC 3986 percent-encoding, nested-expansion preservation, CRLF neutralization, bidi override neutralization, and URL-scheme injection neutralization | `static/compliance/source/test-vectors/catalog-macro-substitution.json` | `/compliance/latest/test-vectors/catalog-macro-substitution.json` | @@ -47,7 +47,7 @@ Directory CDN paths (the three compliance-tree rows) are base paths for programm Every signing vector set ships private key material in `keys.json` so libraries can exercise signer and verifier roles against identical inputs. These keys are **valid only for grading against this suite**. -Any production verifier that trusts a `kid` declared in one of the published `keys.json` files is exploitable — the private key is on the public CDN and anyone can forge signatures under that kid. At time of writing this includes `test-ed25519-2026`, `test-es256-2026`, `test-gov-2026`, `test-revoked-2026` (request-signing) and `test-ed25519-webhook-2026`, `test-es256-webhook-2026`, `test-wrong-purpose-2026`, `test-revoked-webhook-2026` (webhook-signing). Treat every `kid` that appears in any suite `keys.json` as untrusted outside grading, present or future. +Any production verifier that trusts a `kid` declared in one of the published `keys.json` files is exploitable — the private key is on the public CDN and anyone can forge signatures under that kid. At time of writing this includes `test-ed25519-2026`, `test-es256-2026`, `test-gov-2026`, `test-revoked-2026` (request-signing) and `test-ed25519-webhook-2026`, `test-es256-webhook-2026`, `test-wrong-purpose-2026`, `test-response-purpose-2026`, `test-revoked-webhook-2026` (webhook-signing vectors). Treat every `kid` that appears in any suite `keys.json` as untrusted outside grading, present or future. Production signers mint their own keypairs and publish under their own `jwks_uri`; production verifiers MUST NOT register any test `kid` in a trust store exposed to live traffic. diff --git a/docs/reference/whats-new-in-v3.mdx b/docs/reference/whats-new-in-v3.mdx index 2085786351..0bdb4ecb11 100644 --- a/docs/reference/whats-new-in-v3.mdx +++ b/docs/reference/whats-new-in-v3.mdx @@ -59,7 +59,7 @@ AdCP 3.0 expands the protocol beyond media buying into brand identity, governanc **RFC 9421 HTTP Message Signatures are optional in 3.0 and mandatory under AdCP Verified.** Agents sign mutating requests with Ed25519 over a canonicalized covered-component list (method, target URI, `content-digest`, protocol-level fields). The spec pins sf-binary encoding and URL canonicalization so independent implementations produce bit-identical canonical inputs. A 15-step verification checklist defines the seller's path: `alg` allowlist, `keyid` cap-before-crypto (defense against unbounded verification), JWKS resolution via SSRF-validated fetch, `jti` replay dedup, audience binding. Published test vectors at `static/compliance/source/test-vectors/request-signing/` let implementers validate correctness offline. -**Webhooks are signed under the same RFC 9421 profile — baseline-required for sellers.** Webhook authentication unifies on the AdCP 9421 profile as a symmetric variant of request signing: the seller signs outbound webhook requests with a key published in its JWKS at `jwks_uri` (discoverable via `brand.json` `agents[]`). The JWK itself carries `adcp_use: "webhook-signing"` (distinct from `adcp_use: "request-signing"`); `kid` values MUST be unique across purposes within a JWKS. No shared secret crosses the wire. The buyer verifies the signature using the seller's JWKS. A 14-step webhook verifier checklist — documented in the [Security guide](/docs/building/implementation/security) — covers trust-anchor scoping, downgrade-and-injection resistance, and per-keyid replay dedup (100K per keyid, 10M aggregate); verification failures return typed reason codes defined there. HMAC-SHA256 remains a legacy fallback through 3.x (opt-in via `push_notification_config.authentication.credentials`); the entire `authentication` object is removed in 4.0. +**Webhooks are signed under the same RFC 9421 profile — baseline-required for sellers.** Webhook authentication unifies on the AdCP 9421 profile as a symmetric variant of request signing: the seller signs outbound webhook requests with a key published in its JWKS at `jwks_uri` (discoverable via `brand.json` `agents[]`). New signers use `adcp_use: "request-signing"` for webhook delivery; deprecated `adcp_use: "webhook-signing"` keys remain accepted during the compatibility window. Operators that want webhook-only key material publish a distinct `request-signing` `kid`. No shared secret crosses the wire. The buyer verifies the signature using the seller's JWKS. A 14-step webhook verifier checklist — documented in the [Security guide](/docs/building/implementation/security) — covers trust-anchor scoping, downgrade-and-injection resistance, and per-keyid replay dedup (100K per keyid, 10M aggregate); verification failures return typed reason codes defined there. HMAC-SHA256 remains a legacy fallback through 3.x (opt-in via `push_notification_config.authentication.credentials`); the entire `authentication` object is removed in 4.0. **Every webhook payload carries a required `idempotency_key`.** Webhooks use at-least-once delivery, so receivers must dedupe. Every webhook payload — MCP, collection-list changes, property-list changes, content-standards artifacts, rights revocations — carries a sender-generated, cryptographically-random UUID v4 `idempotency_key` stable across retries of the same event. Same name and format as the request-side field. Predictable keys allow pre-seeding a receiver's dedup cache to suppress legitimate events, so sellers MUST generate keys from a cryptographic source. diff --git a/package.json b/package.json index 572e3f3030..2e57b6c603 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "test:migrations": "node tests/migration-validation.test.cjs", "test:hmac-vectors": "node --test --test-force-exit --test-timeout=30000 tests/webhook-hmac-vectors.test.cjs", "test:hmac-signer-conformance": "node --test --test-force-exit --test-timeout=30000 tests/webhook-hmac-signer-conformance.test.cjs", + "test:webhook-signing-vectors": "node --test --test-force-exit --test-timeout=30000 tests/webhook-signing-vectors.test.cjs", "test:webhook-receiver-envelope": "node --test --test-force-exit --test-timeout=30000 tests/webhook-receiver-envelope.test.cjs", "test:transport-errors": "node --test --test-force-exit --test-timeout=30000 tests/transport-error-mapping.test.cjs", "test:targeting-overlay-vectors": "node --test --test-force-exit --test-timeout=30000 tests/media-buy-targeting-overlay-vectors.test.cjs", @@ -103,7 +104,7 @@ "audit:oneof": "node scripts/audit-oneof.mjs", "test:schema-utf8": "node scripts/normalize-schema-utf8.mjs --check", "fix:schema-utf8": "node scripts/normalize-schema-utf8.mjs", - "test": "npm run test:docs-nav && npm run test:release-docs-nav && npm run test:rewrite-dist-redirect-links && npm run test:rewrite-dist-links-idempotency && npm run test:docs-error-handling-copy && npm run test:schemas && npm run test:dist-schema-version-ids && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:adagents-catalog-only && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-receiver-envelope && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:status-as-of-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:compliance-packaged-refs && npm run test:compliance-source-authority && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:update-media-buy-affected-packages && npm run test:storyboard-advisory-expiry && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:run-storyboards-schema-root && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:sdk-shims && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:chat-streaming-code-fences && npm run test:certification-demo-formatting && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:build-schemas-async-response-refs && npm run test:release-workflow && npm run test:immutable-release-artifacts && npm run test:error-codes && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck", + "test": "npm run test:docs-nav && npm run test:release-docs-nav && npm run test:rewrite-dist-redirect-links && npm run test:rewrite-dist-links-idempotency && npm run test:docs-error-handling-copy && npm run test:schemas && npm run test:dist-schema-version-ids && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:adagents-catalog-only && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-signing-vectors && npm run test:webhook-receiver-envelope && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:status-as-of-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:compliance-packaged-refs && npm run test:compliance-source-authority && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:update-media-buy-affected-packages && npm run test:storyboard-advisory-expiry && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:run-storyboards-schema-root && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:sdk-shims && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:chat-streaming-code-fences && npm run test:certification-demo-formatting && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:build-schemas-async-response-refs && npm run test:release-workflow && npm run test:immutable-release-artifacts && npm run test:error-codes && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck", "test:all": "npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:error-handling && npm run test:snippets && npm run typecheck", "precommit:server-unit": "node scripts/precommit-server-unit.cjs", "precommit": "bash scripts/with-timeout.sh 180 npm run test:unit && npm run test:test-dynamic-imports && npm run test:callapi-state-change && bash scripts/with-timeout.sh 240 npm run precommit:server-unit && npm run typecheck", diff --git a/static/compliance/source/universal/webhook-receiver-envelope.yaml b/static/compliance/source/universal/webhook-receiver-envelope.yaml index 487f40320d..d1a86f373f 100644 --- a/static/compliance/source/universal/webhook-receiver-envelope.yaml +++ b/static/compliance/source/universal/webhook-receiver-envelope.yaml @@ -41,7 +41,7 @@ caller: prerequisites: description: | The runner MUST be configured with a buyer webhook receiver URL and a - test webhook-signing key whose public JWK the receiver can resolve. Runners + test webhook signing key whose public JWK the receiver can resolve. Runners without a receiver URL grade this storyboard as not_applicable. vectors: "test-vectors/webhook-receiver-envelope.json" diff --git a/static/schemas/source/account/sync-accounts-request.json b/static/schemas/source/account/sync-accounts-request.json index ba7ab6f044..3135574bf9 100644 --- a/static/schemas/source/account/sync-accounts-request.json +++ b/static/schemas/source/account/sync-accounts-request.json @@ -60,7 +60,7 @@ }, "notification_configs": { "type": "array", - "description": "Account-level webhook subscriptions for notifications whose lifecycle outlives any single media buy (`creative.status_changed`, `creative.purged`, wholesale feed change payloads, future account-anchored resource events after those event types are added to `notification-type.json`). This surface does not currently carry lifecycle events for the account object itself (for example, there is no `account.status_changed` event type); account status changes are observed through `list_accounts` polling or the one-shot `sync_accounts.push_notification_config` async result channel. Declarative replace semantics: when this field is present, the buyer sends the full desired array and the seller replaces the account's current set with that array, keyed by account-scoped `subscriber_id`. Omit this field to leave existing subscribers unchanged; send `[]` to remove all subscribers. Re-sending an existing `subscriber_id` for the account replaces that subscriber's config rather than creating a duplicate; persisted entries whose `subscriber_id` does not appear in the sent array are removed, so the seller MUST NOT merge the new array with persisted state. Paused entries (`active: false`) use the same replacement semantics; a buyer that wants to preserve a paused subscriber MUST re-include it with `active: false`. Duplicate `subscriber_id` values within one submitted array are rejected. Permitted in both provisioning and settings-update modes. Each entry registers a URL, the event types the subscriber wants, and optional legacy auth — see [`notification-config.json`](/schemas/core/notification-config.json). The seller MUST echo applied state on the response and on `list_accounts` reads, with `authentication.credentials` omitted (write-only). Sellers MUST reject entries whose `event_types` include any type whose contract anchors at a media buy or below (today: `scheduled`, `final`, `delayed`, `adjusted`, `impairment`) or account-lifecycle names not present in the enum as per-account validation failures with `INVALID_REQUEST` or `VALIDATION_ERROR` and `error.field` pointing at the invalid `event_types` entry — those events do not belong on this surface. Wholesale feed webhook registrations carry the actual change payload in `/schemas/core/wholesale-feed-webhook.json`; receivers use `get_products` / `get_signals` with `if_wholesale_feed_version` to repair or reconcile. This is distinct from sync_catalogs, which manages buyer-provided campaign input feeds on a seller account.\n\nActivation proof: before activating a new or changed active subscriber, the seller MUST validate the URL, complete the account-level webhook proof-of-control challenge, and only then persist or expose the subscriber as `active: true`. A valid existing proof for the same `(account_id, subscriber_id, normalized url, authentication mode/credential binding, normalized event_types)` tuple MAY be reused; changing any element of that tuple requires fresh proof. The challenge POST itself MUST be signed with the seller's RFC 9421 webhook-signing key and MUST include seller_agent_url, delivery_auth, and event_types so the receiver can verify the pending registration before echoing the challenge. Entries sent with `active: false` may skip only the outbound proof challenge while inactive; sellers MUST still enforce URL parsing, HTTPS, hostname normalization, and reserved-range rejection at write time, and those entries MUST NOT receive fires until reactivated. If proof fails or times out, the seller rejects the account entry with `action: \"failed\"`, leaves the prior notification_configs[] set unchanged, and reports `VALIDATION_ERROR` (or `INVALID_REQUEST` for malformed URLs) at the failing `notification_configs[j].url` field.\n\n**Cap rationale:** `maxItems: 16` is a practical fan-out cap (governance + buyer ingestion + audit bus + dx team + a few partner hooks). The cap exists to prevent unbounded subscriber arrays in storage and to bound the seller's per-event fan-out work. Sellers that hit the cap with legitimate subscribers should surface this on the protocol roadmap rather than work around it.", + "description": "Account-level webhook subscriptions for notifications whose lifecycle outlives any single media buy (`creative.status_changed`, `creative.purged`, wholesale feed change payloads, future account-anchored resource events after those event types are added to `notification-type.json`). This surface does not currently carry lifecycle events for the account object itself (for example, there is no `account.status_changed` event type); account status changes are observed through `list_accounts` polling or the one-shot `sync_accounts.push_notification_config` async result channel. Declarative replace semantics: when this field is present, the buyer sends the full desired array and the seller replaces the account's current set with that array, keyed by account-scoped `subscriber_id`. Omit this field to leave existing subscribers unchanged; send `[]` to remove all subscribers. Re-sending an existing `subscriber_id` for the account replaces that subscriber's config rather than creating a duplicate; persisted entries whose `subscriber_id` does not appear in the sent array are removed, so the seller MUST NOT merge the new array with persisted state. Paused entries (`active: false`) use the same replacement semantics; a buyer that wants to preserve a paused subscriber MUST re-include it with `active: false`. Duplicate `subscriber_id` values within one submitted array are rejected. Permitted in both provisioning and settings-update modes. Each entry registers a URL, the event types the subscriber wants, and optional legacy auth — see [`notification-config.json`](/schemas/core/notification-config.json). The seller MUST echo applied state on the response and on `list_accounts` reads, with `authentication.credentials` omitted (write-only). Sellers MUST reject entries whose `event_types` include any type whose contract anchors at a media buy or below (today: `scheduled`, `final`, `delayed`, `adjusted`, `impairment`) or account-lifecycle names not present in the enum as per-account validation failures with `INVALID_REQUEST` or `VALIDATION_ERROR` and `error.field` pointing at the invalid `event_types` entry — those events do not belong on this surface. Wholesale feed webhook registrations carry the actual change payload in `/schemas/core/wholesale-feed-webhook.json`; receivers use `get_products` / `get_signals` with `if_wholesale_feed_version` to repair or reconcile. This is distinct from sync_catalogs, which manages buyer-provided campaign input feeds on a seller account.\n\nActivation proof: before activating a new or changed active subscriber, the seller MUST validate the URL, complete the account-level webhook proof-of-control challenge, and only then persist or expose the subscriber as `active: true`. A valid existing proof for the same `(account_id, subscriber_id, normalized url, authentication mode/credential binding, normalized event_types)` tuple MAY be reused; changing any element of that tuple requires fresh proof. The challenge POST itself MUST be signed with the seller's RFC 9421 webhook profile key and MUST include seller_agent_url, delivery_auth, and event_types so the receiver can verify the pending registration before echoing the challenge. New signers use `adcp_use: \"request-signing\"`; deprecated `webhook-signing` keys remain accepted during the compatibility window. Entries sent with `active: false` may skip only the outbound proof challenge while inactive; sellers MUST still enforce URL parsing, HTTPS, hostname normalization, and reserved-range rejection at write time, and those entries MUST NOT receive fires until reactivated. If proof fails or times out, the seller rejects the account entry with `action: \"failed\"`, leaves the prior notification_configs[] set unchanged, and reports `VALIDATION_ERROR` (or `INVALID_REQUEST` for malformed URLs) at the failing `notification_configs[j].url` field.\n\n**Cap rationale:** `maxItems: 16` is a practical fan-out cap (governance + buyer ingestion + audit bus + dx team + a few partner hooks). The cap exists to prevent unbounded subscriber arrays in storage and to bound the seller's per-event fan-out work. Sellers that hit the cap with legitimate subscribers should surface this on the protocol roadmap rather than work around it.", "items": { "allOf": [ { diff --git a/static/schemas/source/core/push-notification-config.json b/static/schemas/source/core/push-notification-config.json index 843d4d1bc1..261763639a 100644 --- a/static/schemas/source/core/push-notification-config.json +++ b/static/schemas/source/core/push-notification-config.json @@ -25,7 +25,7 @@ }, "authentication": { "type": "object", - "description": "Legacy authentication configuration (A2A-compatible). Opts the seller into Bearer or HMAC-SHA256 signing instead of the default RFC 9421 webhook profile. Deprecated; removed in AdCP 4.0. **Precedence is a switch, not a fallback:** presence of this block selects the legacy scheme; absence selects 9421. A seller MUST NOT sign the same webhook both ways, and a buyer MUST NOT attempt 'try 9421 first, fall back to HMAC' verification — signature mode is determined solely by whether this block was present at registration time. The seller's baseline 9421 webhook-signing key published at its brand.json `agents[]` `jwks_uri` does not override this selector; it is always discoverable but only used when `authentication` is omitted. See docs/building/implementation/security.mdx#webhook-callbacks for the full precedence and downgrade-resistance rules (including the `webhook_mode_mismatch` rejection a buyer MUST apply when a received webhook's signing mode does not match the registered mode).", + "description": "Legacy authentication configuration (A2A-compatible). Opts the seller into Bearer or HMAC-SHA256 signing instead of the default RFC 9421 webhook profile. Deprecated; removed in AdCP 4.0. **Precedence is a switch, not a fallback:** presence of this block selects the legacy scheme; absence selects 9421. A seller MUST NOT sign the same webhook both ways, and a buyer MUST NOT attempt 'try 9421 first, fall back to HMAC' verification — signature mode is determined solely by whether this block was present at registration time. The seller's baseline 9421 webhook key is published at its brand.json `agents[]` `jwks_uri` using `adcp_use: \"request-signing\"` (deprecated `webhook-signing` keys remain accepted during the compatibility window); it does not override this selector and is only used when `authentication` is omitted. See docs/building/implementation/security.mdx#webhook-callbacks for the full precedence and downgrade-resistance rules (including the `webhook_mode_mismatch` rejection a buyer MUST apply when a received webhook's signing mode does not match the registered mode).", "properties": { "schemes": { "type": "array", diff --git a/static/schemas/source/core/webhook-challenge.json b/static/schemas/source/core/webhook-challenge.json index 289f90bf7a..f7e673d335 100644 --- a/static/schemas/source/core/webhook-challenge.json +++ b/static/schemas/source/core/webhook-challenge.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/webhook-challenge.json", "title": "Webhook Challenge", - "description": "Proof-of-control challenge payload sent by a seller to an account-level notification_configs[] URL before activating a new or changed active subscriber. The seller sends this payload as an HTTPS POST after URL normalization and SSRF validation, and before treating the subscriber as active. The challenge POST itself MUST be signed with the seller's RFC 9421 webhook-signing key even when the candidate config selects legacy delivery auth; `delivery_auth` describes the future webhook delivery mode, not the challenge's own signing mode.", + "description": "Proof-of-control challenge payload sent by a seller to an account-level notification_configs[] URL before activating a new or changed active subscriber. The seller sends this payload as an HTTPS POST after URL normalization and SSRF validation, and before treating the subscriber as active. The challenge POST itself MUST be signed with the seller's RFC 9421 webhook profile key even when the candidate config selects legacy delivery auth; new signers use `adcp_use: \"request-signing\"` and deprecated `webhook-signing` keys remain accepted during the compatibility window. `delivery_auth` describes the future webhook delivery mode, not the challenge's own signing mode.", "type": "object", "properties": { "type": { @@ -32,7 +32,7 @@ "seller_agent_url": { "type": "string", "format": "uri", - "description": "Exact seller agent URL whose RFC 9421 webhook-signing key signs this challenge and that will send subsequent webhooks." + "description": "Exact seller agent URL whose RFC 9421 webhook profile key signs this challenge and that will send subsequent webhooks." }, "delivery_auth": { "type": "object", diff --git a/tests/webhook-signing-vectors.test.cjs b/tests/webhook-signing-vectors.test.cjs new file mode 100644 index 0000000000..0438d6921e --- /dev/null +++ b/tests/webhook-signing-vectors.test.cjs @@ -0,0 +1,93 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const vectorsDir = path.join( + __dirname, + '..', + 'static', + 'compliance', + 'source', + 'test-vectors', + 'webhook-signing', +); + +const keys = JSON.parse(fs.readFileSync(path.join(vectorsDir, 'keys.json'), 'utf8')).keys; +const keysByKid = new Map(keys.map((key) => [key.kid, key])); + +function readVector(relativePath) { + return JSON.parse(fs.readFileSync(path.join(vectorsDir, relativePath), 'utf8')); +} + +function extractSig1Signature(vector) { + const signature = vector.request?.headers?.Signature; + assert.equal(typeof signature, 'string', `${vector.name}: Signature header must be present`); + const match = signature.match(/^sig1=:([A-Za-z0-9_-]+):$/); + assert.ok(match, `${vector.name}: Signature header must contain a sig1 sf-binary value`); + return Buffer.from(match[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64'); +} + +function verifyVectorSignature(vector, relativePath) { + assert.equal(vector.jwks_ref?.length, 1, `${relativePath}: expected exactly one jwks_ref kid`); + const [kid] = vector.jwks_ref; + const jwk = keysByKid.get(kid); + assert.ok(jwk, `${relativePath}: jwks_ref kid ${kid} missing from keys.json`); + assert.ok( + vector.expected_signature_base.includes(`keyid="${kid}"`), + `${relativePath}: expected_signature_base must bind jwks_ref kid ${kid}`, + ); + + const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' }); + const signature = extractSig1Signature(vector); + const signatureBase = Buffer.from(vector.expected_signature_base, 'utf8'); + + if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519') { + assert.equal(signature.length, 64, `${relativePath}: Ed25519 signatures must be 64 bytes`); + assert.equal( + crypto.verify(null, signatureBase, publicKey, signature), + true, + `${relativePath}: Ed25519 signature must verify against ${kid}`, + ); + return; + } + + if (jwk.kty === 'EC' && jwk.crv === 'P-256') { + assert.equal(signature.length, 64, `${relativePath}: ES256 signatures must use IEEE P1363 r||s encoding`); + assert.equal( + crypto.verify('sha256', signatureBase, { key: publicKey, dsaEncoding: 'ieee-p1363' }, signature), + true, + `${relativePath}: ES256 signature must verify against ${kid}`, + ); + return; + } + + throw new Error(`${relativePath}: unsupported test key ${kid} (${jwk.kty}/${jwk.crv})`); +} + +describe('RFC 9421 webhook-signing vectors', () => { + const positiveFiles = fs + .readdirSync(path.join(vectorsDir, 'positive')) + .filter((file) => file.endsWith('.json')) + .sort() + .map((file) => `positive/${file}`); + + for (const relativePath of positiveFiles) { + it(`cryptographically verifies ${relativePath}`, () => { + const vector = readVector(relativePath); + assert.equal(vector.expected_outcome?.success, true, `${relativePath}: must be a positive vector`); + verifyVectorSignature(vector, relativePath); + }); + } + + it('cryptographically verifies negative/008 before the verifier rejects at step 8', () => { + const relativePath = 'negative/008-wrong-adcp-use.json'; + const vector = readVector(relativePath); + assert.equal(vector.expected_outcome?.success, false); + assert.equal(vector.expected_outcome?.failed_step, 8); + assert.equal(vector.expected_outcome?.error_code, 'webhook_signature_key_purpose_invalid'); + assert.deepEqual(vector.jwks_ref, ['test-response-purpose-2026']); + verifyVectorSignature(vector, relativePath); + }); +}); From 7a3bed5bfaac238d21a4882ec52b6d8f458f28ec Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 16 Jun 2026 10:23:38 +0200 Subject: [PATCH 10/10] ci: enforce webhook signing vectors --- .changeset/webhook-allow-request-signing-key-reuse.md | 2 +- .github/workflows/build-check.yml | 4 ++-- docs/brand-protocol/tasks/acquire_rights.mdx | 10 +++++----- docs/governance/collection/tasks/collection_lists.mdx | 6 +++--- docs/intro.mdx | 2 +- docs/reference/migration/prerelease-upgrades.mdx | 6 +++--- docs/reference/release-notes.mdx | 10 +++++----- docs/reference/whats-new-in-v3.mdx | 6 +++--- .../compliance/source/universal/webhook-emission.yaml | 2 +- .../schemas/source/core/push-notification-config.json | 4 ++-- tests/webhook-signing-vectors.test.cjs | 2 +- 11 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.changeset/webhook-allow-request-signing-key-reuse.md b/.changeset/webhook-allow-request-signing-key-reuse.md index 8d6880f64d..b7181e6d86 100644 --- a/.changeset/webhook-allow-request-signing-key-reuse.md +++ b/.changeset/webhook-allow-request-signing-key-reuse.md @@ -2,7 +2,7 @@ "adcontextprotocol": minor --- -Webhooks are signed with the agent's `request-signing` key — there is no separate webhook key purpose. The webhook verifier checklist (step 8) now requires `adcp_use == "request-signing"` (with the deprecated `"webhook-signing"` still accepted for backward compatibility; removal tracked in adcontextprotocol/adcp#5555). Operators that want separate key material for webhooks publish a second `"request-signing"` key with a distinct `kid` and sign webhooks with it — key isolation comes from the `kid`, not a distinct `adcp_use`. Any other key-purpose failure — `"response-signing"`/`"governance-signing"`, absent `adcp_use`, or a missing `verify` key_op — is rejected with `webhook_signature_key_purpose_invalid`. `webhook_mode_mismatch` is unchanged and remains reserved for the HMAC-vs-9421 auth-mode selector mismatch. +Webhooks are signed with the agent's `request-signing` key — there is no separate webhook key purpose. The webhook verifier checklist (step 8) now accepts `adcp_use == "request-signing"` as canonical, with the deprecated `"webhook-signing"` still accepted for backward compatibility (removal tracked in adcontextprotocol/adcp#5555). Operators that want separate key material for webhooks publish a second `"request-signing"` key with a distinct `kid` and sign webhooks with it — key isolation comes from the `kid`, not a distinct `adcp_use`. Any other key-purpose failure — `"response-signing"`/`"governance-signing"`, absent `adcp_use`, or a missing `verify` key_op — is rejected with `webhook_signature_key_purpose_invalid`. `webhook_mode_mismatch` is unchanged and remains reserved for the HMAC-vs-9421 auth-mode selector mismatch. The relaxation is one-directional and safe: cross-protocol confusion is prevented by the RFC 9421 `tag` (`adcp/webhook-signing/v1`, part of the signed base, checked at step 3) and mandatory `content-digest` coverage — not by the key-purpose discriminator. A captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3, so it can never be replayed as a webhook. The reverse remains forbidden: a webhook-signing key MUST NOT verify a request signature (request verification still requires `adcp_use == "request-signing"` exactly). diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index afe86d95db..c06756fe9d 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -119,8 +119,8 @@ jobs: - name: Check platform-agnosticism (no vendor tokens in normative field names) run: npm run test:platform-agnostic - - name: HMAC webhook conformance (verifier + signer) - run: npm run test:hmac-vectors && npm run test:hmac-signer-conformance + - name: Webhook conformance (HMAC + RFC 9421 vectors) + run: npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-signing-vectors server-integration: name: Server integration tests diff --git a/docs/brand-protocol/tasks/acquire_rights.mdx b/docs/brand-protocol/tasks/acquire_rights.mdx index 2f6e57779c..1db6fd2496 100644 --- a/docs/brand-protocol/tasks/acquire_rights.mdx +++ b/docs/brand-protocol/tasks/acquire_rights.mdx @@ -190,7 +190,7 @@ Seconds to minutes for `acquired` or `rejected`. The `pending_approval` status m | `campaign.end_date` | date | No | Campaign end date | | `revocation_webhook` | push-notification-config | Yes | Webhook for revocation notifications. If the rights holder needs to revoke rights, they POST a [revocation-notification](https://adcontextprotocol.org/schemas/v3/brand/revocation-notification.json) to this URL. | | `idempotency_key` | string | No | Client-generated key for safe retries. Resubmitting with the same key returns the original response. | -| `push_notification_config` | push-notification-config | No | Webhook for async status updates if the acquisition requires approval. See [push notifications](/docs/building/implementation/webhooks). | +| `push_notification_config` | push-notification-config | No | Webhook for async status updates if the acquisition requires approval. See [push notifications](/docs/building/by-layer/L3/webhooks). | ### Response statuses @@ -220,10 +220,10 @@ Brand agents MAY also reject when `campaign.start_date` is more than the rights A request is **governance-aware** when the brand agent will project commitment against a governance plan before issuing credentials. That happens by either of two paths: -1. **Inline path** — the request carries an intent-phase `governance_context` token on the [protocol envelope](/docs/building/implementation/security). The buyer threads the token explicitly per request. +1. **Inline path** — the request carries an intent-phase `governance_context` token on the [protocol envelope](/docs/building/by-layer/L1/security). The buyer threads the token explicitly per request. 2. **Bound path** — the request carries `account` (a seller-assigned `account_id`, or `account.brand` + `account.operator` resolving to a bound account), and the brand agent has a governance agent previously bound to that account via [`sync_governance`](/docs/accounts/tasks/sync_governance). The brand agent looks up the bound agent without the buyer threading anything per request. -When **both** paths are present in the same request — an inline `governance_context` token AND an `account` with a bound governance agent — the inline token wins. The token is per-request, JWS-signed against a specific plan, and is the [primary correlation key](/docs/building/implementation/security) for audit and reporting; the bound agent serves as the resolver fallback when no token is threaded. Brand agents MUST consult the agent identified by the inline token when both are present, even if it differs from the bound agent — the buyer's per-request decision overrides the persisted binding. +When **both** paths are present in the same request — an inline `governance_context` token AND an `account` with a bound governance agent — the inline token wins. The token is per-request, JWS-signed against a specific plan, and is the [primary correlation key](/docs/building/by-layer/L1/security) for audit and reporting; the bound agent serves as the resolver fallback when no token is threaded. Brand agents MUST consult the agent identified by the inline token when both are present, even if it differs from the bound agent — the buyer's per-request decision overrides the persisted binding. Both paths trigger the same projection rule. When the request is governance-aware and the selected pricing option has `model: "cpm"`, `campaign.estimated_impressions` is the input the brand agent uses to project commitment against remaining plan budget. To make that projection deterministic across implementations: @@ -277,7 +277,7 @@ If `acquire_rights` returns `pending_approval` and you provided `push_notificati If the rights holder needs to revoke rights (talent controversy, contract violation, etc.), they POST a [`revocation-notification`](https://adcontextprotocol.org/schemas/v3/brand/revocation-notification.json) to the buyer's `revocation_webhook`, authenticating with the credentials provided at acquisition time. The notification contains an `idempotency_key` (required, used for deduplication across retries), `rights_id`, `brand_id`, `reason`, and `effective_at` timestamp. The buyer is responsible for: -- Deduplicating by `idempotency_key` — the same revocation may be delivered multiple times; see [Push Notifications — Reliability](/docs/building/implementation/webhooks#reliability) for the canonical dedup contract +- Deduplicating by `idempotency_key` — the same revocation may be delivered multiple times; see [Push Notifications — Reliability](/docs/building/by-layer/L3/webhooks#reliability) for the canonical dedup contract - Stopping creative delivery by `effective_at` - Removing or replacing affected creatives from active campaigns - Ceasing use of generation credentials (providers may also invalidate credentials independently) @@ -288,7 +288,7 @@ Partial revocation is supported — if `revoked_uses` is present, only those use Return HTTP `200` immediately upon receiving and validating a revocation notification. The rights holder retries on non-`2xx` responses using exponential backoff (1s, 5s, 30s, 5m, 30m). After 6 failed attempts, the rights holder may escalate through other channels. -All webhook signing follows the AdCP [push notification signing profile](/docs/building/implementation/webhooks#signature-verification) — RFC 9421 by default (rights agent signs with its `adcp_use: "request-signing"` key published at its brand.json `agents[]` entry; deprecated `webhook-signing` keys remain accepted during the compatibility window), with the deprecated HMAC-SHA256 fallback available when the rights holder populates `authentication.credentials` on the webhook registration. +All webhook signing follows the AdCP [push notification signing profile](/docs/building/by-layer/L3/webhooks#signature-verification) — RFC 9421 by default (rights agent signs with its `adcp_use: "request-signing"` key published at its brand.json `agents[]` entry; deprecated `webhook-signing` keys remain accepted during the compatibility window), with the deprecated HMAC-SHA256 fallback available when the rights holder populates `authentication.credentials` on the webhook registration. ## Impression caps and overage diff --git a/docs/governance/collection/tasks/collection_lists.mdx b/docs/governance/collection/tasks/collection_lists.mdx index f9d1cab1da..c1b04fe1a8 100644 --- a/docs/governance/collection/tasks/collection_lists.mdx +++ b/docs/governance/collection/tasks/collection_lists.mdx @@ -321,7 +321,7 @@ This excludes specific sports programs by Gracenote ID (SP-prefixed for sports) ## Security considerations -Collection lists gate delivery decisions, so the `auth_token` and webhook callbacks need explicit lifecycle rules. General controls in [Security](/docs/building/implementation/security) apply; the collection-list-specific rules: +Collection lists gate delivery decisions, so the `auth_token` and webhook callbacks need explicit lifecycle rules. General controls in [Security](/docs/building/by-layer/L1/security) apply; the collection-list-specific rules: **`auth_token` scope, revocation, and log hygiene.** Each token authorizes exactly one `list_id`; do not reuse a token across lists. Governance agents MUST issue a distinct token per seller that receives the list — shared tokens cannot be revoked per relationship and make list-wide rotation the only response to a single compromise. Tokens MUST NOT be written to logs, cache keys, or metric labels, and error responses from `get_collection_list` MUST NOT echo the presented token. @@ -330,9 +330,9 @@ Collection lists gate delivery decisions, so the `auth_token` and webhook callba - **Normal deletion or end-of-relationship**: the token MUST fail subsequent `get_collection_list` calls immediately, but sellers with a cached resolution MAY continue serving from cache until `cache_valid_until`. A natural relationship end is not a compromise. - **Compromise-driven revocation**: the governance agent MUST signal cache invalidation. Either return a reduced `cache_valid_until` (at or before `now`) on the next poll that the seller still has access to complete, or emit a `collection_list_changed` webhook whose `change_summary` conveys that the list version has been invalidated so cached copies are discarded. Leaving compromised content in seller caches until the scheduled TTL is not acceptable. -**Webhook URL validation.** The `webhook_url` on `update_collection_list` is SSRF-equivalent to any other buyer-provided callback URL. Apply the canonical [Webhook URL validation (SSRF)](/docs/building/implementation/security#webhook-url-validation-ssrf) rules — HTTPS only, validated IP ranges (IPv4 and IPv6 including `::ffff:0:0/96`), connection pinning (not just DNS re-resolution), no redirect following, size and timeout caps. +**Webhook URL validation.** The `webhook_url` on `update_collection_list` is SSRF-equivalent to any other buyer-provided callback URL. Apply the canonical [Webhook URL validation (SSRF)](/docs/building/by-layer/L1/security#webhook-url-validation-ssrf) rules — HTTPS only, validated IP ranges (IPv4 and IPv6 including `::ffff:0:0/96`), connection pinning (not just DNS re-resolution), no redirect following, size and timeout caps. -**Webhook signature algorithm.** The webhook signature MUST follow the [standard webhook signing rules](/docs/building/implementation/security#webhook-security). By default, the RFC 9421 [webhook callbacks profile](/docs/building/implementation/security#webhook-callbacks) applies: the governance agent signs with its `adcp_use: "request-signing"` key published at the `jwks_uri` of its `agents[]` entry in its own brand.json; deprecated `webhook-signing` keys remain accepted during the compatibility window. The subscribing seller verifies covered components `@method`, `@target-uri`, `@authority`, `content-type`, `content-digest`, with `tag="adcp/webhook-signing/v1"`. The deprecated HMAC-SHA256 fallback applies only when the subscribing seller populates `authentication.credentials` on the webhook registration; that path follows the [Legacy HMAC-SHA256 fallback](/docs/building/implementation/security#legacy-hmac-sha256-fallback-deprecated-removed-in-40) rules, and any body `signature` field under that path is a convenience copy — recipients MUST verify against the headers and MUST NOT trust the body value. +**Webhook signature algorithm.** The webhook signature MUST follow the [standard webhook signing rules](/docs/building/by-layer/L1/security#webhook-security). By default, the RFC 9421 [webhook callbacks profile](/docs/building/by-layer/L1/security#webhook-callbacks) applies: the governance agent signs with its `adcp_use: "request-signing"` key published at the `jwks_uri` of its `agents[]` entry in its own brand.json; deprecated `webhook-signing` keys remain accepted during the compatibility window. The subscribing seller verifies covered components `@method`, `@target-uri`, `@authority`, `content-type`, `content-digest`, with `tag="adcp/webhook-signing/v1"`. The deprecated HMAC-SHA256 fallback applies only when the subscribing seller populates `authentication.credentials` on the webhook registration; that path follows the [Legacy HMAC-SHA256 fallback](/docs/building/by-layer/L1/security#legacy-hmac-sha256-fallback-deprecated-removed-in-40) rules, and any body `signature` field under that path is a convenience copy — recipients MUST verify against the headers and MUST NOT trust the body value. **Distribution-ID inputs.** Governance agents SHOULD validate identifier format before persisting (IMDb: `^tt\d+$`, EIDR: `10.5240/...`, Gracenote: vendor-prefixed) and SHOULD enforce per-account rate limits on list mutations to prevent list-bloat DoS. Surface unresolved identifiers in `coverage_gaps` rather than silently dropping them. diff --git a/docs/intro.mdx b/docs/intro.mdx index bbea298044..6499d1d632 100644 --- a/docs/intro.mdx +++ b/docs/intro.mdx @@ -284,7 +284,7 @@ For sellers that generate creative — AI assistants, conversational ad platform `update_media_buy` handles mid-flight changes: shift budget between packages, adjust flight dates, swap creative assignments. No need to cancel and recreate. -That `idempotency_key` on Sam's request isn't decorative. Pinnacle's buyer agent signs the POST with RFC 9421 HTTP Message Signatures before it leaves the network. StreamHaus verifies Pinnacle's signature against Pinnacle's operator-published JWKS, then accepts the buy. When the campaign moves from `pending_start` to `active`, StreamHaus posts a signed webhook back to Pinnacle's orchestrator — same signature profile, with StreamHaus's request-signing key published through its `brand.json` `agents[]` entry and optionally pinned by publisher `adagents.json`. If Sam's laptop drops the response and his agent retries, the `idempotency_key` makes the second call safe — StreamHaus returns the original buy with `replayed: true` instead of charging twice. Governance approvals ride along as signed JWS tokens on `check_governance` so no agent in the chain can forge Jordan's sign-off. See the [Security guide](/docs/building/implementation/security). +That `idempotency_key` on Sam's request isn't decorative. Pinnacle's buyer agent signs the POST with RFC 9421 HTTP Message Signatures before it leaves the network. StreamHaus verifies Pinnacle's signature against Pinnacle's operator-published JWKS, then accepts the buy. When the campaign moves from `pending_start` to `active`, StreamHaus posts a signed webhook back to Pinnacle's orchestrator — same signature profile, with StreamHaus's request-signing key published through its `brand.json` `agents[]` entry and optionally pinned by publisher `adagents.json`. If Sam's laptop drops the response and his agent retries, the `idempotency_key` makes the second call safe — StreamHaus returns the original buy with `replayed: true` instead of charging twice. Governance approvals ride along as signed JWS tokens on `check_governance` so no agent in the chain can forge Jordan's sign-off. See the [Security guide](/docs/building/by-layer/L1/security). --- diff --git a/docs/reference/migration/prerelease-upgrades.mdx b/docs/reference/migration/prerelease-upgrades.mdx index b78dcfd7a5..251b00692d 100644 --- a/docs/reference/migration/prerelease-upgrades.mdx +++ b/docs/reference/migration/prerelease-upgrades.mdx @@ -15,7 +15,7 @@ If you adopted a prerelease version, review the relevant section below before up | Area | rc.3 | 3.0 | What to do | |---|---|---|---| -| `idempotency_key` on mutating requests | Optional | **Required** on every mutating request (schema `^[A-Za-z0-9_.:-]{16,255}$`; UUID v4 for Verified). Sellers declare `adcp.idempotency = { supported: true/false }` on capabilities. | Generate fresh key per logical operation. Persist keys across agent instances. Declare `adcp.idempotency` on `get_adcp_capabilities` (sellers). When `supported: true`, handle `IDEMPOTENCY_CONFLICT` and `IDEMPOTENCY_EXPIRED`; conformance probes require a mutated-payload replay to return CONFLICT. When `supported: false`, use natural-key checks instead of blind retries. See [Security § Idempotency](/docs/building/implementation/security). | +| `idempotency_key` on mutating requests | Optional | **Required** on every mutating request (schema `^[A-Za-z0-9_.:-]{16,255}$`; UUID v4 for Verified). Sellers declare `adcp.idempotency = { supported: true/false }` on capabilities. | Generate fresh key per logical operation. Persist keys across agent instances. Declare `adcp.idempotency` on `get_adcp_capabilities` (sellers). When `supported: true`, handle `IDEMPOTENCY_CONFLICT` and `IDEMPOTENCY_EXPIRED`; conformance probes require a mutated-payload replay to return CONFLICT. When `supported: false`, use natural-key checks instead of blind retries. See [Security § Idempotency](/docs/building/by-layer/L1/security). | | Webhook signing | HMAC-SHA256 with `push_notification_config.authentication` (required) | RFC 9421 profile (baseline-required for sellers); HMAC fallback available through 3.x via `authentication.credentials` | Publish a signing JWK in your JWKS at `jwks_uri` (referenced from `brand.json` `agents[]`). New signers use `adcp_use: "request-signing"` for webhooks; deprecated `adcp_use: "webhook-signing"` keys remain accepted during the compatibility window. If you want webhook-only key material, publish a second `request-signing` JWK with a distinct `kid`. Drop `push_notification_config.authentication` from new configs; buyers opt into legacy HMAC via `authentication.credentials`. Receivers verify against the sender's JWKS. The entire `authentication` object (HMAC + Bearer) is removed in 4.0. | | `idempotency_key` on webhook payloads | Not standardized (fragile `(task_id, status, timestamp)` tuple dedup) | **Required** — sender-generated UUID v4 on every payload | Sellers: generate a cryptographically-random UUID v4 per event. Receivers: dedupe on `idempotency_key` with 24h minimum TTL, sender-scoped cache. Schemas affected: `mcp-webhook-payload`, `collection-list-changed-webhook`, `property-list-changed-webhook`, `artifact-webhook-payload`, `revocation-notification`. | | `revocation-notification.notification_id` | Field name on rights revocation payload | Renamed to `idempotency_key` | Find-and-replace in your rights-revocation receivers. | @@ -158,7 +158,7 @@ All request and response schemas across governance, collection, property, sponso ### Additive changes in 3.0 - **RFC 9421 request signing profile (optional in 3.0, mandatory under AdCP Verified)** — Ed25519 HTTP Message Signatures with canonicalized covered-component list. Published test vectors at `static/compliance/source/test-vectors/request-signing/`. sf-binary encoding and URL canonicalization pinned for bit-identical canonical inputs. 15-step verification checklist with `keyid` cap-before-crypto. -- **Webhook signing unified on RFC 9421** — Baseline-required for sellers emitting webhooks. Sellers publish a signing JWK in their JWKS at `jwks_uri`; new signers use `adcp_use: "request-signing"` for webhook delivery, while deprecated `webhook-signing` keys remain accepted during the compatibility window. Use a distinct `kid` if you want webhook-only key material. 14-step webhook verifier checklist in the [Security guide](/docs/building/implementation/security). HMAC-SHA256 remains a legacy fallback through 3.x (the entire `authentication` object is removed in 4.0). +- **Webhook signing unified on RFC 9421** — Baseline-required for sellers emitting webhooks. Sellers publish a signing JWK in their JWKS at `jwks_uri`; new signers use `adcp_use: "request-signing"` for webhook delivery, while deprecated `webhook-signing` keys remain accepted during the compatibility window. Use a distinct `kid` if you want webhook-only key material. 14-step webhook verifier checklist in the [Security guide](/docs/building/by-layer/L1/security). HMAC-SHA256 remains a legacy fallback through 3.x (the entire `authentication` object is removed in 4.0). - **Required `idempotency_key` on every webhook payload** — Sender-generated UUID v4 across all five webhook payload schemas. Replaces fragile `(task_id, status, timestamp)` dedup. `revocation-notification.notification_id` renamed to `idempotency_key` for protocol-wide consistency. - **`check_governance` on every spend-commit** — Governance invocation is required at commit, not just at plan approval. Closes the loophole where partial spends could skip governance. - **Experimental status mechanism** — `status: experimental` marker for fields and tasks in production use but not yet under full stability guarantees. `custom` pricing-model escape hatch on signals. @@ -168,7 +168,7 @@ All request and response schemas across governance, collection, property, sponso - **Signed JWS `governance_context`** — Governance decisions are now cryptographically verifiable. Sellers resolve the governance agent's JWKS via `sync_governance` and verify `sub` / `aud` / `phase` / `exp` before honoring the decision. - **Universal security storyboard** — Every agent runs `/compliance/{version}/universal/security.yaml` (unauth rejection, API key, OAuth/RFC 9728, audience binding). Agents declaring signing also run the `signed_requests` harness. - **Cross-instance state persistence** — Architecture spec requires persistent state (tasks, media buys, plans, signed artifacts, idempotency keys) across horizontally-scaled instances. -- **Security implementation guide** — New `docs/building/implementation/security.mdx` documents threat model, three-principal model (brand / operator / agent), and verification paths. Retires ambiguous "principal" terminology. +- **Security implementation guide** — New `docs/building/by-layer/L1/security.mdx` documents threat model, three-principal model (brand / operator / agent), and verification paths. Retires ambiguous "principal" terminology. - **GDPR Art 22 / EU AI Act Annex III as schema invariants** — New registry policy `eu_ai_act_annex_iii`. `requires_human_review` on policies and categories. Schema-level enforcement of `human_review_required: true` for regulated verticals. - **Operating an Agent guide** — New doc for publishers without engineering teams — three paths: partner, self-host, build. - **Release cadence policy** — Named cadence: patch monthly, minor quarterly, major annual if needed. v2 EOL August 1, 2026. diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index d6a647e78e..19028539e8 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -603,7 +603,7 @@ For the full per-PR change list, see [CHANGELOG.md § 3.0.1](https://github.com/ **Requests** — buyer → seller: - **`idempotency_key` required on every mutating request** — fresh key per logical operation, matching `^[A-Za-z0-9_.:-]{16,255}$` (UUID v4 for Verified). Sellers declare dedup semantics via `adcp.idempotency = { supported: true, replay_ttl_seconds: ... }` (1h–7d, 24h recommended) or `{ supported: false }`. When `supported: true`: `replayed: true` on exact replay, `IDEMPOTENCY_CONFLICT` on payload mismatch, `IDEMPOTENCY_EXPIRED` past TTL. When `supported: false`: retries double-process — buyers MUST use natural-key checks instead. Extended to `activate_signal`. Conformance runners probe `supported: true` claims with a deliberate payload-mutation replay. (#2315, #2407, #2436, #2447) - - **RFC 9421 HTTP Message Signatures** — optional in 3.0, required for AdCP Verified. Ed25519 over a canonicalized covered-component list (including `content-digest`). sf-binary and URL canonicalization pinned so independent implementations produce bit-identical canonical inputs. Verifier follows a 15-step checklist (`keyid` cap-before-crypto, SSRF-validated JWKS fetch, `jti` replay dedup, audience binding) — see the [Security guide](/docs/building/implementation/security). Published test vectors under `static/compliance/source/test-vectors/request-signing/`. (#2323, #2341, #2342, #2343) + - **RFC 9421 HTTP Message Signatures** — optional in 3.0, required for AdCP Verified. Ed25519 over a canonicalized covered-component list (including `content-digest`). sf-binary and URL canonicalization pinned so independent implementations produce bit-identical canonical inputs. Verifier follows a 15-step checklist (`keyid` cap-before-crypto, SSRF-validated JWKS fetch, `jti` replay dedup, audience binding) — see the [Security guide](/docs/building/by-layer/L1/security). Published test vectors under `static/compliance/source/test-vectors/request-signing/`. (#2323, #2341, #2342, #2343) **Webhooks** — seller → buyer, same profile in reverse: - **Webhook signing unified on the RFC 9421 profile — baseline-required for sellers emitting webhooks** — Sellers sign outbound webhooks with a key published in their JWKS at `jwks_uri` (discoverable via `brand.json` `agents[]`). New signers use `adcp_use: "request-signing"` for webhook delivery; deprecated `adcp_use: "webhook-signing"` keys remain accepted during the compatibility window. Operators that want webhook-only key material use a distinct `kid`. No shared secret crosses the wire. Verification failures return typed `webhook_signature_*` reason codes defined in the Security guide. HMAC-SHA256 remains a legacy fallback through 3.x (opt-in via `push_notification_config.authentication.credentials`); removed in 4.0. (#2423) @@ -612,7 +612,7 @@ For the full per-PR change list, see [CHANGELOG.md § 3.0.1](https://github.com/ **Governance** — signed authority: - **Signed JWS `governance_context`** — governance decisions are cryptographically verifiable offline. The governance agent issues a JWS signed with its key from `sync_governance`; sellers verify and bind decisions to `sub` (buyer), `aud` (seller), `phase`, and `exp` without round-tripping. Stale or forged decisions are rejected at the transport layer. Sellers with a configured governance agent MUST call `check_governance` before committing budget (rejection with `PERMISSION_DENIED` on missing or invalid context). (#2316, #2403, #2419) - See [Security implementation guide](/docs/building/implementation/security) for the threat model, `adcp_use` JWK taxonomy, and per-primitive verification paths. + See [Security implementation guide](/docs/building/by-layer/L1/security) for the threat model, `adcp_use` JWK taxonomy, and per-primitive verification paths. 2. **Specialisms, compliance storyboards, and AdCP Verified** — Trust primitives define the bar; storyboards test that an agent actually meets it; AdCP Verified certifies the result. Storyboards move into the protocol at `/compliance/{version}/` (universal + protocols + specialisms + test-kits). Every agent runs `/compliance/{version}/universal/security.yaml` regardless of claims — unauth rejection, API key enforcement, OAuth discovery per RFC 9728, audience binding, and (when signing is claimed) the `signed_requests` and `signed_webhooks` runner harnesses. Runner output is a structured, verifiable `runner-output.json` with a hash chain over the test-kit corpus. Cross-instance state persistence is required. New `specialisms` field on `get_adcp_capabilities` lets agents claim narrow capability specialisms across 6 protocols (media-buy, creative, signals, governance, brand, sponsored_intelligence). `sponsored_intelligence` is promoted from specialism to full protocol. `broadcast-platform` → `sales-broadcast-tv`, `social-platform` → `sales-social`. `property-governance` + `collection-governance` split into sibling `property-lists` and `collection-lists` specialisms. Compliance taxonomy renames `domains` → `protocols`; `audience-sync` reclassified from `governance` to `media-buy`. Per-version protocol tarball at `/protocol/{version}.tgz`. The formal AdCP Verified program launches with **3.1** once reference implementations (training agent, SDKs) and ambiguous-storyboard work reach full compliance on a 4–6 week cadence; 3.0 Verified is self-attested via published runner output. See the [Compliance Catalog](/docs/building/compliance-catalog) and [What's new in v3](/docs/reference/whats-new-in-v3#specialisms-and-storyboard-driven-compliance) for the rationale and timeline. (#2176, #2300, #2304, #2332, #2336, #2350, #2352, #2363, #2381) @@ -641,7 +641,7 @@ For the full per-PR change list, see [CHANGELOG.md § 3.0.1](https://github.com/ 14. **Operating an Agent, Release Cadence, CHARTER** — New "Operating an Agent" guide for publishers without engineering teams (partner / self-host / build). Named release cadence policy: patch monthly, minor quarterly, major annual if needed. v2 EOL August 1, 2026. Formal `CHARTER.md` linked from README, IPR, and intro. AI disclosure page. Known-limitations and privacy-considerations reference pages. `status: experimental` marker for in-production but not-yet-stable protocol fields; `custom` pricing-model escape hatch on signals. (#2202, #2309, #2311, #2312, #2321, #2329, #2362, #2382, #2422, #2427) 15. **Trust-surface late hardening** — Two normative tightenings from the external 3.0 security review land as MUST in GA: - - **Idempotency cache insert-rate limiting.** Sellers MUST apply per-`(authenticated_agent, account)` rate limits on idempotency-cache inserts (separate from request rate limits) and return `RATE_LIMITED` with `retry_after` when exceeded. First-deployment ceiling: **60 inserts/sec sustained per agent (3,600/min), burst to 300/sec over rolling 10s windows** — sized against realistic high-volume launch patterns (10 media buys/min × 10 packages × 10 creatives, 3–5× headroom) and consistent with the existing 100k-per-keyid webhook replay cap and 1M-per-keyid request replay cap. Tunable per deployment. Closes a nonce-flood DoS amplification vector. See [security.mdx idempotency § bullet 8](/docs/building/implementation/security#idempotency). + - **Idempotency cache insert-rate limiting.** Sellers MUST apply per-`(authenticated_agent, account)` rate limits on idempotency-cache inserts (separate from request rate limits) and return `RATE_LIMITED` with `retry_after` when exceeded. First-deployment ceiling: **60 inserts/sec sustained per agent (3,600/min), burst to 300/sec over rolling 10s windows** — sized against realistic high-volume launch patterns (10 media buys/min × 10 packages × 10 creatives, 3–5× headroom) and consistent with the existing 100k-per-keyid webhook replay cap and 1M-per-keyid request replay cap. Tunable per deployment. Closes a nonce-flood DoS amplification vector. See [security.mdx idempotency § bullet 8](/docs/building/by-layer/L1/security#idempotency). - **Webhook-registration signing MUST for signing-capable sellers.** Sellers that support request signing MUST reject webhook-registration requests carrying `push_notification_config.authentication` over bearer-only (unsigned) transport, with `request_signature_required`. Structural defense against on-path mutators injecting or stripping the `authentication` block during onboarding — a 9421-signed registration cryptographically commits to the body. Sellers with no signing support keep the log-and-alarm posture. Scoped breakage: buyers that previously registered webhooks with `authentication` against a signing-capable seller over bearer transport must switch to 9421-signed registration. Negative test vector `027-webhook-registration-authentication-unsigned.json` lands with the tightening. 16. **Error-code vocabulary cleanup** (#2704) — The uniform-response MUST (#2691) forbids sellers from minting custom `*_NOT_FOUND` codes for typed parameters; the spec itself was out of compliance in 12 places. Cleanup aligns the spec with the rule: `PLAN_NOT_FOUND` is promoted to standard vocabulary (used across `report_plan_outcome`, `get_plan_audit_logs`, `check_governance`; recovery via `sync_plans`). Eleven other custom codes (`CHECK_NOT_FOUND`, `CAMPAIGN_NOT_FOUND`, `BRAND_NOT_FOUND`, `STANDARDS_NOT_FOUND`, `FORMAT_NOT_FOUND`, `AGENT_NOT_FOUND`, `SIGNAL_AGENT_SEGMENT_NOT_FOUND`, `SEGMENT_NOT_FOUND`, `AUDIENCE_NOT_FOUND`, `CATALOG_NOT_FOUND`, `EVENT_SOURCE_NOT_FOUND`) collapse to `REFERENCE_NOT_FOUND` with `error.field` naming the failed parameter. Signals auth-uniformity tightened: private signal agents now return `REFERENCE_NOT_FOUND` uniformly for unauthorized accounts (preventing cross-tenant enumeration). None of the 12 codes appeared in JSON schemas — prose-level cleanup only; no schema-enum migration. Sellers returning any of the 11 collapsed codes today MUST switch to `REFERENCE_NOT_FOUND`. @@ -692,7 +692,7 @@ Brand schema extensions (`border_radius`, `elevation`, `spacing`, extended color - **Webhooks** — Migrate from HMAC-SHA256 to RFC 9421 signing. Publish a signing JWK in your JWKS at `jwks_uri` (JWKS is referenced from `brand.json` `agents[]`). New signers use `adcp_use: "request-signing"` for webhook delivery; deprecated `webhook-signing` keys remain accepted during the compatibility window. Use a distinct `kid` if you want webhook-only key material. Drop `push_notification_config.authentication` from new configs; buyers opt into legacy HMAC via `authentication.credentials`. Receivers verify against the sender's JWKS. Every outbound webhook payload must carry an `idempotency_key` matching `^[A-Za-z0-9_.:-]{16,255}$` (UUID v4 for Verified). Listeners must dedupe keyed by sender identity (signing `keyid` under 9421, or HMAC/Bearer credential under legacy) with a 24h minimum TTL. HMAC fallback remains available through 3.x; the full `authentication` object is removed in 4.0. - **Idempotency** — Generate a fresh key on every mutating request, matching `^[A-Za-z0-9_.:-]{16,255}$` (UUID v4 for Verified). Same key + identical payload on retry → `replayed: true`. Same key + different payload → `IDEMPOTENCY_CONFLICT`. Key older than the seller-declared `replay_ttl_seconds` → `IDEMPOTENCY_EXPIRED` (1h–7d, 24h recommended — no protocol default). Your agent must persist keys across instances. -- **Request signing (optional in 3.0, required for Verified)** — If you plan to claim AdCP Verified, implement RFC 9421 Ed25519 signing per the [signing profile](/docs/building/implementation/security#request-signing) and declare your signing key via the account surface. Test against `static/compliance/source/test-vectors/request-signing/` and the runner's `signed_requests` harness. +- **Request signing (optional in 3.0, required for Verified)** — If you plan to claim AdCP Verified, implement RFC 9421 Ed25519 signing per the [signing profile](/docs/building/by-layer/L1/security#request-signing) and declare your signing key via the account surface. Test against `static/compliance/source/test-vectors/request-signing/` and the runner's `signed_requests` harness. - **Governance context** — Switch from opaque-string `governance_context` to the signed JWS format. Verify using the governance agent's JWKS (resolved via `sync_governance`). Bind signature to `sub` (buyer), `aud` (seller), `phase`, and `exp` before trusting. - **IO approval** — Remove `MediaBuy.pending_approval` from state filters. Consume approval tasks from the task surface instead. - **Budget autonomy** — Rewrite any `budget.authority_level` references: `agent_full` → `reallocation_unlimited: true`; `agent_limited` → `reallocation_threshold: `; `human_required` → `plan.human_review_required: true`. @@ -758,7 +758,7 @@ Brand schema extensions (`border_radius`, `elevation`, `spacing`, extended color |--------|------|------| | Buyer references | `buyer_ref`, `buyer_campaign_ref`, `campaign_ref` on requests | Removed — seller-assigned `media_buy_id` and `package_id` are canonical | | Idempotency | `buyer_ref` as implicit dedup key | Explicit `idempotency_key` on all mutating requests | -| Governance context | Structured `governance-context.json` schema | Signed-JWS `governance_context` string (opaque to forwarders, cryptographically verifiable by auditors — see [security.mdx](/docs/building/implementation/security#signed-governance-context)) | +| Governance context | Structured `governance-context.json` schema | Signed-JWS `governance_context` string (opaque to forwarders, cryptographically verifiable by auditors — see [security.mdx](/docs/building/by-layer/L1/security#signed-governance-context)) | | `check_governance` binding | `binding` field on request | Removed — inferred from discriminating fields | | `sync_plans` mode | `mode` field (audit/advisory/enforce) | Removed — governance agent configuration | | `check_governance` status | `escalated` as possible status | Removed — use async task lifecycle | diff --git a/docs/reference/whats-new-in-v3.mdx b/docs/reference/whats-new-in-v3.mdx index 0bdb4ecb11..0ca4653b68 100644 --- a/docs/reference/whats-new-in-v3.mdx +++ b/docs/reference/whats-new-in-v3.mdx @@ -59,7 +59,7 @@ AdCP 3.0 expands the protocol beyond media buying into brand identity, governanc **RFC 9421 HTTP Message Signatures are optional in 3.0 and mandatory under AdCP Verified.** Agents sign mutating requests with Ed25519 over a canonicalized covered-component list (method, target URI, `content-digest`, protocol-level fields). The spec pins sf-binary encoding and URL canonicalization so independent implementations produce bit-identical canonical inputs. A 15-step verification checklist defines the seller's path: `alg` allowlist, `keyid` cap-before-crypto (defense against unbounded verification), JWKS resolution via SSRF-validated fetch, `jti` replay dedup, audience binding. Published test vectors at `static/compliance/source/test-vectors/request-signing/` let implementers validate correctness offline. -**Webhooks are signed under the same RFC 9421 profile — baseline-required for sellers.** Webhook authentication unifies on the AdCP 9421 profile as a symmetric variant of request signing: the seller signs outbound webhook requests with a key published in its JWKS at `jwks_uri` (discoverable via `brand.json` `agents[]`). New signers use `adcp_use: "request-signing"` for webhook delivery; deprecated `adcp_use: "webhook-signing"` keys remain accepted during the compatibility window. Operators that want webhook-only key material publish a distinct `request-signing` `kid`. No shared secret crosses the wire. The buyer verifies the signature using the seller's JWKS. A 14-step webhook verifier checklist — documented in the [Security guide](/docs/building/implementation/security) — covers trust-anchor scoping, downgrade-and-injection resistance, and per-keyid replay dedup (100K per keyid, 10M aggregate); verification failures return typed reason codes defined there. HMAC-SHA256 remains a legacy fallback through 3.x (opt-in via `push_notification_config.authentication.credentials`); the entire `authentication` object is removed in 4.0. +**Webhooks are signed under the same RFC 9421 profile — baseline-required for sellers.** Webhook authentication unifies on the AdCP 9421 profile as a symmetric variant of request signing: the seller signs outbound webhook requests with a key published in its JWKS at `jwks_uri` (discoverable via `brand.json` `agents[]`). New signers use `adcp_use: "request-signing"` for webhook delivery; deprecated `adcp_use: "webhook-signing"` keys remain accepted during the compatibility window. Operators that want webhook-only key material publish a distinct `request-signing` `kid`. No shared secret crosses the wire. The buyer verifies the signature using the seller's JWKS. A 14-step webhook verifier checklist — documented in the [Security guide](/docs/building/by-layer/L1/security) — covers trust-anchor scoping, downgrade-and-injection resistance, and per-keyid replay dedup (100K per keyid, 10M aggregate); verification failures return typed reason codes defined there. HMAC-SHA256 remains a legacy fallback through 3.x (opt-in via `push_notification_config.authentication.credentials`); the entire `authentication` object is removed in 4.0. **Every webhook payload carries a required `idempotency_key`.** Webhooks use at-least-once delivery, so receivers must dedupe. Every webhook payload — MCP, collection-list changes, property-list changes, content-standards artifacts, rights revocations — carries a sender-generated, cryptographically-random UUID v4 `idempotency_key` stable across retries of the same event. Same name and format as the request-side field. Predictable keys allow pre-seeding a receiver's dedup cache to suppress legitimate events, so sellers MUST generate keys from a cryptographic source. @@ -69,9 +69,9 @@ AdCP 3.0 expands the protocol beyond media buying into brand identity, governanc **Cross-instance state persistence is now a spec requirement.** Agent state — tasks, media buys, plans, signed artifacts, idempotency keys — MUST be persistent across horizontally-scaled instances. In-memory-only state is non-compliant for production. -See the [Security implementation guide](/docs/building/implementation/security) for the full threat model, principal roles (brand / operator / agent), and step-by-step verification paths. +See the [Security implementation guide](/docs/building/by-layer/L1/security) for the full threat model, principal roles (brand / operator / agent), and step-by-step verification paths. - + Threat model, signing profile, verification paths, and the universal security storyboard. diff --git a/static/compliance/source/universal/webhook-emission.yaml b/static/compliance/source/universal/webhook-emission.yaml index bc5d61f536..6fbda272d0 100644 --- a/static/compliance/source/universal/webhook-emission.yaml +++ b/static/compliance/source/universal/webhook-emission.yaml @@ -660,7 +660,7 @@ phases: narrative: | Every outbound webhook MUST verify under the 14-step webhook verifier checklist per - docs/building/implementation/security.mdx#verifier-checklist-for-webhooks. + docs/building/by-layer/L1/security.mdx#verifier-checklist-for-webhooks. The runner registers the trigger as a 9421-default buyer (no `authentication` block on `push_notification_config`); the agent is graded on the signatures it emits in that mode. diff --git a/static/schemas/source/core/push-notification-config.json b/static/schemas/source/core/push-notification-config.json index 261763639a..62fded619c 100644 --- a/static/schemas/source/core/push-notification-config.json +++ b/static/schemas/source/core/push-notification-config.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/push-notification-config.json", "title": "Push Notification Config", - "description": "Webhook configuration for asynchronous task notifications. Uses A2A-compatible PushNotificationConfig structure. By default, webhooks are signed with the AdCP RFC 9421 profile (see docs/building/implementation/security.mdx#webhook-callbacks) — the seller signs outbound with a key published at the jwks_uri on its own brand.json `agents[]` entry and the buyer verifies against that JWKS, so no shared secret crosses the wire. The optional `authentication` block selects the legacy Bearer or HMAC-SHA256 fallback for compatibility with receivers that have not yet adopted the 9421 profile; this fallback is deprecated and will be removed in AdCP 4.0. Note: the `idempotency_key` that receivers dedup on lives inside the webhook **payload** body (see docs/building/implementation/webhooks.mdx#reliability and the mcp-webhook-payload schema), not in this configuration object — this schema only describes the receiver-side transport config sent to the seller. This schema is designed for composition via allOf - consuming schemas should define their own additionalProperties constraints.", + "description": "Webhook configuration for asynchronous task notifications. Uses A2A-compatible PushNotificationConfig structure. By default, webhooks are signed with the AdCP RFC 9421 profile (see docs/building/by-layer/L1/security.mdx#webhook-callbacks) — the seller signs outbound with a key published at the jwks_uri on its own brand.json `agents[]` entry and the buyer verifies against that JWKS, so no shared secret crosses the wire. The optional `authentication` block selects the legacy Bearer or HMAC-SHA256 fallback for compatibility with receivers that have not yet adopted the 9421 profile; this fallback is deprecated and will be removed in AdCP 4.0. Note: the `idempotency_key` that receivers dedup on lives inside the webhook **payload** body (see docs/building/by-layer/L3/webhooks.mdx#reliability and the mcp-webhook-payload schema), not in this configuration object — this schema only describes the receiver-side transport config sent to the seller. This schema is designed for composition via allOf - consuming schemas should define their own additionalProperties constraints.", "type": "object", "properties": { "url": { @@ -25,7 +25,7 @@ }, "authentication": { "type": "object", - "description": "Legacy authentication configuration (A2A-compatible). Opts the seller into Bearer or HMAC-SHA256 signing instead of the default RFC 9421 webhook profile. Deprecated; removed in AdCP 4.0. **Precedence is a switch, not a fallback:** presence of this block selects the legacy scheme; absence selects 9421. A seller MUST NOT sign the same webhook both ways, and a buyer MUST NOT attempt 'try 9421 first, fall back to HMAC' verification — signature mode is determined solely by whether this block was present at registration time. The seller's baseline 9421 webhook key is published at its brand.json `agents[]` `jwks_uri` using `adcp_use: \"request-signing\"` (deprecated `webhook-signing` keys remain accepted during the compatibility window); it does not override this selector and is only used when `authentication` is omitted. See docs/building/implementation/security.mdx#webhook-callbacks for the full precedence and downgrade-resistance rules (including the `webhook_mode_mismatch` rejection a buyer MUST apply when a received webhook's signing mode does not match the registered mode).", + "description": "Legacy authentication configuration (A2A-compatible). Opts the seller into Bearer or HMAC-SHA256 signing instead of the default RFC 9421 webhook profile. Deprecated; removed in AdCP 4.0. **Precedence is a switch, not a fallback:** presence of this block selects the legacy scheme; absence selects 9421. A seller MUST NOT sign the same webhook both ways, and a buyer MUST NOT attempt 'try 9421 first, fall back to HMAC' verification — signature mode is determined solely by whether this block was present at registration time. The seller's baseline 9421 webhook key is published at its brand.json `agents[]` `jwks_uri` using `adcp_use: \"request-signing\"` (deprecated `webhook-signing` keys remain accepted during the compatibility window); it does not override this selector and is only used when `authentication` is omitted. See docs/building/by-layer/L1/security.mdx#webhook-callbacks for the full precedence and downgrade-resistance rules (including the `webhook_mode_mismatch` rejection a buyer MUST apply when a received webhook's signing mode does not match the registered mode).", "properties": { "schemes": { "type": "array", diff --git a/tests/webhook-signing-vectors.test.cjs b/tests/webhook-signing-vectors.test.cjs index 0438d6921e..8f06e43992 100644 --- a/tests/webhook-signing-vectors.test.cjs +++ b/tests/webhook-signing-vectors.test.cjs @@ -24,7 +24,7 @@ function readVector(relativePath) { function extractSig1Signature(vector) { const signature = vector.request?.headers?.Signature; assert.equal(typeof signature, 'string', `${vector.name}: Signature header must be present`); - const match = signature.match(/^sig1=:([A-Za-z0-9_-]+):$/); + const match = signature.match(/^sig1=:([A-Za-z0-9+/_=-]+):$/); assert.ok(match, `${vector.name}: Signature header must contain a sig1 sf-binary value`); return Buffer.from(match[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }