Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/webhook-emitter-signer-provider.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 14 additions & 6 deletions src/lib/server/create-adcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1096,15 +1096,23 @@ export interface AdcpServerConfig<TAccount = unknown> {
* 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'];
Expand Down
1 change: 1 addition & 0 deletions src/lib/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ export type {
WebhookRetryOptions,
WebhookAuthentication,
} from './webhook-emitter';
export type { SigningProvider } from '../signing/provider';

export { checkGovernance, governanceDeniedError } from './governance';
export type {
Expand Down
64 changes: 56 additions & 8 deletions src/lib/server/webhook-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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[];
}

Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<DeliveryResponse> {
const headers = buildHeaders(args);
const headers = await buildHeaders(args);
const response = await args.fetch(args.url, {
method: 'POST',
headers,
Expand All @@ -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<string, string> {
}): Promise<Record<string, string>> {
const baseHeaders: Record<string, string> = {
'content-type': 'application/json',
};
Expand Down Expand Up @@ -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 };
}

Expand Down
144 changes: 144 additions & 0 deletions test/lib/webhook-emitter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
);
});
});
Loading