Skip to content

Commit bedea16

Browse files
committed
fix(webhooks)!: comply with challenge schema
BREAKING CHANGE: Webhook challenge helpers now require seller_agent_url and event_types, and proof challenges require an RFC 9421 WebhookSender; pass legacy delivery authentication alongside that sender.
1 parent bef739e commit bedea16

10 files changed

Lines changed: 370 additions & 197 deletions

‎README.md‎

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -793,16 +793,14 @@ sender = WebhookSender.from_jwk(webhook_signing_jwk_with_private_d)
793793
try:
794794
# Call only for new or changed active durable subscriptions. Persist
795795
# active=False configs without challenging; challenge before activation.
796-
auth_kwargs = (
797-
{"authentication": config.authentication}
798-
if config.authentication
799-
else {"sender": sender}
800-
)
801796
await challenge_webhook_destination(
802797
url=config.url,
803798
account_id=account_id,
804799
subscriber_id=config.subscriber_id,
805-
**auth_kwargs,
800+
seller_agent_url=seller_agent_url,
801+
event_types=config.event_types,
802+
sender=sender,
803+
authentication=config.authentication,
806804
field="accounts[0].notification_configs[0].url",
807805
policy=WebhookDestinationPolicy.production(),
808806
)
@@ -815,11 +813,12 @@ need `http://localhost` or private-network destinations. Production validation
815813
requires HTTPS and rejects loopback, private, link-local, reserved, and cloud
816814
metadata destinations. The challenge helper uses the SDK-managed pinned
817815
transport and fails unless the receiver echoes the challenge in a JSON
818-
`challenge` or `token` field. For omitted `authentication`, pass an RFC 9421
819-
`WebhookSender.from_jwk(...)` without `client=`. Bearer/HMAC durable configs
820-
must pass `authentication=config.authentication`; custom egress clients,
821-
proxies, and mTLS transports are for normal delivery paths, not registration
822-
challenges.
816+
`challenge` or `token` field. Pass an RFC 9421 `WebhookSender.from_jwk(...)`
817+
without `client=` for every challenge. For deprecated Bearer/HMAC durable
818+
configs, also pass `authentication=config.authentication`; the challenge binds
819+
the delivery mode and credential fingerprint into its signed body without
820+
sending the credential. Custom egress clients, proxies, and mTLS transports are
821+
for normal delivery paths, not registration challenges.
823822

824823
Wholesale feed notifications use stable types from `adcp` / `adcp.types`:
825824

@@ -838,11 +837,9 @@ if "product.updated" in subscription.event_types:
838837
)
839838
```
840839

841-
The webhook-signing JWK MUST be published in your `adagents.json` with
842-
`adcp_use: "webhook-signing"` — distinct from your `request-signing` key so
843-
neither signature can be replayed as the other. `WebhookSender.from_jwk`
844-
refuses to construct from a JWK with the wrong `adcp_use` to fail fast at
845-
setup rather than at receiver verification.
840+
Publish the signing JWK in `adagents.json` with `adcp_use: "request-signing"`.
841+
`WebhookSender.from_jwk` also accepts `webhook-signing` keys for compatibility
842+
and rejects JWKs whose purpose is outside the webhook signing profile.
846843

847844
### Debug Mode
848845

‎docs/handler-authoring.md‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1704,16 +1704,14 @@ try:
17041704
if config.active is not False:
17051705
# Challenge new or changed active durable subscriptions before
17061706
# activation. Inactive configs are stored without a network challenge.
1707-
auth_kwargs = (
1708-
{"authentication": config.authentication}
1709-
if config.authentication
1710-
else {"sender": sender}
1711-
)
17121707
await challenge_webhook_destination(
17131708
url=str(config.url),
17141709
account_id=account_id,
17151710
subscriber_id=config.subscriber_id,
1716-
**auth_kwargs,
1711+
seller_agent_url=seller_agent_url,
1712+
event_types=config.event_types,
1713+
sender=sender,
1714+
authentication=config.authentication,
17171715
policy=WebhookDestinationPolicy.production(),
17181716
field="accounts[0].notification_configs[0].url",
17191717
)

‎docs/production-seller.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,12 +156,12 @@ cd examples/v3_reference_seller
156156
docker compose up -d postgres
157157
```
158158

159-
Generate distinct webhook-signing and outbox-encryption keys. Keep both in a
159+
Generate request-signing and outbox-encryption keys. Keep both in a
160160
secret manager in production; the environment variables below are for the
161161
local runnable path.
162162

