From 2c73927342caa36d9395e1087cdfde899a2b926f Mon Sep 17 00:00:00 2001 From: BaiyuScope3 Date: Mon, 15 Jun 2026 13:09:57 -0400 Subject: [PATCH 1/5] feat(signing): accept reused request-signing key for webhook delivery Webhook verifier step 8 now accepts a key whose adcp_use is either 'webhook-signing' or 'request-signing', letting a signer reuse the request-signing key it already publishes instead of minting a dedicated webhook key. Any other purpose (response-signing, governance-signing, unknown) is still rejected with webhook_mode_mismatch. Safe because domain separation is carried by the RFC 9421 tag (adcp/webhook-signing/v1, part of the signed base) plus mandatory content-digest coverage, not by the key-purpose discriminator: a captured request signature can never be replayed against the webhook verifier because step 3 rejects its tag. signWebhook / signWebhookAsync accept the same set. Conformance vector 008 flipped from negative (request-signing rejected) to positive (request-signing reuse accepted); a new negative 008 covers a response-signing key. A dedicated webhook key remains RECOMMENDED but is no longer REQUIRED. Co-Authored-By: Claude Opus 4.8 --- ...bhook-verify-accept-request-signing-key.md | 27 +++++++++++++ src/lib/signing/signer-async.ts | 3 +- src/lib/signing/signer.ts | 40 ++++++++++++++++++- src/lib/signing/webhook-verifier.ts | 28 ++++++++----- .../webhook-signing-vectors/keys.json | 16 +++++++- .../negative/008-wrong-adcp-use.json | 12 +++--- .../008-request-signing-key-reuse.json | 24 +++++++++++ test/signing-provider-purpose-gate.test.js | 11 ++--- test/webhook-verifier-error-codes.test.js | 33 ++++++++++++--- 9 files changed, 160 insertions(+), 34 deletions(-) create mode 100644 .changeset/webhook-verify-accept-request-signing-key.md create mode 100644 test/fixtures/webhook-signing-vectors/positive/008-request-signing-key-reuse.json diff --git a/.changeset/webhook-verify-accept-request-signing-key.md b/.changeset/webhook-verify-accept-request-signing-key.md new file mode 100644 index 000000000..7bf289a73 --- /dev/null +++ b/.changeset/webhook-verify-accept-request-signing-key.md @@ -0,0 +1,27 @@ +--- +'@adcp/sdk': minor +--- + +feat(signing): webhook verifier accepts a reused request-signing key + +A signer may now reuse its `adcp_use: "request-signing"` key to sign outbound +webhooks instead of minting a dedicated `adcp_use: "webhook-signing"` key. The +webhook verifier (step 8) accepts a key whose `adcp_use` is either +`"webhook-signing"` or `"request-signing"`; any other purpose +(`response-signing`, `governance-signing`, unknown) is still rejected with +`webhook_mode_mismatch`. The signer helpers (`signWebhook` / +`signWebhookAsync`) accept the same set. + +This is safe because cross-protocol confusion is prevented by the RFC 9421 +`tag` (`adcp/webhook-signing/v1`, part of the signed base) and mandatory +`content-digest` coverage — not by the key-purpose discriminator. A captured +request signature (`tag=adcp/request-signing/v1`) can never be replayed +against the webhook verifier because step 3 rejects the tag. + +A dedicated webhook-signing key remains RECOMMENDED for blast-radius isolation +and independent rotation, but is no longer REQUIRED. + +Conformance vectors updated: former negative `008-wrong-adcp-use` is now +positive `008-request-signing-key-reuse`; a new negative `008-wrong-adcp-use` +covers a `response-signing` key (still rejected). Tracks the spec change in +adcontextprotocol/adcp. diff --git a/src/lib/signing/signer-async.ts b/src/lib/signing/signer-async.ts index 73630c5bd..94c081ed4 100644 --- a/src/lib/signing/signer-async.ts +++ b/src/lib/signing/signer-async.ts @@ -9,6 +9,7 @@ import type { } from './signer'; import { assertProviderPurpose, + assertWebhookProviderPurpose, finalizeRequestSignature, finalizeResponseSignature, prepareRequestSignature, @@ -50,7 +51,7 @@ export async function signWebhookAsync( provider: SigningProvider, options: SignWebhookOptions = {} ): Promise { - assertProviderPurpose(provider, 'webhook-signing'); + assertWebhookProviderPurpose(provider); const prepared = prepareWebhookSignature(request, { keyid: provider.keyid, alg: provider.algorithm }, options); const signature = await provider.sign(Buffer.from(prepared.base, 'utf8')); return finalizeRequestSignature(prepared, signature); diff --git a/src/lib/signing/signer.ts b/src/lib/signing/signer.ts index f6c572698..df6bed9dd 100644 --- a/src/lib/signing/signer.ts +++ b/src/lib/signing/signer.ts @@ -109,7 +109,43 @@ function throwIfPurposeMismatch(keyid: string, actual: string | undefined, expec } } -export { assertProviderPurpose }; +/** + * Purpose gate for the webhook signing helpers. Unlike the single-purpose + * gates above, webhook signing accepts EITHER a dedicated + * `adcp_use: "webhook-signing"` key OR the signer's existing + * `adcp_use: "request-signing"` key — reuse is the signer's choice. See + * webhook-verifier.ts step 8 for the security rationale: the signature `tag` + * (`adcp/webhook-signing/v1`) plus mandatory `content-digest` coverage carry + * domain separation, so the key purpose need not be webhook-specific. Any + * other purpose (`response-signing`, `governance-signing`, unknown) is + * refused with the same `webhook_signature_key_purpose_invalid` code the + * verifier emits. + */ +const WEBHOOK_SIGNING_PURPOSES: readonly string[] = ['webhook-signing', 'request-signing']; + +function throwIfWebhookPurposeMismatch(keyid: string, actual: string | undefined): void { + if (actual !== undefined && WEBHOOK_SIGNING_PURPOSES.includes(actual)) return; + throw new WebhookSignatureError( + 'webhook_signature_key_purpose_invalid', + 8, + `Signing key '${keyid}' has adcp_use=${actual === undefined ? '' : `'${actual}'`} ` + + `but webhook signing requires 'webhook-signing' or 'request-signing'.` + ); +} + +function assertWebhookKeyPurpose(key: SignerKey): void { + throwIfWebhookPurposeMismatch(key.keyid, key.privateKey.adcp_use); +} + +function assertWebhookProviderPurpose( + provider: { readonly keyid: string; readonly adcpUse?: string }, + options: { requirePurpose?: boolean } = {} +): void { + if (provider.adcpUse === undefined && !options.requirePurpose) return; + throwIfWebhookPurposeMismatch(provider.keyid, provider.adcpUse); +} + +export { assertProviderPurpose, assertWebhookProviderPurpose }; export interface SignRequestOptions { coverContentDigest?: boolean; @@ -291,7 +327,7 @@ export function prepareWebhookSignature( * conformant webhooks should use this instead of hand-rolling signatures. */ export function signWebhook(request: RequestLike, key: SignerKey, options: SignWebhookOptions = {}): SignedRequest { - assertKeyPurpose(key, 'webhook-signing'); + assertWebhookKeyPurpose(key); const prepared = prepareWebhookSignature(request, { keyid: key.keyid, alg: key.alg }, options); const signature = produceSignature(key, Buffer.from(prepared.base, 'utf8')); return finalizeRequestSignature(prepared, signature); diff --git a/src/lib/signing/webhook-verifier.ts b/src/lib/signing/webhook-verifier.ts index 0a45e370b..05ffd41e3 100644 --- a/src/lib/signing/webhook-verifier.ts +++ b/src/lib/signing/webhook-verifier.ts @@ -8,7 +8,8 @@ * * Distinct from request-signing: * - Tag: `adcp/webhook-signing/v1` (vs `adcp/request-signing/v1`). - * - Key purpose: `adcp_use: "webhook-signing"` (vs `"request-signing"`). + * - Key purpose: `adcp_use: "webhook-signing"` OR `"request-signing"` — a + * signer may reuse its request-signing key for webhooks (see step 8). * - Covered components MUST include `@method`, `@target-uri`, `@authority`, * `content-type`, and `content-digest` — `content-digest` is unconditional * for webhooks (vs policy-driven on requests) because every webhook @@ -169,15 +170,22 @@ export async function verifyWebhookSignature( ); } - // Step 8: key purpose — MUST be scoped for webhook signing. + // Step 8: key purpose — MUST be scoped for webhook delivery. + // + // A signer MAY publish a dedicated `adcp_use: "webhook-signing"` key, OR + // reuse the `adcp_use: "request-signing"` key it already publishes for + // outbound request signing — the choice is the signer's. Both are accepted + // here because cross-protocol confusion is prevented by the signature `tag` + // (step 3, `adcp/webhook-signing/v1`, part of the signed base) and the + // mandatory `content-digest` coverage (step 6) — not by the key-purpose + // discriminator. A captured request signature (`tag=adcp/request-signing/v1`) + // can never be replayed against this verifier because step 3 rejects it. // // Split failure modes so operators can tell "key isn't scoped at all" (or - // lacks the verify key_op) apart from "key is scoped for a different - // mode". The former needs the publisher to add a purpose; the latter - // needs a new keypair minted for the right mode (a request-signing key - // MUST NOT be reused for webhook-signing even if the crypto material is - // compatible — purpose binding is the whole point of the `adcp_use` - // discriminator). + // lacks the verify key_op) apart from "key is scoped for a purpose that is + // not valid for webhook delivery" (e.g. `response-signing`, `governance- + // signing`). A dedicated webhook-signing key remains RECOMMENDED for + // blast-radius isolation, but it is no longer REQUIRED. if (jwk.adcp_use === undefined || !jwk.key_ops?.includes('verify')) { throw new WebhookSignatureError( 'webhook_signature_key_purpose_invalid', @@ -185,11 +193,11 @@ export async function verifyWebhookSignature( `JWK "${jwk.kid}" is not scoped for webhook-signing verification.` ); } - if (jwk.adcp_use !== 'webhook-signing') { + if (jwk.adcp_use !== 'webhook-signing' && jwk.adcp_use !== 'request-signing') { throw new WebhookSignatureError( 'webhook_mode_mismatch', 8, - `JWK "${jwk.kid}" declares adcp_use="${jwk.adcp_use}" but this endpoint requires "webhook-signing".` + `JWK "${jwk.kid}" declares adcp_use="${jwk.adcp_use}" but webhook delivery requires "webhook-signing" or "request-signing".` ); } diff --git a/test/fixtures/webhook-signing-vectors/keys.json b/test/fixtures/webhook-signing-vectors/keys.json index 380881516..d4e460a70 100644 --- a/test/fixtures/webhook-signing-vectors/keys.json +++ b/test/fixtures/webhook-signing-vectors/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/test/fixtures/webhook-signing-vectors/negative/008-wrong-adcp-use.json b/test/fixtures/webhook-signing-vectors/negative/008-wrong-adcp-use.json index 23bc4a225..2f96c50a0 100644 --- a/test/fixtures/webhook-signing-vectors/negative/008-wrong-adcp-use.json +++ b/test/fixtures/webhook-signing-vectors/negative/008-wrong-adcp-use.json @@ -1,5 +1,5 @@ { - "name": "Signing key has adcp_use=request-signing instead of webhook-signing", + "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": { @@ -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_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/test/fixtures/webhook-signing-vectors/positive/008-request-signing-key-reuse.json b/test/fixtures/webhook-signing-vectors/positive/008-request-signing-key-reuse.json new file mode 100644 index 000000000..e1dd873e3 --- /dev/null +++ b/test/fixtures/webhook-signing-vectors/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." +} diff --git a/test/signing-provider-purpose-gate.test.js b/test/signing-provider-purpose-gate.test.js index 712b7cc06..4ec604318 100644 --- a/test/signing-provider-purpose-gate.test.js +++ b/test/signing-provider-purpose-gate.test.js @@ -130,19 +130,14 @@ describe('SigningProvider.adcpUse — purpose gate, async path', () => { assert.ok(signed.headers.Signature); }); - test('rejects a provider with adcpUse="request-signing"', async () => { + test('accepts a provider with adcpUse="request-signing" (signer may reuse its request key)', async () => { const provider = new InMemorySigningProvider({ keyid: KID, algorithm: 'ed25519', privateKey: privateJwk(KID, { adcp_use: 'request-signing' }), }); - await assert.rejects( - () => signWebhookAsync(SAMPLE_REQUEST, provider, SIGN_OPTIONS), - err => - err instanceof WebhookSignatureError && - err.code === 'webhook_signature_key_purpose_invalid' && - /request-signing/.test(err.message) - ); + const signed = await signWebhookAsync(SAMPLE_REQUEST, provider, SIGN_OPTIONS); + assert.ok(signed.headers.Signature); }); test('rejects a response-signing provider key', async () => { diff --git a/test/webhook-verifier-error-codes.test.js b/test/webhook-verifier-error-codes.test.js index 5730c8b84..31c6767f2 100644 --- a/test/webhook-verifier-error-codes.test.js +++ b/test/webhook-verifier-error-codes.test.js @@ -85,8 +85,8 @@ async function verify(requestLike, jwks, opts = {}) { }); } -describe('webhook verifier: webhook_mode_mismatch (adcp#2467)', () => { - test('JWK with adcp_use="request-signing" rejected with webhook_mode_mismatch', async () => { +describe('webhook verifier: key-purpose acceptance at step 8 (adcp#2467)', () => { + test('JWK with adcp_use="request-signing" is ACCEPTED (signer may reuse its request key)', async () => { const now = Math.floor(Date.now() / 1000); const signerKey = signerKeyFor('test-wrong-purpose-2026'); const request = { @@ -95,13 +95,34 @@ describe('webhook verifier: webhook_mode_mismatch (adcp#2467)', () => { headers: { 'Content-Type': 'application/json' }, body: '{"idempotency_key":"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B","status":"completed"}', }; - // Bypass the signer-side adcp_use gate: this test exists precisely to - // exercise the verifier's step-8 cross-purpose rejection, which requires - // a wire payload signed with a request-signing key. + // Signing with a request-signing key is now a supported choice, so the + // convenience signer would accept it; sign directly to assert the + // verifier accepts the resulting wire payload at step 8. const signed = signWebhookBypassingPurposeGate(request, signerKey, { now: () => now }); const jwk = toPublicJwk(keyByKid('test-wrong-purpose-2026')); // adcp_use: "request-signing" const jwks = new StaticJwksResolver([jwk]); + const result = await verify({ ...request, headers: { ...request.headers, ...signed.headers } }, jwks, { now }); + assert.strictEqual(result.status, 'verified'); + assert.strictEqual(result.keyid, 'test-wrong-purpose-2026'); + }); + + test('JWK with adcp_use="response-signing" rejected with webhook_mode_mismatch', async () => { + const now = Math.floor(Date.now() / 1000); + const signerKey = signerKeyFor('test-response-purpose-2026'); + const request = { + method: 'POST', + url: 'https://buyer.example.com/adcp/webhook/create_media_buy/agent_123/op_abc', + headers: { 'Content-Type': 'application/json' }, + body: '{"idempotency_key":"whk_01HW9D3H8FZP2N6R8T0V4X6Z9B","status":"completed"}', + }; + // Bypass the signer-side gate to construct a wire payload signed by a + // genuinely non-webhook key, exercising the verifier's step-8 rejection + // for purposes that are neither webhook-signing nor request-signing. + const signed = signWebhookBypassingPurposeGate(request, signerKey, { now: () => now }); + const jwk = toPublicJwk(keyByKid('test-response-purpose-2026')); // adcp_use: "response-signing" + const jwks = new StaticJwksResolver([jwk]); + let thrown; try { await verify({ ...request, headers: { ...request.headers, ...signed.headers } }, jwks, { now }); @@ -116,7 +137,7 @@ describe('webhook verifier: webhook_mode_mismatch (adcp#2467)', () => { ); assert.strictEqual(thrown.code, 'webhook_mode_mismatch'); assert.strictEqual(thrown.failedStep, 8); - assert.match(thrown.message, /request-signing/); + assert.match(thrown.message, /response-signing/); }); test('JWK with adcp_use undefined still rejected with webhook_signature_key_purpose_invalid', async () => { From e05d44e27b42d52f15becceec61ba3b8b2390866 Mon Sep 17 00:00:00 2001 From: BaiyuScope3 Date: Mon, 15 Jun 2026 14:21:16 -0400 Subject: [PATCH 2/5] fix(signing): use key_purpose_invalid for all webhook key-purpose failures Per reviewer feedback on the paired spec PR: stop overloading webhook_mode_mismatch (reserved for the HMAC-vs-9421 auth-mode selector) for key-purpose failures. Step 8 now emits webhook_signature_key_purpose_invalid for absent adcp_use, a missing verify key_op, OR an adcp_use outside {webhook-signing, request-signing}. Updates the negative 008 vector, keys.json and fixture README, and the test assertion to match. Co-Authored-By: Claude Opus 4.8 --- ...bhook-verify-accept-request-signing-key.md | 8 ++++-- src/lib/signing/webhook-verifier.ts | 28 +++++++++---------- .../webhook-signing-vectors/README.md | 28 ++++++++++--------- .../webhook-signing-vectors/keys.json | 2 +- .../negative/008-wrong-adcp-use.json | 6 ++-- test/webhook-verifier-error-codes.test.js | 13 +++++---- 6 files changed, 46 insertions(+), 39 deletions(-) diff --git a/.changeset/webhook-verify-accept-request-signing-key.md b/.changeset/webhook-verify-accept-request-signing-key.md index 7bf289a73..b1a26cf7b 100644 --- a/.changeset/webhook-verify-accept-request-signing-key.md +++ b/.changeset/webhook-verify-accept-request-signing-key.md @@ -8,9 +8,11 @@ A signer may now reuse its `adcp_use: "request-signing"` key to sign outbound webhooks instead of minting a dedicated `adcp_use: "webhook-signing"` key. The webhook verifier (step 8) accepts a key whose `adcp_use` is either `"webhook-signing"` or `"request-signing"`; any other purpose -(`response-signing`, `governance-signing`, unknown) is still rejected with -`webhook_mode_mismatch`. The signer helpers (`signWebhook` / -`signWebhookAsync`) accept the same set. +(`response-signing`, `governance-signing`, unknown), absent `adcp_use`, or a +missing `verify` key_op is rejected with `webhook_signature_key_purpose_invalid`. +`webhook_mode_mismatch` is unchanged — it remains reserved for the HMAC-vs-9421 +auth-mode selector and is not used for key-purpose failures. The signer helpers +(`signWebhook` / `signWebhookAsync`) accept the same set. This is safe because cross-protocol confusion is prevented by the RFC 9421 `tag` (`adcp/webhook-signing/v1`, part of the signed base) and mandatory diff --git a/src/lib/signing/webhook-verifier.ts b/src/lib/signing/webhook-verifier.ts index 05ffd41e3..39262480f 100644 --- a/src/lib/signing/webhook-verifier.ts +++ b/src/lib/signing/webhook-verifier.ts @@ -181,23 +181,23 @@ export async function verifyWebhookSignature( // discriminator. A captured request signature (`tag=adcp/request-signing/v1`) // can never be replayed against this verifier because step 3 rejects it. // - // Split failure modes so operators can tell "key isn't scoped at all" (or - // lacks the verify key_op) apart from "key is scoped for a purpose that is - // not valid for webhook delivery" (e.g. `response-signing`, `governance- - // signing`). A dedicated webhook-signing key remains RECOMMENDED for - // blast-radius isolation, but it is no longer REQUIRED. - if (jwk.adcp_use === undefined || !jwk.key_ops?.includes('verify')) { + // All key-purpose failures use `webhook_signature_key_purpose_invalid`: + // absent `adcp_use`, a missing `verify` key_op, or an `adcp_use` outside the + // accepted set (e.g. `response-signing`, `governance-signing`). We do NOT + // reuse `webhook_mode_mismatch` here — that code is reserved for the + // HMAC-vs-9421 auth-mode selector mismatch, and overloading it would collapse + // two distinct failure classes onto one stable code receivers branch on. A + // dedicated webhook-signing key remains RECOMMENDED for blast-radius + // isolation, but it is no longer REQUIRED. + if ( + jwk.adcp_use === undefined || + !jwk.key_ops?.includes('verify') || + (jwk.adcp_use !== 'webhook-signing' && jwk.adcp_use !== 'request-signing') + ) { throw new WebhookSignatureError( 'webhook_signature_key_purpose_invalid', 8, - `JWK "${jwk.kid}" is not scoped for webhook-signing verification.` - ); - } - if (jwk.adcp_use !== 'webhook-signing' && jwk.adcp_use !== 'request-signing') { - throw new WebhookSignatureError( - 'webhook_mode_mismatch', - 8, - `JWK "${jwk.kid}" declares adcp_use="${jwk.adcp_use}" but webhook delivery requires "webhook-signing" or "request-signing".` + `JWK "${jwk.kid}" is not scoped for webhook delivery: adcp_use must be "webhook-signing" or "request-signing" with a "verify" key_op (got adcp_use="${jwk.adcp_use}").` ); } diff --git a/test/fixtures/webhook-signing-vectors/README.md b/test/fixtures/webhook-signing-vectors/README.md index 18f678039..c614c283e 100644 --- a/test/fixtures/webhook-signing-vectors/README.md +++ b/test/fixtures/webhook-signing-vectors/README.md @@ -2,17 +2,17 @@ Test vectors for the AdCP RFC 9421 webhook-signing profile. These fixtures drive cross-implementation conformance testing so a signer written in one SDK and a verifier written in another agree on the wire format of outbound push-notification webhooks. -Specification: [Webhook callbacks](https://adcontextprotocol.org/docs/building/implementation/security#webhook-callbacks) in `docs/building/implementation/security.mdx`. +Specification: [Webhook callbacks](https://adcontextprotocol.org/docs/building/by-layer/L1/security#webhook-callbacks) in `docs/building/by-layer/L1/security.mdx`. -**Canonical URLs.** These vectors are served at `https://adcontextprotocol.org/test-vectors/webhook-signing/` (tree preserved — `keys.json`, `negative/*.json`, `positive/*.json` all resolvable). SDKs SHOULD fetch from the CDN path rather than requiring a checkout of the spec repo. Example: `https://adcontextprotocol.org/test-vectors/webhook-signing/positive/001-basic-post.json`. +**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 -`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 -These vectors exercise the [webhook verifier checklist](https://adcontextprotocol.org/docs/building/implementation/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 `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. 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. @@ -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_mode_mismatch (step 8; adcp_use=request-signing, split from key_purpose_invalid per adcp#2467) +│ ├── 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` @@ -140,7 +142,7 @@ Vector 020 tests that the verifier rejects JWKs whose `key_ops` lacks `"verify"` ### `jwks_ref` semantics (positive vector 003) -Vector 003 includes two `Signature-Input` labels: the vector's own `sig1` and a decorative `relay` label. Verifiers MUST process the label named `sig1` specifically — not "the first label in the header," not "any label." The spec at [`#adcp-rfc-9421-profile`](https://adcontextprotocol.org/docs/building/implementation/security#adcp-rfc-9421-profile) says signers name the label `sig1` by convention, and verifiers key off the name rather than position. If an implementation picks a different label, the vector will fail even if that label's signature is individually valid. +Vector 003 includes two `Signature-Input` labels: the vector's own `sig1` and a decorative `relay` label. Verifiers MUST process the label named `sig1` specifically — not "the first label in the header," not "any label." The spec at [`#adcp-rfc-9421-profile`](https://adcontextprotocol.org/docs/building/by-layer/L1/security#adcp-rfc-9421-profile) says signers name the label `sig1` by convention, and verifiers key off the name rather than position. If an implementation picks a different label, the vector will fail even if that label's signature is individually valid. ### Signature validity vs. payload schema validation (positive vector 007) @@ -186,9 +188,9 @@ For each vector in `negative/`: 3. Feed `vec.request` to your verifier. 4. Assert the verifier rejects with `success: false` AND `error_code` equal to `vec.expected_outcome.error_code` byte-for-byte. The `failed_step` is not graded. -### Integration with `@adcp/sdk` +### Integration with `@adcp/client` -Receiver libraries built on top of `@adcp/sdk` SHOULD run these vectors in CI against the library's 9421 webhook verifier. The `webhook-emission` universal's live E2E grading complements but does not replace this — live grading exercises positive paths; static negative vectors are the only reliable path to cover every `webhook_signature_*` error code deterministically. +Receiver libraries built on top of `@adcp/client` SHOULD run these vectors in CI against the library's 9421 webhook verifier. The `webhook-emission` universal's live E2E grading complements but does not replace this — live grading exercises positive paths; static negative vectors are the only reliable path to cover every `webhook_signature_*` error code deterministically. ## Key material @@ -204,7 +206,7 @@ A verification script at `.context/verify-webhook-vectors.mjs` checks that all p ## Specification cross-reference -- Webhook callbacks profile: `docs/building/implementation/security.mdx#webhook-callbacks` +- Webhook callbacks profile: `docs/building/by-layer/L1/security.mdx#webhook-callbacks` - Verifier checklist: `#verifier-checklist-for-webhooks` - Error taxonomy: `#webhook-error-taxonomy` - Replay dedup and sizing: `#webhook-replay-dedup-sizing` diff --git a/test/fixtures/webhook-signing-vectors/keys.json b/test/fixtures/webhook-signing-vectors/keys.json index d4e460a70..001ccce27 100644 --- a/test/fixtures/webhook-signing-vectors/keys.json +++ b/test/fixtures/webhook-signing-vectors/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/test/fixtures/webhook-signing-vectors/negative/008-wrong-adcp-use.json b/test/fixtures/webhook-signing-vectors/negative/008-wrong-adcp-use.json index 2f96c50a0..d87b94257 100644 --- a/test/fixtures/webhook-signing-vectors/negative/008-wrong-adcp-use.json +++ b/test/fixtures/webhook-signing-vectors/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." } diff --git a/test/webhook-verifier-error-codes.test.js b/test/webhook-verifier-error-codes.test.js index 31c6767f2..4d6c86091 100644 --- a/test/webhook-verifier-error-codes.test.js +++ b/test/webhook-verifier-error-codes.test.js @@ -1,7 +1,10 @@ /** - * Error-code coverage for the webhook-signing verifier split added in - * adcp#2467: `webhook_mode_mismatch` (wrong adcp_use for mode) and - * `webhook_target_uri_malformed` (syntactically invalid @target-uri). + * Error-code coverage for the webhook-signing verifier step-8 key-purpose + * check and `webhook_target_uri_malformed` (syntactically invalid + * @target-uri). Webhook delivery accepts `adcp_use` of `webhook-signing` or + * `request-signing`; every other purpose failure returns + * `webhook_signature_key_purpose_invalid`. `webhook_mode_mismatch` is reserved + * for the HMAC-vs-9421 auth-mode selector and is NOT used for key purpose. * * Exercises the verifier directly rather than going through the storyboard * runner so the step-level semantics — distinct codes for distinct @@ -107,7 +110,7 @@ describe('webhook verifier: key-purpose acceptance at step 8 (adcp#2467)', () => assert.strictEqual(result.keyid, 'test-wrong-purpose-2026'); }); - test('JWK with adcp_use="response-signing" rejected with webhook_mode_mismatch', async () => { + test('JWK with adcp_use="response-signing" rejected with webhook_signature_key_purpose_invalid', async () => { const now = Math.floor(Date.now() / 1000); const signerKey = signerKeyFor('test-response-purpose-2026'); const request = { @@ -135,7 +138,7 @@ describe('webhook verifier: key-purpose acceptance at step 8 (adcp#2467)', () => thrown instanceof WebhookSignatureError, `Expected WebhookSignatureError, got ${thrown?.constructor?.name}: ${thrown?.message}` ); - assert.strictEqual(thrown.code, 'webhook_mode_mismatch'); + assert.strictEqual(thrown.code, 'webhook_signature_key_purpose_invalid'); assert.strictEqual(thrown.failedStep, 8); assert.match(thrown.message, /response-signing/); }); From 971ec4ee746a17ee51ae697c8c9cc75c83387527 Mon Sep 17 00:00:00 2001 From: BaiyuScope3 Date: Mon, 15 Jun 2026 14:43:32 -0400 Subject: [PATCH 3/5] refactor(signing): 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 a request-signing key (no separate webhook key purpose). Verifier step 8 and the signer gates still accept request-signing and the deprecated webhook-signing (backward compat, removed in 4.0); the error message and all doc comments now lead with request-signing. Marks the AdcpUse 'webhook-signing' member deprecated and updates the webhook-emitter key-purpose guidance (reuse the request-signing provider; isolate via a second request-signing kid, not a distinct adcp_use). No behavior change — code already accepted both purposes. Co-Authored-By: Claude Opus 4.8 --- ...bhook-verify-accept-request-signing-key.md | 35 ++++++++++--------- src/lib/server/webhook-emitter.ts | 28 +++++++-------- src/lib/signing/errors.ts | 16 +++++---- src/lib/signing/jwks-helpers.ts | 18 ++++++++-- src/lib/signing/signer.ts | 23 ++++++------ src/lib/signing/webhook-verifier.ts | 29 +++++++-------- .../webhook-signing-vectors/README.md | 2 ++ 7 files changed, 86 insertions(+), 65 deletions(-) diff --git a/.changeset/webhook-verify-accept-request-signing-key.md b/.changeset/webhook-verify-accept-request-signing-key.md index b1a26cf7b..a7a18b281 100644 --- a/.changeset/webhook-verify-accept-request-signing-key.md +++ b/.changeset/webhook-verify-accept-request-signing-key.md @@ -2,17 +2,19 @@ '@adcp/sdk': minor --- -feat(signing): webhook verifier accepts a reused request-signing key +feat(signing): sign and verify webhooks with the request-signing key -A signer may now reuse its `adcp_use: "request-signing"` key to sign outbound -webhooks instead of minting a dedicated `adcp_use: "webhook-signing"` key. The -webhook verifier (step 8) accepts a key whose `adcp_use` is either -`"webhook-signing"` or `"request-signing"`; any other purpose -(`response-signing`, `governance-signing`, unknown), absent `adcp_use`, or a -missing `verify` key_op is rejected with `webhook_signature_key_purpose_invalid`. -`webhook_mode_mismatch` is unchanged — it remains reserved for the HMAC-vs-9421 -auth-mode selector and is not used for key-purpose failures. The signer helpers -(`signWebhook` / `signWebhookAsync`) accept the same set. +Webhooks are signed with the agent's `adcp_use: "request-signing"` key — there +is no separate webhook key purpose. The webhook verifier (step 8) accepts a key +whose `adcp_use` is `"request-signing"`; the deprecated `"webhook-signing"` +value is still accepted for backward compatibility (removed in 4.0). Any other +purpose (`response-signing`, `governance-signing`, unknown), absent `adcp_use`, +or a missing `verify` key_op is rejected with +`webhook_signature_key_purpose_invalid`. `webhook_mode_mismatch` is unchanged — +it remains reserved for the HMAC-vs-9421 auth-mode selector and is not used for +key-purpose failures. The signer helpers (`signWebhook` / `signWebhookAsync`) +accept the same set, and the webhook emitter may reuse the request-signing +provider/key. This is safe because cross-protocol confusion is prevented by the RFC 9421 `tag` (`adcp/webhook-signing/v1`, part of the signed base) and mandatory @@ -20,10 +22,11 @@ This is safe because cross-protocol confusion is prevented by the RFC 9421 request signature (`tag=adcp/request-signing/v1`) can never be replayed against the webhook verifier because step 3 rejects the tag. -A dedicated webhook-signing key remains RECOMMENDED for blast-radius isolation -and independent rotation, but is no longer REQUIRED. +Webhook key isolation, when wanted, is a second `request-signing` key under a +distinct `kid` — not a distinct `adcp_use`. -Conformance vectors updated: former negative `008-wrong-adcp-use` is now -positive `008-request-signing-key-reuse`; a new negative `008-wrong-adcp-use` -covers a `response-signing` key (still rejected). Tracks the spec change in -adcontextprotocol/adcp. +Conformance vectors: positive `008-request-signing-key-reuse` covers a +request-signing key signing a webhook; negative `008-wrong-adcp-use` covers a +`response-signing` key (rejected); the existing `webhook-signing` positive +vectors continue to exercise the deprecated-but-accepted path. Tracks the spec +change in adcontextprotocol/adcp. diff --git a/src/lib/server/webhook-emitter.ts b/src/lib/server/webhook-emitter.ts index ab1b729dc..559a3eb01 100644 --- a/src/lib/server/webhook-emitter.ts +++ b/src/lib/server/webhook-emitter.ts @@ -106,8 +106,11 @@ export interface WebhookRetryOptions { export interface WebhookEmitterOptions { /** - * In-process JWK signing key. `adcp_use` MUST be `"webhook-signing"`. - * Mutually exclusive with `signerProvider` — exactly one must be provided. + * In-process JWK signing key. Its JWKS entry SHOULD carry + * `adcp_use: "request-signing"` — webhooks are signed with the agent's + * request-signing key (the deprecated `"webhook-signing"` value is still + * accepted by verifiers for backward compatibility). Mutually exclusive + * with `signerProvider` — exactly one must be provided. */ signerKey?: SignerKey; /** @@ -116,18 +119,15 @@ export interface WebhookEmitterOptions { * enters process memory. Mutually exclusive with `signerKey` — exactly one * must be provided. * - * **Single-purpose key requirement.** The JWKS entry for the wrapped key - * MUST carry `adcp_use: "webhook-signing"` so receivers can validate key - * purpose at JWKS-publication time. The `SigningProvider` interface - * exposes only `keyid` / `algorithm` / `fingerprint`, not JWKS metadata, - * so the SDK cannot enforce the purpose binding at runtime — it's the - * publisher's responsibility to publish the correct `adcp_use` on the - * JWK and to NOT reuse the same `SigningProvider` instance for both - * `request_signing.provider` and `webhooks.signerProvider`. Per - * `docs/guides/SIGNING-GUIDE.md` § Key separation, AdCP requires - * **distinct key material** per purpose; mint a second - * `cryptoKeyVersion` for webhook signing rather than sharing the - * request-signing key. + * **Key purpose.** Webhooks are signed with a `request-signing` key; domain + * separation between requests and webhooks is carried by the signature + * `tag`, not the `adcp_use` discriminator. The same `SigningProvider` used + * for `request_signing.provider` MAY be reused here. To isolate webhook key + * material (so a webhook-key compromise does not extend to request signing, + * or to rotate independently), wrap a second `request-signing` + * `cryptoKeyVersion` published under a distinct `kid` — isolation comes from + * the `kid`, not a distinct `adcp_use`. (The deprecated `"webhook-signing"` + * purpose is still accepted by verifiers for backward compatibility.) */ signerProvider?: SigningProvider; retries?: WebhookRetryOptions; diff --git a/src/lib/signing/errors.ts b/src/lib/signing/errors.ts index cfdc11ffa..a0f4c5f29 100644 --- a/src/lib/signing/errors.ts +++ b/src/lib/signing/errors.ts @@ -54,14 +54,16 @@ export type WebhookSignatureErrorCode = // `Signature-Input` headers themselves; this flags the covered URI. | 'webhook_target_uri_malformed' | 'webhook_signature_key_unknown' - // JWK has no `adcp_use` declared (or lacks the `verify` key_op). Kept - // distinct from `webhook_mode_mismatch` so operators can tell "key is not - // scoped at all" apart from "key is scoped for the wrong mode". + // Every webhook key-purpose failure: absent `adcp_use`, a missing `verify` + // key_op, or an `adcp_use` outside the accepted set. Webhooks are signed + // with a `request-signing` key (the deprecated `webhook-signing` is also + // accepted for backward compatibility); any other purpose + // (`response-signing`, `governance-signing`) is rejected with this code. | 'webhook_signature_key_purpose_invalid' - // JWK declares `adcp_use` but for a different mode than webhook-signing - // (e.g. `request-signing`). Separate code from `key_purpose_invalid` so - // the remediation is clear: mint a new key scoped for `webhook-signing` - // rather than adding the purpose to an existing request-signing key. + // The buyer's registered auth mode does not match the signing mode on the + // received webhook (HMAC-vs-9421 selector mismatch — see the spec's + // downgrade-resistance rules). This is NOT a key-purpose failure; reusing a + // request-signing key for webhooks is allowed and verifies cleanly. | 'webhook_mode_mismatch' | 'webhook_signature_key_revoked' | 'webhook_signature_revocation_stale' diff --git a/src/lib/signing/jwks-helpers.ts b/src/lib/signing/jwks-helpers.ts index 7f64c88b7..c17418d15 100644 --- a/src/lib/signing/jwks-helpers.ts +++ b/src/lib/signing/jwks-helpers.ts @@ -17,6 +17,17 @@ const WIRE_ALG_TO_JOSE: Record = { 'ecdsa-p256-sha256': 'ES256', }; +/** + * AdCP JWK purpose discriminator. + * + * `'webhook-signing'` is **deprecated** (removed in AdCP 4.0): webhooks are + * signed with a `'request-signing'` key, differentiated from request + * signatures by the RFC 9421 `tag`. Verifiers still accept `'webhook-signing'` + * on the webhook path for backward compatibility, but new signers SHOULD + * publish and sign with `'request-signing'` keys only (use a second + * `'request-signing'` key under a distinct `kid` when webhook key isolation + * is desired). + */ export type AdcpUse = 'request-signing' | 'webhook-signing' | 'response-signing' | 'governance-signing'; const ADCP_USE_VALUES = new Set([ @@ -42,8 +53,11 @@ export interface PemToAdcpJwkOptions { algorithm: AdcpSignAlg; /** * Purpose binding, enforced by AdCP verifiers at step 8. - * - `'request-signing'` — for JWKs published at the buyer's `jwks_uri`. - * - `'webhook-signing'` — for JWKs used to sign outbound webhook callbacks. + * - `'request-signing'` — for JWKs published at the buyer's `jwks_uri`. Also + * signs outbound webhook callbacks (differentiated by the RFC 9421 `tag`). + * - `'webhook-signing'` — **deprecated** (removed in 4.0); use + * `'request-signing'` for webhooks. Still accepted by verifiers for + * backward compatibility. * - `'response-signing'` — for compatibility with agents that sign JSON * transport responses directly. * - `'governance-signing'` — for JWKs used to sign governance context diff --git a/src/lib/signing/signer.ts b/src/lib/signing/signer.ts index df6bed9dd..a43dbac2b 100644 --- a/src/lib/signing/signer.ts +++ b/src/lib/signing/signer.ts @@ -110,18 +110,17 @@ function throwIfPurposeMismatch(keyid: string, actual: string | undefined, expec } /** - * Purpose gate for the webhook signing helpers. Unlike the single-purpose - * gates above, webhook signing accepts EITHER a dedicated - * `adcp_use: "webhook-signing"` key OR the signer's existing - * `adcp_use: "request-signing"` key — reuse is the signer's choice. See - * webhook-verifier.ts step 8 for the security rationale: the signature `tag` - * (`adcp/webhook-signing/v1`) plus mandatory `content-digest` coverage carry - * domain separation, so the key purpose need not be webhook-specific. Any - * other purpose (`response-signing`, `governance-signing`, unknown) is - * refused with the same `webhook_signature_key_purpose_invalid` code the - * verifier emits. + * Purpose gate for the webhook signing helpers. Webhooks are signed with a + * `adcp_use: "request-signing"` key — there is no dedicated webhook key + * purpose. See webhook-verifier.ts step 8 for the rationale: the signature + * `tag` (`adcp/webhook-signing/v1`) plus mandatory `content-digest` coverage + * carry domain separation, so the key purpose need not be webhook-specific. + * The deprecated `adcp_use: "webhook-signing"` value is still accepted for + * backward compatibility (removed in 4.0). Any other purpose + * (`response-signing`, `governance-signing`, unknown) is refused with the same + * `webhook_signature_key_purpose_invalid` code the verifier emits. */ -const WEBHOOK_SIGNING_PURPOSES: readonly string[] = ['webhook-signing', 'request-signing']; +const WEBHOOK_SIGNING_PURPOSES: readonly string[] = ['request-signing', 'webhook-signing']; function throwIfWebhookPurposeMismatch(keyid: string, actual: string | undefined): void { if (actual !== undefined && WEBHOOK_SIGNING_PURPOSES.includes(actual)) return; @@ -129,7 +128,7 @@ function throwIfWebhookPurposeMismatch(keyid: string, actual: string | undefined 'webhook_signature_key_purpose_invalid', 8, `Signing key '${keyid}' has adcp_use=${actual === undefined ? '' : `'${actual}'`} ` + - `but webhook signing requires 'webhook-signing' or 'request-signing'.` + `but webhook signing requires 'request-signing' (the deprecated 'webhook-signing' is also accepted).` ); } diff --git a/src/lib/signing/webhook-verifier.ts b/src/lib/signing/webhook-verifier.ts index 39262480f..3632cd8c1 100644 --- a/src/lib/signing/webhook-verifier.ts +++ b/src/lib/signing/webhook-verifier.ts @@ -170,34 +170,35 @@ export async function verifyWebhookSignature( ); } - // Step 8: key purpose — MUST be scoped for webhook delivery. + // Step 8: key purpose — webhooks are signed with a `request-signing` key. // - // A signer MAY publish a dedicated `adcp_use: "webhook-signing"` key, OR - // reuse the `adcp_use: "request-signing"` key it already publishes for - // outbound request signing — the choice is the signer's. Both are accepted - // here because cross-protocol confusion is prevented by the signature `tag` - // (step 3, `adcp/webhook-signing/v1`, part of the signed base) and the - // mandatory `content-digest` coverage (step 6) — not by the key-purpose - // discriminator. A captured request signature (`tag=adcp/request-signing/v1`) - // can never be replayed against this verifier because step 3 rejects it. + // Webhooks carry no separate key purpose: the signer uses its + // `adcp_use: "request-signing"` key (the deprecated `"webhook-signing"` + // value is still accepted here for backward compatibility, removed in 4.0). + // This is safe because cross-protocol confusion is prevented by the + // signature `tag` (step 3, `adcp/webhook-signing/v1`, part of the signed + // base) and mandatory `content-digest` coverage (step 6) — not by the + // key-purpose discriminator. A captured request signature + // (`tag=adcp/request-signing/v1`) can never be replayed against this + // verifier because step 3 rejects it. Webhook key isolation, when wanted, is + // a second `request-signing` key under a distinct `kid` — not a distinct + // `adcp_use`. // // All key-purpose failures use `webhook_signature_key_purpose_invalid`: // absent `adcp_use`, a missing `verify` key_op, or an `adcp_use` outside the // accepted set (e.g. `response-signing`, `governance-signing`). We do NOT // reuse `webhook_mode_mismatch` here — that code is reserved for the // HMAC-vs-9421 auth-mode selector mismatch, and overloading it would collapse - // two distinct failure classes onto one stable code receivers branch on. A - // dedicated webhook-signing key remains RECOMMENDED for blast-radius - // isolation, but it is no longer REQUIRED. + // two distinct failure classes onto one stable code receivers branch on. if ( jwk.adcp_use === undefined || !jwk.key_ops?.includes('verify') || - (jwk.adcp_use !== 'webhook-signing' && jwk.adcp_use !== 'request-signing') + (jwk.adcp_use !== 'request-signing' && jwk.adcp_use !== 'webhook-signing') ) { throw new WebhookSignatureError( 'webhook_signature_key_purpose_invalid', 8, - `JWK "${jwk.kid}" is not scoped for webhook delivery: adcp_use must be "webhook-signing" or "request-signing" with a "verify" key_op (got adcp_use="${jwk.adcp_use}").` + `JWK "${jwk.kid}" is not scoped for webhook delivery: adcp_use must be "request-signing" (or the deprecated "webhook-signing") with a "verify" key_op (got adcp_use="${jwk.adcp_use}").` ); } diff --git a/test/fixtures/webhook-signing-vectors/README.md b/test/fixtures/webhook-signing-vectors/README.md index c614c283e..c4a55a72f 100644 --- a/test/fixtures/webhook-signing-vectors/README.md +++ b/test/fixtures/webhook-signing-vectors/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 5fbdf93cfae0c7abcb6976f9d2b2e0c7de88bbea Mon Sep 17 00:00:00 2001 From: BaiyuScope3 Date: Mon, 15 Jun 2026 14:59:46 -0400 Subject: [PATCH 4/5] docs(signing): reference webhook-signing removal issue instead of 4.0 Mirror the spec review fix: replace hard-coded '4.0' removal language in the AdcpUse deprecation notes, verifier/signer/emitter comments, changeset, and vendored conformance README with a reference to the tracking issue (adcontextprotocol/adcp#5555), since the removal window is a WG decision. Co-Authored-By: Claude Opus 4.8 --- .changeset/webhook-verify-accept-request-signing-key.md | 2 +- src/lib/signing/jwks-helpers.ts | 4 ++-- src/lib/signing/signer.ts | 2 +- src/lib/signing/webhook-verifier.ts | 2 +- test/fixtures/webhook-signing-vectors/README.md | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.changeset/webhook-verify-accept-request-signing-key.md b/.changeset/webhook-verify-accept-request-signing-key.md index a7a18b281..b905b4053 100644 --- a/.changeset/webhook-verify-accept-request-signing-key.md +++ b/.changeset/webhook-verify-accept-request-signing-key.md @@ -7,7 +7,7 @@ feat(signing): sign and verify webhooks with the request-signing key Webhooks are signed with the agent's `adcp_use: "request-signing"` key — there is no separate webhook key purpose. The webhook verifier (step 8) accepts a key whose `adcp_use` is `"request-signing"`; the deprecated `"webhook-signing"` -value is still accepted for backward compatibility (removed in 4.0). Any other +value is still accepted for backward compatibility (pending removal — adcontextprotocol/adcp#5555). Any other purpose (`response-signing`, `governance-signing`, unknown), absent `adcp_use`, or a missing `verify` key_op is rejected with `webhook_signature_key_purpose_invalid`. `webhook_mode_mismatch` is unchanged — diff --git a/src/lib/signing/jwks-helpers.ts b/src/lib/signing/jwks-helpers.ts index c17418d15..3cfb8a009 100644 --- a/src/lib/signing/jwks-helpers.ts +++ b/src/lib/signing/jwks-helpers.ts @@ -20,7 +20,7 @@ const WIRE_ALG_TO_JOSE: Record = { /** * AdCP JWK purpose discriminator. * - * `'webhook-signing'` is **deprecated** (removed in AdCP 4.0): webhooks are + * `'webhook-signing'` is **deprecated** (pending removal — adcontextprotocol/adcp#5555): webhooks are * signed with a `'request-signing'` key, differentiated from request * signatures by the RFC 9421 `tag`. Verifiers still accept `'webhook-signing'` * on the webhook path for backward compatibility, but new signers SHOULD @@ -55,7 +55,7 @@ export interface PemToAdcpJwkOptions { * Purpose binding, enforced by AdCP verifiers at step 8. * - `'request-signing'` — for JWKs published at the buyer's `jwks_uri`. Also * signs outbound webhook callbacks (differentiated by the RFC 9421 `tag`). - * - `'webhook-signing'` — **deprecated** (removed in 4.0); use + * - `'webhook-signing'` — **deprecated** (pending removal — adcontextprotocol/adcp#5555); use * `'request-signing'` for webhooks. Still accepted by verifiers for * backward compatibility. * - `'response-signing'` — for compatibility with agents that sign JSON diff --git a/src/lib/signing/signer.ts b/src/lib/signing/signer.ts index a43dbac2b..2dcbfc702 100644 --- a/src/lib/signing/signer.ts +++ b/src/lib/signing/signer.ts @@ -116,7 +116,7 @@ function throwIfPurposeMismatch(keyid: string, actual: string | undefined, expec * `tag` (`adcp/webhook-signing/v1`) plus mandatory `content-digest` coverage * carry domain separation, so the key purpose need not be webhook-specific. * The deprecated `adcp_use: "webhook-signing"` value is still accepted for - * backward compatibility (removed in 4.0). Any other purpose + * backward compatibility (pending removal — adcontextprotocol/adcp#5555). Any other purpose * (`response-signing`, `governance-signing`, unknown) is refused with the same * `webhook_signature_key_purpose_invalid` code the verifier emits. */ diff --git a/src/lib/signing/webhook-verifier.ts b/src/lib/signing/webhook-verifier.ts index 3632cd8c1..5f5aff597 100644 --- a/src/lib/signing/webhook-verifier.ts +++ b/src/lib/signing/webhook-verifier.ts @@ -174,7 +174,7 @@ export async function verifyWebhookSignature( // // Webhooks carry no separate key purpose: the signer uses its // `adcp_use: "request-signing"` key (the deprecated `"webhook-signing"` - // value is still accepted here for backward compatibility, removed in 4.0). + // value is still accepted here for backward compatibility, pending removal — adcontextprotocol/adcp#5555). // This is safe because cross-protocol confusion is prevented by the // signature `tag` (step 3, `adcp/webhook-signing/v1`, part of the signed // base) and mandatory `content-digest` coverage (step 6) — not by the diff --git a/test/fixtures/webhook-signing-vectors/README.md b/test/fixtures/webhook-signing-vectors/README.md index c4a55a72f..d34739049 100644 --- a/test/fixtures/webhook-signing-vectors/README.md +++ b/test/fixtures/webhook-signing-vectors/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 4583344ca85c3a6aded05efd500a993fb885f2b3 Mon Sep 17 00:00:00 2001 From: BaiyuScope3 Date: Tue, 16 Jun 2026 13:46:59 -0400 Subject: [PATCH 5/5] fix(signing): accept request-signing keys on webhook emission/auto-wire paths Align with the merged spec (adcontextprotocol/adcp#5552): webhooks are signed with the request-signing key, so the sell-side emission grader (assertJwksPurpose) and the tenant-registry webhook auto-wire (assertWebhookSigningUse) must accept adcp_use 'request-signing' as well as the deprecated 'webhook-signing'. Previously both hard-required 'webhook-signing' and would reject a conformant request-signing agent. Updates the auto-wire tests and the signWebhook doc comment to match. Co-Authored-By: Claude Opus 4.8 --- src/lib/server/decisioning/tenant-registry.ts | 26 +++++++------ src/lib/signing/signer.ts | 3 +- src/lib/testing/storyboard/runner.ts | 21 +++++++--- ...server-decisioning-tenant-registry.test.js | 39 ++++++++++++++----- 4 files changed, 62 insertions(+), 27 deletions(-) diff --git a/src/lib/server/decisioning/tenant-registry.ts b/src/lib/server/decisioning/tenant-registry.ts index 296cf95cc..c3d47780d 100644 --- a/src/lib/server/decisioning/tenant-registry.ts +++ b/src/lib/server/decisioning/tenant-registry.ts @@ -610,29 +610,31 @@ function deriveSigningAlg(jwk: JsonWebKey): 'ed25519' | 'ecdsa-p256-sha256' { } /** - * Enforce AdCP key-purpose discriminator: a key wired into webhook - * signing MUST carry `adcp_use: "webhook-signing"`. Throws with a - * remediation-pointing error otherwise. Called only when auto-wiring - * fires (signingKey set + serverOptions.webhooks.signerKey unset); - * adopters who want different keys per purpose wire them explicitly + * Enforce the AdCP key-purpose discriminator on the webhook auto-wire path. + * Webhooks are signed with the agent's `adcp_use: "request-signing"` key + * (the deprecated `"webhook-signing"` value is still accepted — + * adcontextprotocol/adcp#5555); any other purpose is rejected. Called only + * when auto-wiring fires (signingKey set + serverOptions.webhooks.signerKey + * unset); adopters who want different keys per purpose wire them explicitly * and bypass this check. */ +const WEBHOOK_WIRE_PURPOSES = ['request-signing', 'webhook-signing']; function assertWebhookSigningUse(key: TenantSigningKey): void { const publicUse = (key.publicJwk as Record).adcp_use; const privateUse = (key.privateJwk as Record).adcp_use; - if (publicUse !== 'webhook-signing') { + if (typeof publicUse !== 'string' || !WEBHOOK_WIRE_PURPOSES.includes(publicUse)) { throw new Error( - `TenantConfig.signingKey: publicJwk.adcp_use must be 'webhook-signing' for the registry's webhook auto-wire path. ` + + `TenantConfig.signingKey: publicJwk.adcp_use must be 'request-signing' (or the deprecated 'webhook-signing') for the registry's webhook auto-wire path. ` + `Got ${publicUse === undefined ? '' : JSON.stringify(publicUse)}. ` + - 'Per AdCP, request-signing and webhook-signing keys MUST be distinct (key-purpose discriminator, adcp#2423). ' + - 'Either: (a) tag this key with `adcp_use: "webhook-signing"` if it IS the webhook-signing key, ' + - '(b) mint a separate webhook-signing key and put the request-signing key on serverOptions.signedRequests instead, or ' + + 'Webhooks are signed with the request-signing key; domain separation is carried by the RFC 9421 tag, not the key purpose. ' + + 'Either: (a) tag this key with `adcp_use: "request-signing"`, ' + + '(b) mint a separate request-signing key under a distinct kid for webhook isolation, or ' + '(c) wire `serverOptions.webhooks.signerKey` explicitly — the explicit config bypasses auto-wiring.' ); } - if (privateUse !== 'webhook-signing') { + if (publicUse !== privateUse) { throw new Error( - `TenantConfig.signingKey: privateJwk.adcp_use must be 'webhook-signing' (same purpose as publicJwk). ` + + `TenantConfig.signingKey: privateJwk.adcp_use must match publicJwk.adcp_use ('${publicUse}'). ` + `Got ${privateUse === undefined ? '' : JSON.stringify(privateUse)}.` ); } diff --git a/src/lib/signing/signer.ts b/src/lib/signing/signer.ts index 2dcbfc702..dec703278 100644 --- a/src/lib/signing/signer.ts +++ b/src/lib/signing/signer.ts @@ -27,7 +27,8 @@ export interface SignerKey { /** * Private JWK. MUST carry `adcp_use` matching the helper being called: * - `signRequest` requires `adcp_use: 'request-signing'` - * - `signWebhook` requires `adcp_use: 'webhook-signing'` + * - `signWebhook` requires `adcp_use: 'request-signing'` (the deprecated + * `'webhook-signing'` is still accepted — adcontextprotocol/adcp#5555) * - `signResponse` requires `adcp_use: 'response-signing'` * * Mismatched or missing `adcp_use` throws at the signer with the same diff --git a/src/lib/testing/storyboard/runner.ts b/src/lib/testing/storyboard/runner.ts index 308c51896..9d16a5942 100644 --- a/src/lib/testing/storyboard/runner.ts +++ b/src/lib/testing/storyboard/runner.ts @@ -4689,7 +4689,12 @@ async function executeProbeStep( } else if (step.task === 'fetch_brand_jwks') { httpResult = await probeBrandJwks(options._profile?.raw_capabilities, probeOpts); } else if (step.task === 'assert_jwks_purpose') { - httpResult = assertJwksPurpose(runState.priorProbes.get('fetch_brand_jwks'), 'webhook-signing'); + // Webhook delivery is signed with the agent's request-signing key; the + // deprecated webhook-signing purpose is still accepted (adcontextprotocol/adcp#5555). + httpResult = assertJwksPurpose(runState.priorProbes.get('fetch_brand_jwks'), [ + 'request-signing', + 'webhook-signing', + ]); } else if (step.task === 'expect_rate_limit_not_replayed') { const specError = validateRateLimitTripSpec(step.rate_limit_trip); if (specError) { @@ -5532,7 +5537,8 @@ async function probeBrandJwks( return jwks; } -function assertJwksPurpose(prior: HttpProbeResult | undefined, purpose: string): HttpProbeResult { +function assertJwksPurpose(prior: HttpProbeResult | undefined, purposes: string | readonly string[]): HttpProbeResult { + const accepted = typeof purposes === 'string' ? [purposes] : purposes; if (!prior || prior.error) { return { url: prior?.url ?? '', @@ -5555,7 +5561,12 @@ function assertJwksPurpose(prior: HttpProbeResult | undefined, purpose: string): const matching = keys.filter(key => { if (!key || typeof key !== 'object') return false; const rec = key as { adcp_use?: unknown; status?: unknown; revoked?: unknown }; - return rec.adcp_use === purpose && rec.status !== 'revoked' && rec.revoked !== true; + return ( + typeof rec.adcp_use === 'string' && + accepted.includes(rec.adcp_use) && + rec.status !== 'revoked' && + rec.revoked !== true + ); }); if (matching.length === 0) { return { @@ -5563,14 +5574,14 @@ function assertJwksPurpose(prior: HttpProbeResult | undefined, purpose: string): status: 0, headers: {}, body: prior.body, - error: `JWKS contains no active key with adcp_use="${purpose}"`, + error: `JWKS contains no active key with adcp_use in {${accepted.join(', ')}}`, }; } return { url: prior.url, status: 200, headers: prior.headers, - body: { purpose, matching_key_count: matching.length }, + body: { accepted_purposes: accepted, matching_key_count: matching.length }, }; } diff --git a/test/server-decisioning-tenant-registry.test.js b/test/server-decisioning-tenant-registry.test.js index 7c4466d60..4f49888ce 100644 --- a/test/server-decisioning-tenant-registry.test.js +++ b/test/server-decisioning-tenant-registry.test.js @@ -1490,11 +1490,11 @@ describe('TenantRegistry — webhook-signing auto-wire', () => { signingKey: noUseKey, platform: basePlatform(), }), - /publicJwk\.adcp_use must be 'webhook-signing'.*/s + /publicJwk\.adcp_use must be 'request-signing'.*/s ); }); - it('wrong adcp_use (e.g., request-signing) → register throws pointing at adcp#2423', () => { + it('adcp_use=request-signing → register succeeds (webhooks use the request-signing key)', () => { const requestSigningKey = { ...VALID_KEY, publicJwk: { ...VALID_KEY.publicJwk, adcp_use: 'request-signing' }, @@ -1506,14 +1506,35 @@ describe('TenantRegistry — webhook-signing auto-wire', () => { defaultServerOptions: DEFAULT_SERVER_OPTIONS, autoValidate: false, }); + assert.doesNotThrow(() => + registry.register('autowire-request-signing', { + agentUrl: 'https://autowire-request-signing.example.com', + signingKey: requestSigningKey, + platform: basePlatform(), + }) + ); + }); + + it('non-webhook-valid adcp_use (e.g., response-signing) → register throws', () => { + const responseSigningKey = { + ...VALID_KEY, + publicJwk: { ...VALID_KEY.publicJwk, adcp_use: 'response-signing' }, + privateJwk: { ...VALID_KEY.privateJwk, adcp_use: 'response-signing' }, + }; + const validator = fakeValidator(async () => ({ ok: true })); + const registry = createTenantRegistry({ + jwksValidator: validator, + defaultServerOptions: DEFAULT_SERVER_OPTIONS, + autoValidate: false, + }); assert.throws( () => - registry.register('autowire-wrong-use', { - agentUrl: 'https://autowire-wrong-use.example.com', - signingKey: requestSigningKey, + registry.register('autowire-response-use', { + agentUrl: 'https://autowire-response-use.example.com', + signingKey: responseSigningKey, platform: basePlatform(), }), - /adcp#2423/ + /publicJwk\.adcp_use must be 'request-signing'/ ); }); @@ -1535,7 +1556,7 @@ describe('TenantRegistry — webhook-signing auto-wire', () => { signingKey: asymmKey, platform: basePlatform(), }), - /privateJwk\.adcp_use.*same purpose as publicJwk/ + /privateJwk\.adcp_use must match publicJwk/ ); }); @@ -1568,8 +1589,8 @@ describe('TenantRegistry — webhook-signing auto-wire', () => { // auto-wiring even if signingKey would have failed the strict check. const wrongUseKey = { ...VALID_KEY, - publicJwk: { ...VALID_KEY.publicJwk, adcp_use: 'request-signing' }, - privateJwk: { ...VALID_KEY.privateJwk, adcp_use: 'request-signing' }, + publicJwk: { ...VALID_KEY.publicJwk, adcp_use: 'response-signing' }, + privateJwk: { ...VALID_KEY.privateJwk, adcp_use: 'response-signing' }, }; const validator = fakeValidator(async () => ({ ok: true })); const registry = createTenantRegistry({