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
32 changes: 32 additions & 0 deletions .changeset/webhook-verify-accept-request-signing-key.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 14 additions & 12 deletions src/lib/server/decisioning/tenant-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).adcp_use;
const privateUse = (key.privateJwk as Record<string, unknown>).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 ? '<unset>' : 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 ? '<unset>' : JSON.stringify(privateUse)}.`
);
}
Expand Down
28 changes: 14 additions & 14 deletions src/lib/server/webhook-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand All @@ -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;
Expand Down
16 changes: 9 additions & 7 deletions src/lib/signing/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
18 changes: 16 additions & 2 deletions src/lib/signing/jwks-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ const WIRE_ALG_TO_JOSE: Record<AdcpSignAlg, string> = {
'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<AdcpUse>([
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/lib/signing/signer-async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
} from './signer';
import {
assertProviderPurpose,
assertWebhookProviderPurpose,
finalizeRequestSignature,
finalizeResponseSignature,
prepareRequestSignature,
Expand Down Expand Up @@ -50,7 +51,7 @@ export async function signWebhookAsync(
provider: SigningProvider,
options: SignWebhookOptions = {}
): Promise<SignedRequest> {
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);
Expand Down
42 changes: 39 additions & 3 deletions src/lib/signing/signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ? '<missing>' : `'${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;
Expand Down Expand Up @@ -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);
Expand Down
45 changes: 27 additions & 18 deletions src/lib/signing/webhook-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}").`
);
}

Expand Down
21 changes: 16 additions & 5 deletions src/lib/testing/storyboard/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 ?? '',
Expand All @@ -5555,22 +5561,27 @@ 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 {
url: prior.url,
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 },
};
}

Expand Down
Loading
Loading