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
11 changes: 11 additions & 0 deletions .changeset/webhook-allow-request-signing-key-reuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"adcontextprotocol": minor
---

Webhooks are signed with the agent's `request-signing` key — there is no separate webhook key purpose. The webhook verifier checklist (step 8) now accepts `adcp_use == "request-signing"` as canonical, with the deprecated `"webhook-signing"` still accepted for backward compatibility (removal tracked in adcontextprotocol/adcp#5555). Operators that want separate key material for webhooks publish a second `"request-signing"` key with a distinct `kid` and sign webhooks with it — key isolation comes from the `kid`, not a distinct `adcp_use`. Any other key-purpose failure — `"response-signing"`/`"governance-signing"`, absent `adcp_use`, or a missing `verify` key_op — is rejected with `webhook_signature_key_purpose_invalid`. `webhook_mode_mismatch` is unchanged and remains reserved for the HMAC-vs-9421 auth-mode selector mismatch.

The relaxation is one-directional and safe: cross-protocol confusion is prevented by the RFC 9421 `tag` (`adcp/webhook-signing/v1`, part of the signed base, checked at step 3) and mandatory `content-digest` coverage — not by the key-purpose discriminator. A captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3, so it can never be replayed as a webhook. The reverse remains forbidden: a webhook-signing key MUST NOT verify a request signature (request verification still requires `adcp_use == "request-signing"` exactly).

Conformance vectors updated: former negative `webhook-signing/negative/008-wrong-adcp-use` (request-signing key rejected) becomes positive `webhook-signing/positive/008-request-signing-key-reuse` (accepted); a new negative `008-wrong-adcp-use` covers a `response-signing` key, still rejected.

Semver note: this is `minor` because it widens verifier acceptance and deprecates the old key purpose without removing any wire-compatible signer or verifier behavior. The future removal of `"webhook-signing"` from the accepted webhook key-purpose set is tracked in adcontextprotocol/adcp#5555 and will be a major-version change.
6 changes: 3 additions & 3 deletions .github/workflows/build-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ jobs:

- name: Fetch 3.0.x for error-code drift comparison
if: github.ref != 'refs/heads/3.0.x' && github.base_ref != '3.0.x'
run: git fetch --depth=1 origin 3.0.x:refs/remotes/origin/3.0.x
run: git fetch --depth=1 origin +3.0.x:refs/remotes/origin/3.0.x

- name: Error-code drift vs 3.0.x
if: github.ref != 'refs/heads/3.0.x' && github.base_ref != '3.0.x'
Expand All @@ -119,8 +119,8 @@ jobs:
- name: Check platform-agnosticism (no vendor tokens in normative field names)
run: npm run test:platform-agnostic

- name: HMAC webhook conformance (verifier + signer)
run: npm run test:hmac-vectors && npm run test:hmac-signer-conformance
- name: Webhook conformance (HMAC + RFC 9421 vectors)
run: npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-signing-vectors

server-integration:
name: Server integration tests
Expand Down
2 changes: 1 addition & 1 deletion docs/accounts/tasks/sync_accounts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ Each entry has:

