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/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/src/lib/server/webhook-emitter.ts b/src/lib/server/webhook-emitter.ts index 5cdc18f10..6b75302e8 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,31 @@ 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. + * + * **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; idempotencyKeyStore?: WebhookIdempotencyKeyStore; /** @@ -180,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[]; } @@ -192,6 +228,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 +272,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 +343,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 +363,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 +411,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..6668c2786 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,146 @@ 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(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)'); + }); +}); + +// ──────────────────────────────────────────────────────────── +// 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 +// ──────────────────────────────────────────────────────────── + +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; + } + ); + }); +});