From bedea168d9c467cab5ca8fff13ea013d4df573cf Mon Sep 17 00:00:00 2001 From: rachitm022 Date: Fri, 13 Nov 2026 08:15:37 +0530 Subject: [PATCH] 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. --- README.md | 29 +- docs/handler-authoring.md | 10 +- docs/production-seller.md | 4 +- src/adcp/signing/__init__.py | 4 +- src/adcp/signing/webhook_signer.py | 2 +- src/adcp/webhook_sender.py | 35 ++- src/adcp/webhooks.py | 191 +++++------- .../signing/test_webhook_sender_e2e.py | 7 +- tests/test_webhook_challenge.py | 279 ++++++++++++++++-- tests/test_webhook_signing_capabilities.py | 6 +- 10 files changed, 370 insertions(+), 197 deletions(-) diff --git a/README.md b/README.md index 62d57b212d..ff47c30b57 100644 --- a/README.md +++ b/README.md @@ -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(), ) @@ -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`: @@ -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 diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index 2ebeedc897..a6762ab1b0 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -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", ) diff --git a/docs/production-seller.md b/docs/production-seller.md index 36fc0ccc22..869d0a5328 100644 --- a/docs/production-seller.md +++ b/docs/production-seller.md @@ -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 diff --git a/src/adcp/signing/__init__.py b/src/adcp/signing/__init__.py index 2ae704f5b7..879e664142 100644 --- a/src/adcp/signing/__init__.py +++ b/src/adcp/signing/__init__.py @@ -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. diff --git a/src/adcp/signing/webhook_signer.py b/src/adcp/signing/webhook_signer.py index 6494536f40..b47d9ee465 100644 --- a/src/adcp/signing/webhook_signer.py +++ b/src/adcp/signing/webhook_signer.py @@ -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. """ diff --git a/src/adcp/webhook_sender.py b/src/adcp/webhook_sender.py index 7bca83b7d5..8820c6c2e1 100644 --- a/src/adcp/webhook_sender.py +++ b/src/adcp/webhook_sender.py @@ -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, @@ -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, @@ -389,10 +391,8 @@ 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. @@ -400,15 +400,12 @@ def from_jwk( # 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": @@ -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``, @@ -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":"...", ...}`` @@ -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"]) diff --git a/src/adcp/webhooks.py b/src/adcp/webhooks.py index 7d61c2237b..51643ea1fa 100644 --- a/src/adcp/webhooks.py +++ b/src/adcp/webhooks.py @@ -29,7 +29,7 @@ import time import uuid import warnings -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, NoReturn, cast @@ -1100,8 +1100,11 @@ def create_webhook_challenge_payload( *, 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, -) -> dict[str, str]: +) -> dict[str, Any]: """Build the durable ``notification_configs[]`` challenge payload.""" if not isinstance(account_id, str) or not account_id: @@ -1111,12 +1114,43 @@ def create_webhook_challenge_payload( challenge_value = generate_webhook_challenge_value() if challenge is None else challenge if not isinstance(challenge_value, str) or not challenge_value: raise ValueError("challenge must be a non-empty string") - return { + delivery_auth: dict[str, str] = {"mode": "rfc9421"} + if authentication is not None: + auth_config = _authentication_to_config(authentication) + schemes = auth_config.get("schemes") + if not isinstance(schemes, (list, tuple)) or len(schemes) != 1: + raise ValueError("authentication.schemes must contain exactly one scheme") + mode = str(getattr(schemes[0], "value", schemes[0])) + if mode not in ("Bearer", "HMAC-SHA256"): + raise ValueError(f"unsupported legacy authentication scheme {mode!r}") + credentials = auth_config.get("credentials") + if not isinstance(credentials, str) or len(credentials) < 32: + raise ValueError( + f"authentication.schemes={[mode]!r} requires credentials of at least 32 characters" + ) + _warn_auth_deprecation_once() + delivery_auth = { + "mode": mode, + "credential_fingerprint": hashlib.sha256(credentials.encode("utf-8")).hexdigest(), + } + payload: dict[str, Any] = { "type": "webhook.challenge", "challenge": challenge_value, "account_id": account_id, "subscriber_id": subscriber_id, + "seller_agent_url": seller_agent_url, + "delivery_auth": delivery_auth, + "event_types": list(event_types), } + from adcp.validation.schema_loader import get_named_validator + + validator = get_named_validator("core/webhook-challenge.json") + if validator is None: + raise RuntimeError("bundled webhook challenge schema is unavailable") + error = next(validator.iter_errors(payload), None) + if error is not None: + raise ValueError(f"webhook challenge payload failed schema validation ({error.validator})") + return payload def validate_webhook_challenge_response( @@ -1343,80 +1377,11 @@ def _authentication_to_config(authentication: AdCPBaseModel | Mapping[str, Any]) return dict(authentication) -async def _send_legacy_webhook_challenge( - *, - url: str, - authentication: Mapping[str, Any], - payload: dict[str, str], - timeout_seconds: float | None, - policy: WebhookDestinationPolicy, - extra_headers: Mapping[str, str] | None, -) -> httpx.Response: - schemes_raw = authentication.get("schemes") - if schemes_raw is not None and not isinstance(schemes_raw, (list, tuple)): - raise ValueError(f"authentication.schemes must be a list, got {type(schemes_raw).__name__}") - schemes = list(schemes_raw or []) - if len(schemes) != 1: - raise ValueError("authentication.schemes must contain exactly one scheme") - auth_scheme = str(getattr(schemes[0], "value", schemes[0])) - credentials = authentication.get("credentials") - - if auth_scheme not in ("Bearer", "HMAC-SHA256"): - raise ValueError( - f"unknown authentication scheme {auth_scheme!r}; " - "supported legacy schemes are 'Bearer' and 'HMAC-SHA256'." - ) - if not isinstance(credentials, str) or not credentials: - raise ValueError(f"authentication.schemes={[auth_scheme]!r} requires credentials") - _validate_header_value("authentication.credentials", credentials) - _warn_auth_deprecation_once() - - body_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") - if len(body_bytes) > _MAX_BODY_BYTES: - raise ValueError( - f"serialized webhook challenge body is {len(body_bytes):,} bytes, " - f"over the {_MAX_BODY_BYTES:,}-byte cap." - ) - - headers: dict[str, str] = {"Content-Type": "application/json"} - if auth_scheme == "Bearer": - headers["Authorization"] = f"Bearer {credentials}" - else: - get_adcp_signed_headers_for_webhook( - headers, - secret=credentials, - timestamp=str(int(time.time())), - payload=payload, - ) - - if extra_headers: - if len(extra_headers) > _MAX_EXTRA_HEADERS: - raise ValueError( - f"extra_headers has {len(extra_headers)} entries; " - f"helper caps at {_MAX_EXTRA_HEADERS}." - ) - for key in extra_headers: - normalized = str(key).lower() - if normalized in _RESERVED_HEADERS or normalized.startswith(":"): - raise ValueError(_reserved_header_message(normalized, key)) - for key, value in extra_headers.items(): - _validate_header_value(f"extra_headers[{key!r}]", value) - headers[key] = value - - return await _post_managed_webhook_challenge( - url=url, - body=body_bytes, - headers=headers, - timeout_seconds=timeout_seconds, - policy=policy, - ) - - async def _send_sender_webhook_challenge( *, url: str, sender: WebhookSender, - payload: dict[str, str], + payload: dict[str, Any], timeout_seconds: float | None, policy: WebhookDestinationPolicy, extra_headers: Mapping[str, str] | None, @@ -1476,6 +1441,8 @@ async def challenge_webhook_destination( url: str | AnyUrl, account_id: str, subscriber_id: str, + seller_agent_url: str, + event_types: Sequence[str], sender: WebhookSender | None = None, authentication: AdCPBaseModel | Mapping[str, Any] | None = None, challenge: str | None = None, @@ -1490,20 +1457,12 @@ async def challenge_webhook_destination( ``sync_accounts.accounts[].notification_configs[]`` entry. Inactive configs can be persisted without calling this helper. - ``authentication`` follows the durable config's legacy auth selector: - when present, the challenge is sent with Bearer or HMAC-SHA256. When - omitted, pass an RFC 9421 :class:`WebhookSender`; the helper uses that - sender's webhook-signing key and the SDK-managed pinned transport. + ``authentication`` describes the durable config's deprecated Bearer or + HMAC-SHA256 delivery mode. The challenge itself is always signed by the + RFC 9421 ``sender`` and uses the SDK-managed pinned transport. """ error_url = str(url) if isinstance(url, (str, AnyUrl)) else None - if sender is not None and authentication is not None: - raise WebhookChallengeError( - "pass either sender= for RFC 9421 or authentication= for legacy auth, not both", - reason="ambiguous_auth_mode", - field=field, - url=error_url, - ) sender_owns_client = bool(getattr(cast(Any, sender), "_owns_client", False)) sender_transport_hooks = tuple(getattr(cast(Any, sender), "_transport_hooks", ())) if sender is not None and not sender_owns_client: @@ -1522,30 +1481,28 @@ async def challenge_webhook_destination( ) if sender is not None and not sender.signs_with_rfc9421: raise WebhookChallengeError( - "proof-of-control requires an RFC 9421 WebhookSender when authentication is omitted", + "proof-of-control requires an RFC 9421 WebhookSender", reason="sender_auth_mode_mismatch", field=field, url=error_url, - suggestion=( - "Use WebhookSender.from_jwk(...) for default durable configs, " - "or pass config.authentication for legacy Bearer/HMAC configs." - ), + suggestion="Use WebhookSender.from_jwk(...) to sign the challenge.", ) - if sender is None and authentication is None: + if sender is None: raise WebhookChallengeError( - "webhook challenge requires sender= when authentication is omitted", + "webhook challenge requires an RFC 9421 sender", reason="sender_required", field=field, url=error_url, - suggestion=( - "Pass the seller's WebhookSender, or pass config.authentication for legacy auth." - ), + suggestion="Pass the seller's RFC 9421 WebhookSender.", ) try: destination = validate_webhook_destination_url(url, policy=policy, field=field) 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, ) except WebhookDestinationValidationError as exc: @@ -1566,36 +1523,22 @@ async def challenge_webhook_destination( challenge_value = payload["challenge"] try: - if sender is not None: - effective_timeout = ( - timeout_seconds - if timeout_seconds is not None - else float(getattr(cast(Any, sender), "_timeout", _DEFAULT_TIMEOUT_SECONDS)) - ) - response = await _send_sender_webhook_challenge( - url=destination.effective_url, - sender=sender, - payload=payload, - timeout_seconds=effective_timeout, - policy=destination.policy, - extra_headers=extra_headers, - ) - status_code = response.status_code - response_headers = dict(response.headers) - response_body = response.content - else: - auth_config = _authentication_to_config(cast(Any, authentication)) - response = await _send_legacy_webhook_challenge( - url=destination.effective_url, - authentication=auth_config, - payload=payload, - extra_headers=extra_headers, - timeout_seconds=timeout_seconds, - policy=destination.policy, - ) - status_code = response.status_code - response_headers = dict(response.headers) - response_body = response.content + effective_timeout = ( + timeout_seconds + if timeout_seconds is not None + else float(getattr(cast(Any, sender), "_timeout", _DEFAULT_TIMEOUT_SECONDS)) + ) + response = await _send_sender_webhook_challenge( + url=destination.effective_url, + sender=sender, + payload=payload, + timeout_seconds=effective_timeout, + policy=destination.policy, + extra_headers=extra_headers, + ) + status_code = response.status_code + response_headers = dict(response.headers) + response_body = response.content except httpx.TimeoutException as exc: raise WebhookChallengeError( "webhook challenge timed out", diff --git a/tests/conformance/signing/test_webhook_sender_e2e.py b/tests/conformance/signing/test_webhook_sender_e2e.py index a2687c8d21..9221a61bb4 100644 --- a/tests/conformance/signing/test_webhook_sender_e2e.py +++ b/tests/conformance/signing/test_webhook_sender_e2e.py @@ -232,14 +232,13 @@ async def test_send_raw_enforces_body_size_cap() -> None: @pytest.mark.asyncio async def test_from_jwk_rejects_wrong_adcp_use() -> None: - """Guardrail at construction: a request-signing JWK silently produces - signatures no webhook receiver will accept. Fail fast.""" + """Guardrail at construction rejects JWKs outside the webhook profile.""" bad_jwk: dict[str, Any] = { **copy.deepcopy(REQUEST_ED25519), - "adcp_use": "request-signing", # wrong purpose for a webhook sender + "adcp_use": "governance-signing", "d": REQUEST_ED25519["_private_d_for_test_only"], } - with pytest.raises(ValueError, match="webhook-signing"): + with pytest.raises(ValueError, match="request-signing"): WebhookSender.from_jwk(bad_jwk) diff --git a/tests/test_webhook_challenge.py b/tests/test_webhook_challenge.py index 92e6c7c394..a771400a7a 100644 --- a/tests/test_webhook_challenge.py +++ b/tests/test_webhook_challenge.py @@ -1,8 +1,6 @@ from __future__ import annotations import copy -import hashlib -import hmac import json import socket from pathlib import Path @@ -13,6 +11,7 @@ import pytest from adcp import NotificationConfig +from adcp.validation.schema_loader import get_named_validator from adcp.webhooks import ( WebhookChallengeError, WebhookDestinationPolicy, @@ -32,6 +31,9 @@ "kid": "test-webhook-ed25519-2026", "adcp_use": "webhook-signing", } +_SELLER_AGENT_URL = "https://seller.example/adcp" +_EVENT_TYPES = ["creative.status_changed"] +_VALID_CHALLENGE = "challenge_123_00000000000000000000" def _rfc9421_sender(*, timeout_seconds: float = 10.0) -> WebhookSender: @@ -62,26 +64,203 @@ def test_create_webhook_challenge_payload_shape() -> None: payload = create_webhook_challenge_payload( account_id="acct_1", subscriber_id="buyer-primary", - challenge="opaque-random-value", + seller_agent_url="https://seller.example/adcp", + event_types=["creative.status_changed", "creative.purged"], + challenge="opaque-random-value-0000000000000", ) assert payload == { "type": "webhook.challenge", - "challenge": "opaque-random-value", + "challenge": "opaque-random-value-0000000000000", "account_id": "acct_1", "subscriber_id": "buyer-primary", + "seller_agent_url": "https://seller.example/adcp", + "delivery_auth": {"mode": "rfc9421"}, + "event_types": ["creative.status_changed", "creative.purged"], } +@pytest.mark.parametrize( + ("scheme", "credentials", "fingerprint"), + [ + ( + "Bearer", + "b" * 40, + "e26d2da3ab585c9840b157a4a9b5639fda905c2ad33d04d2c598be8883b51236", + ), + ( + "HMAC-SHA256", + "s" * 40, + "3923fb8348df77529b04f6f536b8204856d0501d031063de795c57ad693b288f", + ), + ], +) +def test_create_webhook_challenge_payload_binds_legacy_delivery_auth( + scheme: str, + credentials: str, + fingerprint: str, +) -> None: + payload = create_webhook_challenge_payload( + account_id="acct_1", + subscriber_id="buyer-primary", + seller_agent_url="https://seller.example/adcp", + event_types=["creative.status_changed"], + authentication={"schemes": [scheme], "credentials": credentials}, + challenge="opaque-random-value-0000000000000", + ) + + assert payload["delivery_auth"] == { + "mode": scheme, + "credential_fingerprint": fingerprint, + } + assert credentials not in json.dumps(payload) + + +@pytest.mark.parametrize( + "authentication", + [ + {"schemes": [], "credentials": "b" * 40}, + {"schemes": ["Basic"], "credentials": "b" * 40}, + {"schemes": ["Bearer"]}, + {"schemes": ["HMAC-SHA256"], "credentials": "too-short"}, + ], +) +def test_create_webhook_challenge_payload_rejects_invalid_legacy_delivery_auth( + authentication: dict[str, Any], +) -> None: + with pytest.raises(ValueError, match="authentication"): + create_webhook_challenge_payload( + account_id="acct_1", + subscriber_id="buyer-primary", + seller_agent_url="https://seller.example/adcp", + event_types=["creative.status_changed"], + authentication=authentication, + challenge="opaque-random-value-0000000000000", + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("subscriber_id", "buyer primary"), + ("seller_agent_url", "not a uri"), + ("event_types", []), + ("event_types", ["not.a.notification"]), + ("event_types", ["creative.purged", "creative.purged"]), + ("challenge", "too-short"), + ], +) +def test_create_webhook_challenge_payload_rejects_schema_invalid_required_fields( + field: str, + value: object, +) -> None: + arguments: dict[str, Any] = { + "account_id": "acct_1", + "subscriber_id": "buyer-primary", + "seller_agent_url": "https://seller.example/adcp", + "event_types": ["creative.status_changed"], + "challenge": "opaque-random-value-0000000000000", + } + arguments[field] = value + + with pytest.raises(ValueError, match="webhook challenge payload"): + create_webhook_challenge_payload(**arguments) + + +def test_create_webhook_challenge_payload_validates_against_cached_3_2_schema() -> None: + payload = create_webhook_challenge_payload( + account_id="acct_1", + subscriber_id="buyer-primary", + seller_agent_url="https://seller.example/adcp", + event_types=["creative.status_changed", "creative.purged"], + challenge="opaque-random-value-0000000000000", + ) + validator = get_named_validator("core/webhook-challenge.json", version="3.2") + + assert validator is not None + assert list(validator.iter_errors(payload)) == [] + + def test_create_webhook_challenge_payload_rejects_empty_challenge() -> None: with pytest.raises(ValueError, match="challenge"): create_webhook_challenge_payload( account_id="acct_1", subscriber_id="buyer-primary", + seller_agent_url="https://seller.example/adcp", + event_types=["creative.status_changed"], challenge="", ) +@pytest.mark.asyncio +async def test_webhook_sender_sends_complete_challenge_payload() -> None: + captured: list[httpx.Request] = [] + async with httpx.AsyncClient(transport=_challenge_transport(captured)) as client: + sender = WebhookSender.from_jwk( + {**_WEBHOOK_JWK, "d": _WEBHOOK_JWK["_private_d_for_test_only"]}, + client=client, + ) + result = await sender.send_webhook_challenge( + url="https://buyer.example/webhooks/catalog", + account_id="acct_1", + subscriber_id="buyer-primary", + seller_agent_url="https://seller.example/adcp", + event_types=["creative.status_changed"], + challenge="challenge_123_00000000000000000000", + ) + + assert result.ok + assert json.loads(captured[0].content) == { + "type": "webhook.challenge", + "challenge": "challenge_123_00000000000000000000", + "account_id": "acct_1", + "subscriber_id": "buyer-primary", + "seller_agent_url": "https://seller.example/adcp", + "delivery_auth": {"mode": "rfc9421"}, + "event_types": ["creative.status_changed"], + } + + +@pytest.mark.asyncio +async def test_webhook_sender_accepts_current_request_signing_jwk() -> None: + captured: list[httpx.Request] = [] + async with httpx.AsyncClient(transport=_challenge_transport(captured)) as client: + sender = WebhookSender.from_jwk( + {**_REQUEST_ED25519, "d": _REQUEST_ED25519["_private_d_for_test_only"]}, + client=client, + ) + result = await sender.send_webhook_challenge( + url="https://buyer.example/webhooks/catalog", + account_id="acct_1", + subscriber_id="buyer-primary", + seller_agent_url=_SELLER_AGENT_URL, + event_types=_EVENT_TYPES, + challenge=_VALID_CHALLENGE, + ) + + assert result.ok + assert captured[0].headers["Signature-Input"] + assert captured[0].headers["Signature"] + + +@pytest.mark.asyncio +async def test_webhook_sender_rejects_unsigned_challenge_sender() -> None: + captured: list[httpx.Request] = [] + async with httpx.AsyncClient(transport=_challenge_transport(captured)) as client: + sender = WebhookSender.from_bearer_token("sender-token", client=client) + with pytest.raises(ValueError, match="RFC 9421"): + await sender.send_webhook_challenge( + url="https://buyer.example/webhooks/catalog", + account_id="acct_1", + subscriber_id="buyer-primary", + seller_agent_url=_SELLER_AGENT_URL, + event_types=_EVENT_TYPES, + challenge=_VALID_CHALLENGE, + ) + + assert captured == [] + + @pytest.mark.parametrize("body", [b'{"challenge":"abc"}', {"token": "abc"}]) def test_validate_webhook_challenge_response_accepts_challenge_or_token( body: bytes | dict[str, str], @@ -112,14 +291,16 @@ async def test_challenge_webhook_destination_with_sender( url="https://buyer.example/webhooks/catalog", account_id="acct_1", subscriber_id="buyer-primary", + seller_agent_url="https://seller.example/adcp", + event_types=["creative.status_changed", "creative.purged"], sender=sender, - challenge="challenge_123", + challenge="challenge_123_00000000000000000000", field="accounts[0].notification_configs[0].url", ) assert result.ok assert result.echoed_field == "challenge" - assert result.challenge == "challenge_123" + assert result.challenge == "challenge_123_00000000000000000000" assert result.destination.effective_url == "https://buyer.example/webhooks/catalog" assert "signature" in captured[0].headers assert "signature-input" in captured[0].headers @@ -128,9 +309,12 @@ async def test_challenge_webhook_destination_with_sender( sent = json.loads(captured[0].content) assert sent == { "type": "webhook.challenge", - "challenge": "challenge_123", + "challenge": "challenge_123_00000000000000000000", "account_id": "acct_1", "subscriber_id": "buyer-primary", + "seller_agent_url": "https://seller.example/adcp", + "delivery_auth": {"mode": "rfc9421"}, + "event_types": ["creative.status_changed", "creative.purged"], } @@ -140,6 +324,7 @@ async def test_challenge_webhook_destination_with_legacy_authentication( ) -> None: _public_dns(monkeypatch) captured: list[httpx.Request] = [] + sender = _rfc9421_sender() def handler(request: httpx.Request) -> httpx.Response: captured.append(request) @@ -154,13 +339,22 @@ def handler(request: httpx.Request) -> httpx.Response: url="https://buyer.example/webhooks/catalog", account_id="acct_1", subscriber_id="audit-bus", + seller_agent_url="https://seller.example/adcp", + event_types=["creative.purged"], + sender=sender, authentication={"schemes": ["Bearer"], "credentials": "b" * 40}, - challenge="legacy_challenge_123", + challenge="legacy_challenge_123_000000000000", ) assert result.echoed_field == "token" - assert captured[0].headers["authorization"] == f"Bearer {'b' * 40}" - assert json.loads(captured[0].content)["type"] == "webhook.challenge" + assert "authorization" not in captured[0].headers + assert "signature" in captured[0].headers + assert json.loads(captured[0].content)["delivery_auth"] == { + "mode": "Bearer", + "credential_fingerprint": ( + "e26d2da3ab585c9840b157a4a9b5639fda905c2ad33d04d2c598be8883b51236" + ), + } @pytest.mark.asyncio @@ -169,6 +363,7 @@ async def test_challenge_webhook_destination_accepts_notification_config_url( ) -> None: _public_dns(monkeypatch) captured: list[httpx.Request] = [] + sender = _rfc9421_sender() config = NotificationConfig.model_validate( { "subscriber_id": "buyer-primary", @@ -186,8 +381,11 @@ async def test_challenge_webhook_destination_accepts_notification_config_url( url=config.url, account_id="acct_1", subscriber_id=config.subscriber_id, + seller_agent_url=_SELLER_AGENT_URL, + event_types=config.event_types, + sender=sender, authentication=config.authentication, - challenge="typed_url_challenge_123", + challenge=_VALID_CHALLENGE, ) assert result.ok @@ -201,6 +399,7 @@ async def test_challenge_webhook_destination_with_legacy_hmac( _public_dns(monkeypatch) captured: list[httpx.Request] = [] secret = "s" * 40 + sender = _rfc9421_sender() def handler(request: httpx.Request) -> httpx.Response: captured.append(request) @@ -215,18 +414,22 @@ def handler(request: httpx.Request) -> httpx.Response: url="https://buyer.example/webhooks/catalog", account_id="acct_1", subscriber_id="audit-bus", + seller_agent_url=_SELLER_AGENT_URL, + event_types=["creative.purged"], + sender=sender, authentication={"schemes": ["HMAC-SHA256"], "credentials": secret}, - challenge="hmac_challenge_123", + challenge=_VALID_CHALLENGE, ) sent = captured[0] - timestamp = sent.headers["x-adcp-timestamp"] - expected = hmac.new( - secret.encode("utf-8"), - f"{timestamp}.".encode() + sent.content, - hashlib.sha256, - ).hexdigest() - assert sent.headers["x-adcp-signature"] == f"sha256={expected}" + assert "signature" in sent.headers + assert "x-adcp-signature" not in sent.headers + assert json.loads(sent.content)["delivery_auth"] == { + "mode": "HMAC-SHA256", + "credential_fingerprint": ( + "3923fb8348df77529b04f6f536b8204856d0501d031063de795c57ad693b288f" + ), + } @pytest.mark.asyncio @@ -249,8 +452,10 @@ def handler(_request: httpx.Request) -> httpx.Response: url="https://buyer.example/webhooks/catalog", account_id="acct_1", subscriber_id="buyer-primary", + seller_agent_url=_SELLER_AGENT_URL, + event_types=_EVENT_TYPES, sender=sender, - challenge="challenge_123", + challenge=_VALID_CHALLENGE, ) assert exc.value.reason == "http_status" @@ -260,6 +465,7 @@ def handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio async def test_challenge_webhook_destination_local_development_policy() -> None: captured: dict[str, Any] = {} + sender = _rfc9421_sender() def handler(request: httpx.Request) -> httpx.Response: captured["url"] = str(request.url) @@ -274,8 +480,11 @@ def handler(request: httpx.Request) -> httpx.Response: url="http://localhost/webhooks/catalog", account_id="acct_1", subscriber_id="buyer-primary", + seller_agent_url=_SELLER_AGENT_URL, + event_types=_EVENT_TYPES, + sender=sender, authentication={"schemes": ["Bearer"], "credentials": "b" * 40}, - challenge="local_challenge_123", + challenge=_VALID_CHALLENGE, policy=WebhookDestinationPolicy.local_development(), ) @@ -293,6 +502,8 @@ async def test_challenge_webhook_destination_rejects_sender_with_custom_client() url="https://buyer.example/webhooks/catalog", account_id="acct_1", subscriber_id="buyer-primary", + seller_agent_url=_SELLER_AGENT_URL, + event_types=_EVENT_TYPES, sender=sender, ) @@ -308,6 +519,8 @@ async def test_challenge_webhook_destination_rejects_non_rfc9421_sender() -> Non url="https://buyer.example/webhooks/catalog", account_id="acct_1", subscriber_id="buyer-primary", + seller_agent_url=_SELLER_AGENT_URL, + event_types=_EVENT_TYPES, sender=sender, ) @@ -330,6 +543,8 @@ def rewrite_url(self, url: str) -> str: url="https://buyer.example/webhooks/catalog", account_id="acct_1", subscriber_id="buyer-primary", + seller_agent_url=_SELLER_AGENT_URL, + event_types=_EVENT_TYPES, sender=sender, ) @@ -352,6 +567,8 @@ async def test_challenge_webhook_destination_rejects_sender_host_override( url="https://buyer.example/webhooks/catalog", account_id="acct_1", subscriber_id="buyer-primary", + seller_agent_url=_SELLER_AGENT_URL, + event_types=_EVENT_TYPES, sender=sender, extra_headers={"Host": "evil.internal"}, ) @@ -363,11 +580,15 @@ async def test_challenge_webhook_destination_rejects_sender_host_override( async def test_challenge_webhook_destination_wraps_preflight_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: + sender = _rfc9421_sender() with pytest.raises(WebhookChallengeError) as exc: await challenge_webhook_destination( url="http://buyer.example/webhooks/catalog", account_id="acct_1", subscriber_id="buyer-primary", + seller_agent_url=_SELLER_AGENT_URL, + event_types=_EVENT_TYPES, + sender=sender, authentication={"schemes": ["Bearer"], "credentials": "b" * 40}, ) @@ -379,6 +600,9 @@ async def test_challenge_webhook_destination_wraps_preflight_errors( url="https://buyer.example/webhooks/catalog", account_id="", subscriber_id="buyer-primary", + seller_agent_url=_SELLER_AGENT_URL, + event_types=_EVENT_TYPES, + sender=sender, authentication={"schemes": ["Bearer"], "credentials": "b" * 40}, ) @@ -389,6 +613,9 @@ async def test_challenge_webhook_destination_wraps_preflight_errors( url="https://buyer.example/webhooks/catalog", account_id="acct_1", subscriber_id="buyer-primary", + seller_agent_url=_SELLER_AGENT_URL, + event_types=_EVENT_TYPES, + sender=sender, authentication={"schemes": ["Bearer"], "credentials": "b" * 40}, challenge="", ) @@ -417,8 +644,10 @@ def fake_getaddrinfo(host: str, port: object) -> list[tuple[object, ...]]: url="http://localhost/webhooks/catalog", account_id="acct_1", subscriber_id="buyer-primary", + seller_agent_url=_SELLER_AGENT_URL, + event_types=_EVENT_TYPES, sender=sender, - challenge="local_sender_challenge_123", + challenge=_VALID_CHALLENGE, policy=WebhookDestinationPolicy.local_development(), ) @@ -443,14 +672,16 @@ async def test_challenge_webhook_destination_sender_uses_sender_timeout( ): async_client.return_value.__aenter__.return_value.post.return_value = httpx.Response( 200, - json={"challenge": "timeout_challenge_123"}, + json={"challenge": _VALID_CHALLENGE}, ) await challenge_webhook_destination( url="https://buyer.example/webhooks/catalog", account_id="acct_1", subscriber_id="buyer-primary", + seller_agent_url=_SELLER_AGENT_URL, + event_types=_EVENT_TYPES, sender=sender, - challenge="timeout_challenge_123", + challenge=_VALID_CHALLENGE, ) assert async_client.call_args.kwargs["timeout"] == 42.0 diff --git a/tests/test_webhook_signing_capabilities.py b/tests/test_webhook_signing_capabilities.py index 102f387da9..51d58bf9a2 100644 --- a/tests/test_webhook_signing_capabilities.py +++ b/tests/test_webhook_signing_capabilities.py @@ -34,10 +34,8 @@ _KEYS = json.loads((VECTORS_DIR / "keys.json").read_text())["keys"] _REQUEST_ED25519 = next(k for k in _KEYS if k["kid"] == "test-ed25519-2026") -# Clone the request-signing fixture into a webhook-signing JWK. The -# from_jwk constructor rejects keys whose adcp_use is not exactly -# "webhook-signing" — separation of webhook-signing and request-signing -# key material is part of the AdCP security posture. +# Clone the request-signing fixture into a webhook-signing JWK to exercise +# compatibility with that accepted key purpose. _WEBHOOK_JWK = { **copy.deepcopy(_REQUEST_ED25519), "kid": "test-webhook-ed25519-2026",