From f0ccf857c893356b1882ec114b67682c2b2f5d06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 16:45:46 +0000 Subject: [PATCH 1/4] feat(signing): add signerProvider option to createWebhookEmitter for KMS-backed webhook signing Closes #1020. Adopters who moved to the 5.20.0 SigningProvider path for request signing still held an in-process JWK for webhook delivery; this closes that gap by letting createWebhookEmitter accept a signerProvider that routes through signWebhookAsync instead. All emitter semantics (retries, idempotency-key stability, content-digest, redirect policy) are identical between the two paths. https://claude.ai/code/session_01EsiKFg3cUHDxgwDpfXJLRJ --- .changeset/webhook-emitter-signer-provider.md | 15 +++ src/lib/server/webhook-emitter.ts | 41 +++++-- test/lib/webhook-emitter.test.js | 101 ++++++++++++++++++ 3 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 .changeset/webhook-emitter-signer-provider.md diff --git a/.changeset/webhook-emitter-signer-provider.md b/.changeset/webhook-emitter-signer-provider.md new file mode 100644 index 000000000..3b5058996 --- /dev/null +++ b/.changeset/webhook-emitter-signer-provider.md @@ -0,0 +1,15 @@ +--- +"@adcp/client": minor +--- + +feat(signing): add `signerProvider` option to `createWebhookEmitter` for KMS-backed webhook signing + +Adopters who moved request signing to a managed key store (GCP KMS, AWS KMS, Azure Key Vault) via the 5.20.0 `SigningProvider` abstraction previously still had to hold a private JWK in process for webhook signing, defeating the KMS threat model. + +`WebhookEmitterOptions` now accepts `signerProvider?: SigningProvider` as a KMS-backed alternative to `signerKey`. Internally, the emitter routes to `signWebhookAsync` when a provider is set and `signWebhook` when a `signerKey` is set. Exactly one must be provided; construction throws `TypeError` if neither or both are given. + +All existing emitter semantics (retries, idempotency-key stability, content-digest, redirect policy) are identical between the two paths — only the signing dispatch differs. + +**Migration note:** `signerKey` changes from required to optional at the TypeScript type level. Existing callers that pass `signerKey` are unaffected. Callers who forward `WebhookEmitterOptions` and rely on `signerKey` being a required field in their own type signatures should update those types. + +**JWKS note:** The JWK published at `jwks_uri` for the key wrapped by a `signerProvider` MUST carry `adcp_use: "webhook-signing"` — receivers validate key purpose against this field. diff --git a/src/lib/server/webhook-emitter.ts b/src/lib/server/webhook-emitter.ts index 5cdc18f10..523b067de 100644 --- a/src/lib/server/webhook-emitter.ts +++ b/src/lib/server/webhook-emitter.ts @@ -22,6 +22,8 @@ */ import { signWebhook, type SignerKey } from '../signing/signer'; +import { signWebhookAsync } from '../signing/signer-async'; +import type { SigningProvider } from '../signing/provider'; import type { RequestLike } from '../signing/canonicalize'; import { createHmac, randomUUID } from 'node:crypto'; @@ -103,8 +105,19 @@ export interface WebhookRetryOptions { } export interface WebhookEmitterOptions { - /** Ed25519 / ECDSA-P256 signing key. `adcp_use` MUST be `"webhook-signing"`. */ - signerKey: SignerKey; + /** + * In-process JWK signing key. `adcp_use` MUST be `"webhook-signing"`. + * Mutually exclusive with `signerProvider` — exactly one must be provided. + */ + signerKey?: SignerKey; + /** + * Async KMS-backed signing provider (GCP KMS, AWS KMS, Azure Key Vault, etc.). + * Routes webhook signing through `signWebhookAsync` so the private key never + * enters process memory. Mutually exclusive with `signerKey` — exactly one + * must be provided. The JWKS entry for the wrapped key MUST carry + * `adcp_use: "webhook-signing"` so receivers can validate key purpose. + */ + signerProvider?: SigningProvider; retries?: WebhookRetryOptions; idempotencyKeyStore?: WebhookIdempotencyKeyStore; /** @@ -192,6 +205,12 @@ export interface WebhookEmitter { // ──────────────────────────────────────────────────────────── export function createWebhookEmitter(options: WebhookEmitterOptions): WebhookEmitter { + if (options.signerKey && options.signerProvider) { + throw new TypeError('createWebhookEmitter: provide exactly one of signerKey or signerProvider, not both'); + } + if (!options.signerKey && !options.signerProvider) { + throw new TypeError('createWebhookEmitter: one of signerKey or signerProvider is required'); + } const store = options.idempotencyKeyStore ?? memoryWebhookKeyStore(); const generateKey = options.generateIdempotencyKey ?? defaultGenerateIdempotencyKey; const fetchImpl = options.fetch ?? globalThis.fetch; @@ -230,6 +249,7 @@ export function createWebhookEmitter(options: WebhookEmitterOptions): WebhookEmi url: params.url, bodyBytes, signerKey: options.signerKey, + signerProvider: options.signerProvider, authentication: params.authentication ?? null, tag: options.tag, userAgent: options.userAgent, @@ -300,14 +320,15 @@ interface DeliveryResponse { async function deliverOnce(args: { url: string; bodyBytes: string; - signerKey: SignerKey; + signerKey?: SignerKey; + signerProvider?: SigningProvider; authentication: WebhookAuthentication; tag?: string; userAgent?: string; fetch: typeof fetch; suppressLegacyWarnings?: boolean; }): Promise { - const headers = buildHeaders(args); + const headers = await buildHeaders(args); const response = await args.fetch(args.url, { method: 'POST', headers, @@ -319,15 +340,16 @@ async function deliverOnce(args: { }; } -function buildHeaders(args: { +async function buildHeaders(args: { url: string; bodyBytes: string; - signerKey: SignerKey; + signerKey?: SignerKey; + signerProvider?: SigningProvider; authentication: WebhookAuthentication; tag?: string; userAgent?: string; suppressLegacyWarnings?: boolean; -}): Record { +}): Promise> { const baseHeaders: Record = { 'content-type': 'application/json', }; @@ -366,7 +388,10 @@ function buildHeaders(args: { headers: baseHeaders, body: args.bodyBytes, }; - const signed = signWebhook(request, args.signerKey, args.tag !== undefined ? { tag: args.tag } : {}); + const signOpts = args.tag !== undefined ? { tag: args.tag } : {}; + const signed = args.signerProvider + ? await signWebhookAsync(request, args.signerProvider, signOpts) + : signWebhook(request, args.signerKey!, signOpts); return { ...baseHeaders, ...signed.headers }; } diff --git a/test/lib/webhook-emitter.test.js b/test/lib/webhook-emitter.test.js index bd0ccf99a..2290ebfc7 100644 --- a/test/lib/webhook-emitter.test.js +++ b/test/lib/webhook-emitter.test.js @@ -22,6 +22,7 @@ const { verifyWebhookSignature } = require('../../dist/lib/signing/webhook-verif const { StaticJwksResolver } = require('../../dist/lib/signing/jwks.js'); const { InMemoryReplayStore } = require('../../dist/lib/signing/replay.js'); const { InMemoryRevocationStore } = require('../../dist/lib/signing/revocation.js'); +const { signerKeyToProvider } = require('../../dist/lib/signing/testing.js'); // ──────────────────────────────────────────────────────────── // Fixtures @@ -319,3 +320,103 @@ describe('createWebhookEmitter: observability', () => { assert.strictEqual(results[1].status, 204); }); }); + +// ──────────────────────────────────────────────────────────── +// signerProvider path (KMS-backed async signing) +// ──────────────────────────────────────────────────────────── + +describe('createWebhookEmitter: signerProvider path', () => { + test('provider-signed webhook produces a 9421 signature the public verifier accepts', async () => { + const { signerKey, publicJwk } = makeSignerKey(); + const provider = signerKeyToProvider(signerKey); + const fetch = stubFetch([{ status: 204 }]); + const emitter = createWebhookEmitter({ signerProvider: provider, fetch, sleep: noSleep }); + + await emitter.emit({ + url: 'https://buyer.example/webhook', + payload: { task: { task_id: 'mb-provider' } }, + operation_id: 'op.provider', + }); + + const call = fetch.calls[0]; + const verified = await verifyWebhookSignature( + { method: 'POST', url: call.url, headers: call.headers, body: call.body }, + { + jwks: new StaticJwksResolver([publicJwk]), + replayStore: new InMemoryReplayStore(), + revocationStore: new InMemoryRevocationStore(), + } + ); + assert.strictEqual(verified.status, 'verified'); + }); + + test('provider path delivers and returns idempotency_key with compact body', async () => { + const { signerKey } = makeSignerKey(); + const provider = signerKeyToProvider(signerKey); + const fetch = stubFetch([{ status: 204 }]); + const emitter = createWebhookEmitter({ signerProvider: provider, fetch, sleep: noSleep }); + + const result = await emitter.emit({ + url: 'http://127.0.0.1:9999/webhook', + payload: { task: { task_id: 'mb-provider-2' } }, + operation_id: 'op.provider2', + }); + + assert.strictEqual(result.delivered, true); + assert.strictEqual(result.attempts, 1); + assert.match(result.idempotency_key, /^[A-Za-z0-9_.:-]{16,255}$/); + const body = JSON.parse(fetch.calls[0].body); + assert.strictEqual(body.idempotency_key, result.idempotency_key); + assert.ok(!fetch.calls[0].body.includes(', '), 'body MUST be compact (adcp#2478)'); + }); + + test('provider path retries on 503 with stable idempotency_key', async () => { + const { signerKey } = makeSignerKey(); + const provider = signerKeyToProvider(signerKey); + const fetch = stubFetch([{ status: 503 }, { status: 204 }]); + const emitter = createWebhookEmitter({ signerProvider: provider, fetch, sleep: noSleep }); + + const result = await emitter.emit({ + url: 'http://127.0.0.1/hook', + payload: { event: 'retry' }, + operation_id: 'op.provider-retry', + }); + + assert.strictEqual(result.delivered, true); + assert.strictEqual(fetch.calls.length, 2); + const keys = fetch.calls.map((c) => JSON.parse(c.body).idempotency_key); + assert.strictEqual(new Set(keys).size, 1, 'idempotency_key MUST be stable across retries (adcp#2417)'); + }); +}); + +// ──────────────────────────────────────────────────────────── +// Construction-time mutual-exclusion validation +// ──────────────────────────────────────────────────────────── + +describe('createWebhookEmitter: construction validation', () => { + test('throws when neither signerKey nor signerProvider is provided', () => { + const fetch = stubFetch([]); + assert.throws( + () => createWebhookEmitter({ fetch, sleep: noSleep }), + (err) => { + assert.ok(err instanceof TypeError); + assert.match(err.message, /one of signerKey or signerProvider is required/); + return true; + } + ); + }); + + test('throws when both signerKey and signerProvider are provided', () => { + const { signerKey } = makeSignerKey(); + const provider = signerKeyToProvider(signerKey); + const fetch = stubFetch([]); + assert.throws( + () => createWebhookEmitter({ signerKey, signerProvider: provider, fetch, sleep: noSleep }), + (err) => { + assert.ok(err instanceof TypeError); + assert.match(err.message, /provide exactly one of signerKey or signerProvider, not both/); + return true; + } + ); + }); +}); From c829f2fb4428ce63068546668aa631ccb837f957 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 16:49:25 +0000 Subject: [PATCH 2/4] fix(signing): wire signerProvider into createAdcpServer webhooks and export SigningProvider Pre-PR review caught two blockers: - createAdcpServer webhooks Pick omitted signerProvider, blocking KMS path via the server factory - SigningProvider type not re-exported from server barrel, requiring internal import path Also adds final_status assertion to provider retry test per review nit. https://claude.ai/code/session_01EsiKFg3cUHDxgwDpfXJLRJ --- src/lib/server/create-adcp-server.ts | 20 ++++++++++++++------ src/lib/server/index.ts | 1 + test/lib/webhook-emitter.test.js | 1 + 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index 7d4a469dd..c4c6f1e8e 100644 --- a/src/lib/server/create-adcp-server.ts +++ b/src/lib/server/create-adcp-server.ts @@ -1096,15 +1096,23 @@ export interface AdcpServerConfig { * idempotency-stable webhooks without hand-rolling the pipeline. Omit * if your server never emits webhooks. * - * The `signerKey` MUST have `adcp_use: "webhook-signing"` — a - * request-signing key is a conformance violation per adcp#2423 (key - * purpose discriminator). Publishers publishing their JWKS at the - * `jwks_uri` on brand.json's `agents[]` entry reuse the same key across - * every buyer they deliver to. + * Provide exactly one of `signerKey` (in-process JWK) or `signerProvider` + * (KMS-backed async signing). The signing key or provider key MUST have + * `adcp_use: "webhook-signing"` — a request-signing key is a conformance + * violation per adcp#2423 (key purpose discriminator). Publishers publishing + * their JWKS at the `jwks_uri` on brand.json's `agents[]` entry reuse the + * same key across every buyer they deliver to. */ webhooks?: Pick< WebhookEmitterOptions, - 'signerKey' | 'retries' | 'idempotencyKeyStore' | 'generateIdempotencyKey' | 'fetch' | 'userAgent' | 'tag' + | 'signerKey' + | 'signerProvider' + | 'retries' + | 'idempotencyKeyStore' + | 'generateIdempotencyKey' + | 'fetch' + | 'userAgent' + | 'tag' > & { /** Observability: emitter-wide onAttempt hook. */ onAttempt?: WebhookEmitterOptions['onAttempt']; diff --git a/src/lib/server/index.ts b/src/lib/server/index.ts index 366257e60..0e2127db2 100644 --- a/src/lib/server/index.ts +++ b/src/lib/server/index.ts @@ -275,6 +275,7 @@ export type { WebhookRetryOptions, WebhookAuthentication, } from './webhook-emitter'; +export type { SigningProvider } from '../signing/provider'; export { checkGovernance, governanceDeniedError } from './governance'; export type { diff --git a/test/lib/webhook-emitter.test.js b/test/lib/webhook-emitter.test.js index 2290ebfc7..839b3df3e 100644 --- a/test/lib/webhook-emitter.test.js +++ b/test/lib/webhook-emitter.test.js @@ -383,6 +383,7 @@ describe('createWebhookEmitter: signerProvider path', () => { }); assert.strictEqual(result.delivered, true); + assert.strictEqual(result.final_status, 204); assert.strictEqual(fetch.calls.length, 2); const keys = fetch.calls.map((c) => JSON.parse(c.body).idempotency_key); assert.strictEqual(new Set(keys).size, 1, 'idempotency_key MUST be stable across retries (adcp#2417)'); From c61d055cae2586e93b1b1bc58a83a7c3a60e0544 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 26 Apr 2026 16:38:09 -0400 Subject: [PATCH 3/4] style(test): apply prettier formatting to webhook-emitter test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's format:check failed on the test file added in #1021. No logic change — pure prettier --write. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/lib/webhook-emitter.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/lib/webhook-emitter.test.js b/test/lib/webhook-emitter.test.js index 839b3df3e..f85f30144 100644 --- a/test/lib/webhook-emitter.test.js +++ b/test/lib/webhook-emitter.test.js @@ -385,7 +385,7 @@ describe('createWebhookEmitter: signerProvider path', () => { assert.strictEqual(result.delivered, true); assert.strictEqual(result.final_status, 204); assert.strictEqual(fetch.calls.length, 2); - const keys = fetch.calls.map((c) => JSON.parse(c.body).idempotency_key); + const keys = fetch.calls.map(c => JSON.parse(c.body).idempotency_key); assert.strictEqual(new Set(keys).size, 1, 'idempotency_key MUST be stable across retries (adcp#2417)'); }); }); @@ -399,7 +399,7 @@ describe('createWebhookEmitter: construction validation', () => { const fetch = stubFetch([]); assert.throws( () => createWebhookEmitter({ fetch, sleep: noSleep }), - (err) => { + err => { assert.ok(err instanceof TypeError); assert.match(err.message, /one of signerKey or signerProvider is required/); return true; @@ -413,7 +413,7 @@ describe('createWebhookEmitter: construction validation', () => { const fetch = stubFetch([]); assert.throws( () => createWebhookEmitter({ signerKey, signerProvider: provider, fetch, sleep: noSleep }), - (err) => { + err => { assert.ok(err instanceof TypeError); assert.match(err.message, /provide exactly one of signerKey or signerProvider, not both/); return true; From 90c6222aca66a3dec369ee3fff66739fffb4bc1d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 26 Apr 2026 16:52:08 -0400 Subject: [PATCH 4/4] fix(signing): tighten signerProvider docs + lock wire-byte parity in test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reviewers (security, code, protocol). Security: clean. Code: no blockers. Protocol: sound-with-caveats. Addressing the convergent items: - **Single-purpose key warning on `signerProvider`** (protocol caveat + code-review nit). JSDoc now explicitly tells operators NOT to wire the same `SigningProvider` instance to both `request_signing.provider` and `webhooks.signerProvider` — per `SIGNING-GUIDE.md:38-43`, AdCP requires distinct key material per purpose. Mint a second `cryptoKeyVersion` for webhook signing. - **`WebhookEmitResult.errors` leak warning** (security LOW). When a KMS adapter rejection bubbles through the retry loop, the message text comes from the adapter and may include infra detail (KMS resource names, IAM principals, project IDs). New JSDoc mirrors the caution flagged on `SigningProvider.fingerprint`. - **Wire-byte equality test** (code-review issue). The PR claims "only the dispatch differs between sync and provider paths" — now asserted: same body + same nonce + same `created` produces identical `signatureBase`, identical `Signature-Input`, identical Ed25519 Signature bytes (deterministic), identical `Content-Digest`. Locks the contract beyond prose. Test totals: 20/20 (was 19). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/server/webhook-emitter.ts | 27 ++++++++++++++++++-- test/lib/webhook-emitter.test.js | 42 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/lib/server/webhook-emitter.ts b/src/lib/server/webhook-emitter.ts index 523b067de..6b75302e8 100644 --- a/src/lib/server/webhook-emitter.ts +++ b/src/lib/server/webhook-emitter.ts @@ -114,8 +114,20 @@ export interface WebhookEmitterOptions { * Async KMS-backed signing provider (GCP KMS, AWS KMS, Azure Key Vault, etc.). * Routes webhook signing through `signWebhookAsync` so the private key never * enters process memory. Mutually exclusive with `signerKey` — exactly one - * must be provided. The JWKS entry for the wrapped key MUST carry - * `adcp_use: "webhook-signing"` so receivers can validate key purpose. + * 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. */ signerProvider?: SigningProvider; retries?: WebhookRetryOptions; @@ -193,6 +205,17 @@ export interface WebhookEmitResult { attempts: number; delivered: boolean; final_status?: number; + /** + * Per-attempt error messages (transport / signer / network failures). + * + * **Logging caution:** when a `signerProvider` rejection bubbles into + * this array, the message text comes from the adapter and may include + * infra-flavored detail (KMS resource names, IAM principals, project + * IDs). Mirrors the same caution flagged on `SigningProvider.fingerprint`. + * If you pipe `errors[]` into shared logs / observability pipelines, + * sanitize or redact at your boundary — adapter messages aren't + * guaranteed to be operator-safe. + */ errors: string[]; } diff --git a/test/lib/webhook-emitter.test.js b/test/lib/webhook-emitter.test.js index f85f30144..6668c2786 100644 --- a/test/lib/webhook-emitter.test.js +++ b/test/lib/webhook-emitter.test.js @@ -390,6 +390,48 @@ describe('createWebhookEmitter: signerProvider path', () => { }); }); +// ──────────────────────────────────────────────────────────── +// Wire-byte equality: signerKey and signerProvider paths produce +// byte-identical signature bases for the same input. Locks the +// "only the dispatch differs" contract claimed in the PR description. +// ──────────────────────────────────────────────────────────── + +describe('createWebhookEmitter: signerKey and signerProvider produce byte-identical signature bases', () => { + test('same body + same nonce + same `created` → identical Signature-Input + signatureBase', async () => { + const { signerKey } = makeSignerKey('parity-test-2026'); + const provider = signerKeyToProvider(signerKey); + + const fixedNow = 1_700_000_000; + const fixedNonce = 'parity-test-nonce-fixed-1234'; + + // Pin nonce + now via signOptions so the only nondeterministic input + // (timestamp + nonce) is fixed across both paths. Ed25519 is + // deterministic, so the resulting Signature bytes will also match — + // but the strong assertion is the signatureBase, which the verifier + // is what receivers compute against. + const { signWebhook, signWebhookAsync } = require('../../dist/lib/signing/index.js'); + const request = { + method: 'POST', + url: 'https://example.test/webhook', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ idempotency_key: 'idem-test', operation_id: 'op-1', payload: { hello: 'world' } }), + }; + const sigOpts = { now: () => fixedNow, nonce: fixedNonce }; + + const sync = signWebhook(request, signerKey, sigOpts); + const async_ = await signWebhookAsync(request, provider, sigOpts); + + // Signature base — the bytes the verifier hashes. Wire equivalence. + assert.strictEqual(async_.signatureBase, sync.signatureBase); + // Signature-Input header — same params (kid, alg, tag, nonce, created, + // expires, components). For Ed25519 (deterministic) the Signature + // bytes also match. + assert.strictEqual(async_.headers['Signature-Input'], sync.headers['Signature-Input']); + assert.strictEqual(async_.headers.Signature, sync.headers.Signature); + assert.strictEqual(async_.headers['Content-Digest'], sync.headers['Content-Digest']); + }); +}); + // ──────────────────────────────────────────────────────────── // Construction-time mutual-exclusion validation // ────────────────────────────────────────────────────────────