Skip to content
Open
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
29 changes: 13 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -793,16 +793,14 @@ sender = WebhookSender.from_jwk(webhook_signing_jwk_with_private_d)
try:
# Call only for new or changed active durable subscriptions. Persist
# active=False configs without challenging; challenge before activation.
auth_kwargs = (
{"authentication": config.authentication}
if config.authentication
else {"sender": sender}
)
await challenge_webhook_destination(
url=config.url,
account_id=account_id,
subscriber_id=config.subscriber_id,
**auth_kwargs,
seller_agent_url=seller_agent_url,
event_types=config.event_types,
sender=sender,
authentication=config.authentication,
field="accounts[0].notification_configs[0].url",
policy=WebhookDestinationPolicy.production(),
)
Expand All @@ -815,11 +813,12 @@ need `http://localhost` or private-network destinations. Production validation
requires HTTPS and rejects loopback, private, link-local, reserved, and cloud
metadata destinations. The challenge helper uses the SDK-managed pinned
transport and fails unless the receiver echoes the challenge in a JSON
`challenge` or `token` field. For omitted `authentication`, pass an RFC 9421
`WebhookSender.from_jwk(...)` without `client=`. Bearer/HMAC durable configs
must pass `authentication=config.authentication`; custom egress clients,
proxies, and mTLS transports are for normal delivery paths, not registration
challenges.
`challenge` or `token` field. Pass an RFC 9421 `WebhookSender.from_jwk(...)`
without `client=` for every challenge. For deprecated Bearer/HMAC durable
configs, also pass `authentication=config.authentication`; the challenge binds
the delivery mode and credential fingerprint into its signed body without
sending the credential. Custom egress clients, proxies, and mTLS transports are
for normal delivery paths, not registration challenges.

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

Expand All @@ -838,11 +837,9 @@ if "product.updated" in subscription.event_types:
)
```

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

### Debug Mode

Expand Down
10 changes: 4 additions & 6 deletions docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -1704,16 +1704,14 @@ try:
if config.active is not False:
# Challenge new or changed active durable subscriptions before
# activation. Inactive configs are stored without a network challenge.
auth_kwargs = (
{"authentication": config.authentication}
if config.authentication
else {"sender": sender}
)
await challenge_webhook_destination(
url=str(config.url),
account_id=account_id,
subscriber_id=config.subscriber_id,
**auth_kwargs,
seller_agent_url=seller_agent_url,
event_types=config.event_types,
sender=sender,
authentication=config.authentication,
policy=WebhookDestinationPolicy.production(),
field="accounts[0].notification_configs[0].url",
)
Expand Down
4 changes: 2 additions & 2 deletions docs/production-seller.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,12 @@ cd examples/v3_reference_seller
docker compose up -d postgres
```

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

