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..b905b4053 --- /dev/null +++ b/.changeset/webhook-verify-accept-request-signing-key.md @@ -0,0 +1,32 @@ +--- +'@adcp/sdk': minor +--- + +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 (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 — +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 +`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. + +Webhook key isolation, when wanted, is a second `request-signing` key under a +distinct `kid` — not a distinct `adcp_use`. + +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/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/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..3cfb8a009 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** (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 + * 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** (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 * transport responses directly. * - `'governance-signing'` — for JWKs used to sign governance context 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..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 @@ -109,7 +110,42 @@ function throwIfPurposeMismatch(keyid: string, actual: string | undefined, expec } } -export { assertProviderPurpose }; +/** + * 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 (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. + */ +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; + throw new WebhookSignatureError( + 'webhook_signature_key_purpose_invalid', + 8, + `Signing key '${keyid}' has adcp_use=${actual === undefined ? '' : `'${actual}'`} ` + + `but webhook signing requires 'request-signing' (the deprecated 'webhook-signing' is also accepted).` + ); +} + +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..5f5aff597 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,27 +170,35 @@ export async function verifyWebhookSignature( ); } - // Step 8: key purpose — MUST be scoped for webhook signing. + // Step 8: key purpose — webhooks are signed with a `request-signing` key. // - // 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). - if (jwk.adcp_use === undefined || !jwk.key_ops?.includes('verify')) { + // 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, 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 + // 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. + if ( + jwk.adcp_use === undefined || + !jwk.key_ops?.includes('verify') || + (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-signing verification.` - ); - } - if (jwk.adcp_use !== 'webhook-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}" 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/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/fixtures/webhook-signing-vectors/README.md b/test/fixtures/webhook-signing-vectors/README.md index 18f678039..d34739049 100644 --- a/test/fixtures/webhook-signing-vectors/README.md +++ b/test/fixtures/webhook-signing-vectors/README.md @@ -2,17 +2,19 @@ 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`. +**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`. ## ⚠️ 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. @@ -24,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 @@ -32,8 +34,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 +45,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 +66,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 +106,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 +144,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 +190,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 +208,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 380881516..001ccce27 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_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", + "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..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=request-signing instead of webhook-signing", - "spec_reference": "#webhook-callbacks (mode_mismatch at step 8; adcp#2467 split)", + "name": "Signing key has adcp_use=response-signing instead of a webhook-valid purpose", + "spec_reference": "#webhook-callbacks (key_purpose_invalid at step 8)", "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_mode_mismatch", + "error_code": "webhook_signature_key_purpose_invalid", "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_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/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/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({ 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..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 @@ -85,8 +88,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 +98,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_signature_key_purpose_invalid', 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 }); @@ -114,9 +138,9 @@ describe('webhook verifier: webhook_mode_mismatch (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, /request-signing/); + assert.match(thrown.message, /response-signing/); }); test('JWK with adcp_use undefined still rejected with webhook_signature_key_purpose_invalid', async () => {