Before persisting or echoing an entry as `active: true`, the seller MUST validate the URL, apply the SSRF rules in [Webhook URL validation](/docs/building/by-layer/L1/security#webhook-url-validation-ssrf), and prove that the receiver controls the endpoint.

Proof is required when there is no current valid proof for the tuple `(account_id, subscriber_id, normalized url, authentication mode/credential binding, normalized event_types)`. Changing the subscriber ID, normalized URL, authentication mode/credential binding, or `event_types[]` requires fresh proof before the new set can become active. The challenge POST itself MUST be signed with the seller's RFC 9421 webhook-signing key even when the candidate config selects legacy delivery auth. The receiver MUST verify the RFC 9421 signature and MUST reject the challenge unless `seller_agent_url`, `delivery_auth`, and `event_types` match the pending registration. Sellers MAY re-challenge on their own proof-expiration policy.
Proof is required when there is no current valid proof for the tuple `(account_id, subscriber_id, normalized url, authentication mode/credential binding, normalized event_types)`. Changing the subscriber ID, normalized URL, authentication mode/credential binding, or `event_types[]` requires fresh proof before the new set can become active. The challenge POST itself MUST be signed with the seller's RFC 9421 webhook profile key even when the candidate config selects legacy delivery auth. New signers use `adcp_use: "request-signing"`; deprecated `webhook-signing` keys remain accepted during the compatibility window. The receiver MUST verify the RFC 9421 signature and MUST reject the challenge unless `seller_agent_url`, `delivery_auth`, and `event_types` match the pending registration. Sellers MAY re-challenge on their own proof-expiration policy.

The standard challenge is an HTTPS POST to the candidate `url` with a JSON body containing `type`, `challenge`, `account_id`, `subscriber_id`, `seller_agent_url`, `delivery_auth`, and `event_types`. The canonical schemas are [`webhook-challenge.json`](https://adcontextprotocol.org/schemas/v3/core/webhook-challenge.json) and [`webhook-challenge-response.json`](https://adcontextprotocol.org/schemas/v3/core/webhook-challenge-response.json).

Expand Down
10 changes: 5 additions & 5 deletions docs/brand-protocol/tasks/acquire_rights.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ Seconds to minutes for `acquired` or `rejected`. The `pending_approval` status m
| `campaign.end_date` | date | No | Campaign end date |
| `revocation_webhook` | push-notification-config | Yes | Webhook for revocation notifications. If the rights holder needs to revoke rights, they POST a [revocation-notification](https://adcontextprotocol.org/schemas/v3/brand/revocation-notification.json) to this URL. |
| `idempotency_key` | string | No | Client-generated key for safe retries. Resubmitting with the same key returns the original response. |
| `push_notification_config` | push-notification-config | No | Webhook for async status updates if the acquisition requires approval. See [push notifications](/docs/building/implementation/webhooks). |
| `push_notification_config` | push-notification-config | No | Webhook for async status updates if the acquisition requires approval. See [push notifications](/docs/building/by-layer/L3/webhooks). |

### Response statuses

Expand Down Expand Up @@ -220,10 +220,10 @@ Brand agents MAY also reject when `campaign.start_date` is more than the rights

A request is **governance-aware** when the brand agent will project commitment against a governance plan before issuing credentials. That happens by either of two paths:

1. **Inline path** — the request carries an intent-phase `governance_context` token on the [protocol envelope](/docs/building/implementation/security). The buyer threads the token explicitly per request.
1. **Inline path** — the request carries an intent-phase `governance_context` token on the [protocol envelope](/docs/building/by-layer/L1/security). The buyer threads the token explicitly per request.
2. **Bound path** — the request carries `account` (a seller-assigned `account_id`, or `account.brand` + `account.operator` resolving to a bound account), and the brand agent has a governance agent previously bound to that account via [`sync_governance`](/docs/accounts/tasks/sync_governance). The brand agent looks up the bound agent without the buyer threading anything per request.

When **both** paths are present in the same request — an inline `governance_context` token AND an `account` with a bound governance agent — the inline token wins. The token is per-request, JWS-signed against a specific plan, and is the [primary correlation key](/docs/building/implementation/security) for audit and reporting; the bound agent serves as the resolver fallback when no token is threaded. Brand agents MUST consult the agent identified by the inline token when both are present, even if it differs from the bound agent — the buyer's per-request decision overrides the persisted binding.
When **both** paths are present in the same request — an inline `governance_context` token AND an `account` with a bound governance agent — the inline token wins. The token is per-request, JWS-signed against a specific plan, and is the [primary correlation key](/docs/building/by-layer/L1/security) for audit and reporting; the bound agent serves as the resolver fallback when no token is threaded. Brand agents MUST consult the agent identified by the inline token when both are present, even if it differs from the bound agent — the buyer's per-request decision overrides the persisted binding.

Both paths trigger the same projection rule. When the request is governance-aware and the selected pricing option has `model: "cpm"`, `campaign.estimated_impressions` is the input the brand agent uses to project commitment against remaining plan budget. To make that projection deterministic across implementations:

Expand Down Expand Up @@ -277,7 +277,7 @@ If `acquire_rights` returns `pending_approval` and you provided `push_notificati
If the rights holder needs to revoke rights (talent controversy, contract violation, etc.), they POST a [`revocation-notification`](https://adcontextprotocol.org/schemas/v3/brand/revocation-notification.json) to the buyer's `revocation_webhook`, authenticating with the credentials provided at acquisition time. The notification contains an `idempotency_key` (required, used for deduplication across retries), `rights_id`, `brand_id`, `reason`, and `effective_at` timestamp.

The buyer is responsible for:
- Deduplicating by `idempotency_key` — the same revocation may be delivered multiple times; see [Push Notifications — Reliability](/docs/building/implementation/webhooks#reliability) for the canonical dedup contract
- Deduplicating by `idempotency_key` — the same revocation may be delivered multiple times; see [Push Notifications — Reliability](/docs/building/by-layer/L3/webhooks#reliability) for the canonical dedup contract
- Stopping creative delivery by `effective_at`
- Removing or replacing affected creatives from active campaigns
- Ceasing use of generation credentials (providers may also invalidate credentials independently)
Expand All @@ -288,7 +288,7 @@ Partial revocation is supported — if `revoked_uses` is present, only those use

Return HTTP `200` immediately upon receiving and validating a revocation notification. The rights holder retries on non-`2xx` responses using exponential backoff (1s, 5s, 30s, 5m, 30m). After 6 failed attempts, the rights holder may escalate through other channels.

All webhook signing follows the AdCP [push notification signing profile](/docs/building/implementation/webhooks#signature-verification) — RFC 9421 by default (rights agent signs with its `adcp_use: "webhook-signing"` key published at its brand.json `agents[]` entry), with the deprecated HMAC-SHA256 fallback available when the rights holder populates `authentication.credentials` on the webhook registration.
All webhook signing follows the AdCP [push notification signing profile](/docs/building/by-layer/L3/webhooks#signature-verification) — RFC 9421 by default (rights agent signs with its `adcp_use: "request-signing"` key published at its brand.json `agents[]` entry; deprecated `webhook-signing` keys remain accepted during the compatibility window), with the deprecated HMAC-SHA256 fallback available when the rights holder populates `authentication.credentials` on the webhook registration.

## Impression caps and overage

Expand Down
19 changes: 10 additions & 9 deletions docs/building/by-layer/L1/request-signing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ If any covered component changes after signing, verification fails.

### Key separation

Every agent needs **separate keys per purpose**, each with a distinct `kid` and `adcp_use` tag:
Every agent publishes signing keys with a distinct `kid` and `adcp_use` tag:

- `adcp_use: "request-signing"` — for signing outbound tool calls
- `adcp_use: "webhook-signing"` — for signing outbound webhooks
- `adcp_use: "request-signing"` — for signing outbound tool calls and outbound webhooks
- `adcp_use: "webhook-signing"` — deprecated; still accepted on the webhook path for backward compatibility

Reusing a key across purposes is forbidden by the spec.
For blast-radius isolation, publish a second `request-signing` key under a separate `kid` for webhook delivery rather than reusing the same key material for tool calls and webhooks.

### Discovery chain

Expand Down Expand Up @@ -122,7 +122,7 @@ if err != nil { /* handle */ }
// fields (kid, kty, crv, alg, use, key_ops, adcp_use) are set.
```

Use `signing.ProfileWebhookSigning` for webhook keys — never reuse a request-signing key for webhook signing (per `adcp_use` purpose separation).
Use `signing.ProfileWebhookSigning` for the webhook RFC 9421 profile. Publish the corresponding public key with `adcp_use: "request-signing"`; if you want webhook-only key material, use a separate `kid`.
</Tab>
</Tabs>

Expand Down Expand Up @@ -172,7 +172,7 @@ Serve a JSON Web Key Set at a stable HTTPS URL (defaults to `/.well-known/jwks.j
}
```

Only public keys go here — no `d` field. Set `Cache-Control: max-age=3600` or similar. If you serve both request-signing and webhook-signing keys, include both in the same JWKS with different `kid` values and `adcp_use` tags.
Only public keys go here — no `d` field. Set `Cache-Control: max-age=3600` or similar. If you use separate key material for webhook delivery, publish a second `request-signing` JWK with a distinct `kid`. Deprecated `webhook-signing` JWKs remain accepted on the webhook path for backward compatibility.

### brand.json

Expand Down Expand Up @@ -545,8 +545,9 @@ import (
)

// Mount the same Middleware on your webhook receiver, but configure it for
// the webhook profile — adcp_use="webhook-signing", Content-Digest required,
// no required_for gating (webhooks always carry signatures).
// the webhook profile — adcp_use="request-signing" (deprecated
// "webhook-signing" also accepted), Content-Digest required, no required_for
// gating (webhooks always carry signatures).
webhookMW := signing.Middleware(signing.MiddlewareOptions{
Resolver: signing.NewBrandJSONJWKSResolver(),
Replay: signing.NewMemoryReplayStore(0),
Expand Down Expand Up @@ -632,7 +633,7 @@ webhookClient := &http.Client{
</Tab>
</Tabs>

Publish a separate JWK with `"adcp_use": "webhook-signing"` in your JWKS alongside your request-signing key. Never reuse the same key for both purposes — receivers enforce purpose at the JWK `adcp_use` level, not the RFC 9421 tag.
Publish the webhook signing public key as a JWK with `"adcp_use": "request-signing"`. The webhook verifier still accepts deprecated `"webhook-signing"` keys for backward compatibility, but new signers should use `request-signing`. If you want independent webhook rotation or blast-radius isolation, publish a separate `request-signing` JWK with a webhook-specific `kid`.

## Step 7: Declare the capability

Expand Down
Loading
Loading