163163
```bash
164-
adcp-keygen --alg ed25519 --purpose webhook-signing \
164+
adcp-keygen --alg ed25519 --purpose request-signing \
165165
--kid reference-webhook-key \
166166
--out /tmp/adcp-reference-webhook-signing.pem
167167

‎src/adcp/signing/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@
3232
from adcp.signing import generate_signing_keypair
3333
3434
# CLI equivalence:
35-
# adcp-keygen --alg ed25519 --purpose webhook-signing
35+
# adcp-keygen --alg ed25519 --purpose request-signing
3636
pem, public_jwk = generate_signing_keypair(
37-
alg="ed25519", purpose="webhook-signing"
37+
alg="ed25519", purpose="request-signing"
3838
)
3939
4040
# Mode 0600, O_EXCL so an existing file is never overwritten.

‎src/adcp/signing/webhook_signer.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* ``cover_content_digest`` — always ``True`` (body IS the event)
88
* ``Signature`` — unpadded Base64URL throughout 3.x, independently of the
99
AdCP 3.2 request-signing migration to padded standard Base64.
10-
* the signing JWK MUST have ``adcp_use: "webhook-signing"`` in the sender's
10+
* the signing JWK has an AdCP webhook-compatible ``adcp_use`` in the sender's
1111
published ``adagents.json``; verifying this at publish time is out of scope
1212
for the signer, but callers should enforce it when registering their keyring.
1313
"""

‎src/adcp/webhook_sender.py‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from pydantic import BaseModel, TypeAdapter
4242

4343
from adcp._deferred_adapters import deferred_adapter
44+
from adcp.signing.constants import WEBHOOK_ACCEPTED_ADCP_USES
4445
from adcp.signing.crypto import (
4546
ALG_ED25519,
4647
ALG_ES256,
@@ -64,6 +65,7 @@
6465
WholesaleFeedEvent,
6566
WholesaleFeedWebhook,
6667
)
68+
from adcp.types.base import AdCPBaseModel
6769
from adcp.types.domains.core.async_response_data import AdcpAsyncResponseData
6870
from adcp.webhook_auth import (
6971
AdcpLegacyHmacStrategy,
@@ -389,26 +391,21 @@ def from_jwk(
389391
) -> WebhookSender:
390392
"""Construct from a JWK that includes the private scalar.
391393
392-
The JWK MUST have ``adcp_use == "webhook-signing"`` — the sender
393-
doesn't validate this (you're signing with your own key; validation
394-
happens at the receiver), but a key whose adcp_use is wrong will be
395-
rejected by every conformant verifier.
394+
The JWK must identify a purpose accepted by the AdCP webhook profile:
395+
``request-signing`` or ``webhook-signing``.
396396
397397
``allow_private_destinations`` and ``allowed_destination_ports``
398398
forward to :meth:`__init__` — see that signature for semantics.
399399
"""
400400
# Snapshot the mapping once — a live Mapping could otherwise return
401401
# different values across the adcp_use / kid / d / alg reads.
402402
jwk_snapshot = dict(jwk)
403-
if jwk_snapshot.get("adcp_use") != "webhook-signing":
403+
adcp_use = jwk_snapshot.get("adcp_use")
404+
if adcp_use not in WEBHOOK_ACCEPTED_ADCP_USES:
405+
accepted_uses = ", ".join(sorted(WEBHOOK_ACCEPTED_ADCP_USES))
404406
raise ValueError(
405-
f"WebhookSender requires a JWK with adcp_use='webhook-signing' "
406-
f"(got {jwk_snapshot.get('adcp_use')!r}). Webhook-signing and "
407-
f"request-signing keys MUST be distinct so a signature from one "
408-
f"surface cannot be replayed as the other. Generate a separate "
409-
f"key with adcp_use='webhook-signing' and publish it in your "
410-
f"adagents.json alongside your request-signing key. See "
411-
f"https://adcontextprotocol.org/docs/building/implementation/security"
407+
"WebhookSender requires a JWK with an AdCP webhook signing purpose "
408+
f"({accepted_uses}); got {adcp_use!r}"
412409
)
413410
alg = jwk_snapshot.get("alg")
414411
if alg == "EdDSA":
@@ -445,7 +442,7 @@ def from_pem(
445442
) -> WebhookSender:
446443
"""Load a private key from a PEM file and bind it as a webhook sender.
447444
448-
Companion to ``adcp-keygen --purpose webhook-signing``, which writes
445+
Companion to ``adcp-keygen --purpose request-signing``, which writes
449446
the PEM and prints the public JWK. The JWK is published at your
450447
``jwks_uri``; the PEM holds the private key material. ``from_pem``
451448
reads the PEM, constructs the right ``PrivateKey`` type for ``alg``,
@@ -1643,13 +1640,17 @@ async def send_webhook_challenge(
16431640
url: str,
16441641
account_id: str,
16451642
subscriber_id: str,
1643+
seller_agent_url: str,
1644+
event_types: Sequence[str],
1645+
authentication: AdCPBaseModel | Mapping[str, Any] | None = None,
16461646
challenge: str | None = None,
16471647
extra_headers: Mapping[str, str] | None = None,
16481648
) -> WebhookDeliveryResult:
16491649
"""POST a signed durable-subscription proof-of-control challenge.
16501650
16511651
The body matches the durable ``notification_configs[]`` challenge
1652-
shape and intentionally does not inject ``idempotency_key``:
1652+
shape, binds the pending delivery authentication and event set, and
1653+
intentionally does not inject ``idempotency_key``:
16531654
16541655
``{"type":"webhook.challenge","challenge":"...", ...}``
16551656
@@ -1658,9 +1659,15 @@ async def send_webhook_challenge(
16581659
want URL validation and response echo checking in one call.
16591660
"""
16601661

1662+
if not self.signs_with_rfc9421:
1663+
raise ValueError("webhook challenges require RFC 9421 signing")
1664+
16611665
payload = create_webhook_challenge_payload(
16621666
account_id=account_id,
16631667
subscriber_id=subscriber_id,
1668+
seller_agent_url=seller_agent_url,
1669+
event_types=event_types,
1670+
authentication=authentication,
16641671
challenge=challenge,
16651672
)
16661673
challenge_value = str(payload["challenge"])

0 commit comments

Comments
 (0)