diff --git a/README.md b/README.md index 6b878287a..a6b698f3a 100644 --- a/README.md +++ b/README.md @@ -832,16 +832,15 @@ 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, + signing_profile=config.signing_profile, field="accounts[0].notification_configs[0].url", policy=WebhookDestinationPolicy.production(), ) @@ -854,11 +853,14 @@ 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 sender that supports the config's +selected signing profile without `client=`; use `WebhookSender.from_jwk(...)` +for `adcp/webhook-signing/v1`, and `WebhookSender.from_wba(...)` or +`with_web_bot_auth(...)` for `web-bot-auth`. 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`: @@ -877,11 +879,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 906079ad2..5d089d952 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -1748,16 +1748,15 @@ 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, + signing_profile=config.signing_profile, policy=WebhookDestinationPolicy.production(), field="accounts[0].notification_configs[0].url", ) @@ -1790,10 +1789,12 @@ need `http://localhost` or private-network endpoints. The helper returns both subscription state. The proof-of-control helper uses the SDK-managed pinned transport, posts a `webhook.challenge`, and requires the receiver to echo the challenge value 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 reserved for normal delivery paths, not registration +`authentication`, pass an RFC 9421 sender that supports the config's selected +signing profile without `client=`; use `WebhookSender.from_jwk(...)` for +`adcp/webhook-signing/v1`, and `WebhookSender.from_wba(...)` or +`with_web_bot_auth(...)` for `web-bot-auth`. Bearer/HMAC durable configs must +pass `authentication=config.authentication`; custom egress clients, proxies, +and mTLS transports are reserved for normal delivery paths, not registration proof-of-control. ### Wholesale feed notifications diff --git a/docs/production-seller.md b/docs/production-seller.md index 36fc0ccc2..869d0a532 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 5d01a6dc3..67c0f565f 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 6494536f4..b47d9ee46 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 49f214c15..9e6aeac41 100644 --- a/src/adcp/webhook_sender.py +++ b/src/adcp/webhook_sender.py @@ -42,6 +42,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, @@ -65,6 +66,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, @@ -506,10 +508,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. @@ -517,15 +517,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": @@ -562,7 +559,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``, @@ -2124,13 +2121,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":"...", ...}`` @@ -2139,9 +2140,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 d6d2705d8..a4b0aa610 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 @@ -1228,8 +1228,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: @@ -1239,12 +1242,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( @@ -1471,80 +1505,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, @@ -1606,6 +1571,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, @@ -1621,22 +1588,14 @@ 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. ``signing_profile`` is the config's ``signing_profile`` (AdCP 3.3); the challenge is signed under it, ``adcp/webhook-signing/v1`` when omitted. """ 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: @@ -1655,14 +1614,11 @@ 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.", ) required_profile = signing_profile or WEBHOOK_SIGNING_PROFILE_ADCP if sender is not None and required_profile not in sender.signing_profiles: @@ -1683,21 +1639,22 @@ async def challenge_webhook_destination( field=field, url=error_url, ) - 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: @@ -1718,37 +1675,23 @@ 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, - signing_profile=required_profile, - ) - 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, + signing_profile=required_profile, + ) + 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 a2687c8d2..9221a61bb 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 d74ff78f0..f4ec9ad7b 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 @@ -484,6 +715,8 @@ async def test_challenge_is_signed_under_the_configs_signing_profile( 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, signing_profile=profile, ) @@ -506,6 +739,8 @@ async def test_challenge_refuses_a_profile_the_sender_cannot_sign() -> None: 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, ) assert exc.value.reason == "signing_profile_unavailable" diff --git a/tests/test_webhook_signing_capabilities.py b/tests/test_webhook_signing_capabilities.py index 102f387da..51d58bf9a 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",