```bash
adcp-keygen --alg ed25519 --purpose webhook-signing \
adcp-keygen --alg ed25519 --purpose request-signing \
--kid reference-webhook-key \
--out /tmp/adcp-reference-webhook-signing.pem

Expand Down
4 changes: 2 additions & 2 deletions src/adcp/signing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@
from adcp.signing import generate_signing_keypair

# CLI equivalence:
# adcp-keygen --alg ed25519 --purpose webhook-signing
# adcp-keygen --alg ed25519 --purpose request-signing
pem, public_jwk = generate_signing_keypair(
alg="ed25519", purpose="webhook-signing"
alg="ed25519", purpose="request-signing"
)

# Mode 0600, O_EXCL so an existing file is never overwritten.
Expand Down
2 changes: 1 addition & 1 deletion src/adcp/signing/webhook_signer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* ``cover_content_digest`` — always ``True`` (body IS the event)
* ``Signature`` — unpadded Base64URL throughout 3.x, independently of the
AdCP 3.2 request-signing migration to padded standard Base64.
* the signing JWK MUST have ``adcp_use: "webhook-signing"`` in the sender's
* the signing JWK has an AdCP webhook-compatible ``adcp_use`` in the sender's
published ``adagents.json``; verifying this at publish time is out of scope
for the signer, but callers should enforce it when registering their keyring.
"""
Expand Down
35 changes: 21 additions & 14 deletions src/adcp/webhook_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from pydantic import BaseModel, TypeAdapter

from adcp._deferred_adapters import deferred_adapter
from adcp.signing.constants import WEBHOOK_ACCEPTED_ADCP_USES
from adcp.signing.crypto import (
ALG_ED25519,
ALG_ES256,
Expand All @@ -64,6 +65,7 @@
WholesaleFeedEvent,
WholesaleFeedWebhook,
)
from adcp.types.base import AdCPBaseModel
from adcp.types.domains.core.async_response_data import AdcpAsyncResponseData
from adcp.webhook_auth import (
AdcpLegacyHmacStrategy,
Expand Down Expand Up @@ -389,26 +391,21 @@ def from_jwk(
) -> WebhookSender:
"""Construct from a JWK that includes the private scalar.

The JWK MUST have ``adcp_use == "webhook-signing"`` — the sender
doesn't validate this (you're signing with your own key; validation
happens at the receiver), but a key whose adcp_use is wrong will be
rejected by every conformant verifier.
The JWK must identify a purpose accepted by the AdCP webhook profile:
``request-signing`` or ``webhook-signing``.

``allow_private_destinations`` and ``allowed_destination_ports``
forward to :meth:`__init__` — see that signature for semantics.
"""
# Snapshot the mapping once — a live Mapping could otherwise return
# different values across the adcp_use / kid / d / alg reads.
jwk_snapshot = dict(jwk)
if jwk_snapshot.get("adcp_use") != "webhook-signing":
adcp_use = jwk_snapshot.get("adcp_use")
if adcp_use not in WEBHOOK_ACCEPTED_ADCP_USES:
accepted_uses = ", ".join(sorted(WEBHOOK_ACCEPTED_ADCP_USES))
raise ValueError(
f"WebhookSender requires a JWK with adcp_use='webhook-signing' "
f"(got {jwk_snapshot.get('adcp_use')!r}). Webhook-signing and "
f"request-signing keys MUST be distinct so a signature from one "
f"surface cannot be replayed as the other. Generate a separate "
f"key with adcp_use='webhook-signing' and publish it in your "
f"adagents.json alongside your request-signing key. See "
f"https://adcontextprotocol.org/docs/building/implementation/security"
"WebhookSender requires a JWK with an AdCP webhook signing purpose "
f"({accepted_uses}); got {adcp_use!r}"
)
alg = jwk_snapshot.get("alg")
if alg == "EdDSA":
Expand Down Expand Up @@ -445,7 +442,7 @@ def from_pem(
) -> WebhookSender:
"""Load a private key from a PEM file and bind it as a webhook sender.

Companion to ``adcp-keygen --purpose webhook-signing``, which writes
Companion to ``adcp-keygen --purpose request-signing``, which writes
the PEM and prints the public JWK. The JWK is published at your
``jwks_uri``; the PEM holds the private key material. ``from_pem``
reads the PEM, constructs the right ``PrivateKey`` type for ``alg``,
Expand Down Expand Up @@ -1643,13 +1640,17 @@ async def send_webhook_challenge(
url: str,
account_id: str,
subscriber_id: str,
seller_agent_url: str,
event_types: Sequence[str],
authentication: AdCPBaseModel | Mapping[str, Any] | None = None,
challenge: str | None = None,
extra_headers: Mapping[str, str] | None = None,
) -> WebhookDeliveryResult:
"""POST a signed durable-subscription proof-of-control challenge.

The body matches the durable ``notification_configs[]`` challenge
shape and intentionally does not inject ``idempotency_key``:
shape, binds the pending delivery authentication and event set, and
intentionally does not inject ``idempotency_key``:

``{"type":"webhook.challenge","challenge":"...", ...}``

Expand All @@ -1658,9 +1659,15 @@ async def send_webhook_challenge(
want URL validation and response echo checking in one call.
"""

if not self.signs_with_rfc9421:
raise ValueError("webhook challenges require RFC 9421 signing")

payload = create_webhook_challenge_payload(
account_id=account_id,
subscriber_id=subscriber_id,
seller_agent_url=seller_agent_url,
event_types=event_types,
authentication=authentication,
challenge=challenge,
)
challenge_value = str(payload["challenge"])
Expand Down
Loading
Loading