diff --git a/examples/v3_reference_seller/src/app.py b/examples/v3_reference_seller/src/app.py index 460b3da50..5da2e4c20 100644 --- a/examples/v3_reference_seller/src/app.py +++ b/examples/v3_reference_seller/src/app.py @@ -55,13 +55,15 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from adcp.decisioning import InMemoryMockAdServer, serve +from adcp.decisioning import AdcpError, InMemoryMockAdServer, serve from adcp.server import ( SubdomainTenantMiddleware, ToolContext, current_tenant, ) from adcp.validation import ValidationHookConfig +from adcp.webhook_sender import WebhookSender +from adcp.webhook_supervisor import InMemoryWebhookDeliverySupervisor from .audit import make_sink as make_audit_sink from .buyer_registry import make_registry as make_buyer_registry @@ -126,6 +128,21 @@ def main() -> None: "mock_sales_guaranteed_key_do_not_use_in_prod", ) + # Webhook-signing wiring (#384). Loaded from env so the PEM stays + # off the process command line and out of any os.environ dump that + # would otherwise surface a raw JWK scalar. The key is a separate + # ed25519/es256 keypair from the request-signing key — AdCP requires + # webhook-signing material be distinct so a signature from one + # surface cannot be replayed on the other. + # + # Generate with: + # adcp-keygen --alg ed25519 --purpose webhook-signing \ + # --out /etc/adcp/webhook-signing.pem + # Then publish the printed public JWK at the seller's jwks_uri. + signing_pem_path = os.environ.get("ADCP_WEBHOOK_SIGNING_KEY_PATH") + signing_key_id = os.environ.get("ADCP_WEBHOOK_SIGNING_KEY_ID") + signing_alg = os.environ.get("ADCP_WEBHOOK_SIGNING_ALG", "ed25519") + engine = create_async_engine(db_url, pool_size=10, max_overflow=20) sessionmaker = async_sessionmaker(engine, expire_on_commit=False) @@ -153,11 +170,54 @@ def main() -> None: # Adopters with a real production upstream replace ``mode='mock'`` # with ``mode='live'`` in their ``AccountStore.resolve`` and declare # :attr:`V3ReferenceSeller.upstream_url` to their production URL. + # Wire the webhook supervisor iff signing material is present. When + # the env vars are unset, the seller falls back to the + # ``auto_emit_completion_webhooks=False`` posture below — a buyer + # registering ``push_notification_config.url`` will not receive + # auto-emitted completion webhooks, but boot succeeds without a key. + # The framework's #384 validator binds these two posture knobs + # together: capabilities advertise signing iff the supervisor is + # wired with an RFC 9421 key. + webhook_supervisor: InMemoryWebhookDeliverySupervisor | None = None + if signing_pem_path and signing_key_id: + webhook_sender = WebhookSender.from_pem( + signing_pem_path, + key_id=signing_key_id, + alg=signing_alg, + ) + webhook_supervisor = InMemoryWebhookDeliverySupervisor(sender=webhook_sender) + logger.info( + "Webhook signing wired: key_id=%s alg=%s pem=%s", + signing_key_id, + signing_alg, + signing_pem_path, + ) + elif signing_pem_path or signing_key_id: + # Partial config is operator error — both env vars must be set + # together, or both omitted. Raise AdcpError (terminal) so an + # adopter wrapping main() in ``except AdcpError`` catches all + # boot misconfigs uniformly, matching the sibling validators. + raise AdcpError( + "INVALID_REQUEST", + message=( + "ADCP_WEBHOOK_SIGNING_KEY_PATH and " + "ADCP_WEBHOOK_SIGNING_KEY_ID must be set together — got " + f"path={signing_pem_path!r}, key_id={signing_key_id!r}" + ), + recovery="terminal", + details={ + "missing": "webhook_signing_env_pair", + "ADCP_WEBHOOK_SIGNING_KEY_PATH_set": bool(signing_pem_path), + "ADCP_WEBHOOK_SIGNING_KEY_ID_set": bool(signing_key_id), + }, + ) + platform = V3ReferenceSeller( sessionmaker=sessionmaker, upstream_api_key=upstream_api_key, mock_upstream_url=upstream_url, mock_ad_server=mock_ad_server, + webhook_signing_alg=signing_alg if webhook_supervisor is not None else None, ) logger.info( @@ -190,15 +250,14 @@ def main() -> None: validation=ValidationHookConfig(requests="strict", responses="strict"), mock_ad_server=mock_ad_server, enable_debug_endpoints=True, - # The reference platform doesn't emit completion webhooks — - # turn off the F12 auto-emit gate so server boot doesn't trip - # ``validate_webhook_sender_for_platform``. Adopters whose - # platforms need webhook delivery wire a - # :class:`WebhookSender` (or - # :class:`InMemoryWebhookDeliverySupervisor`) and remove this - # kwarg — see the webhook_supervisor module for the wiring - # pattern. - auto_emit_completion_webhooks=False, + # Auto-emit binds to the supervisor: when a webhook-signing PEM + # is wired via the ADCP_WEBHOOK_SIGNING_KEY_PATH env var, the + # supervisor signs every auto-emitted completion webhook per + # RFC 9421 and the seller advertises the matching capability. + # When unwired, auto-emit stays off so the F12 boot gate doesn't + # trip on the missing sender (no silent webhook drops). + webhook_supervisor=webhook_supervisor, + auto_emit_completion_webhooks=webhook_supervisor is not None, # FastMCP's TransportSecurityMiddleware enforces DNS-rebinding # protection: its default ``allowed_hosts`` accepts only # loopback (``127.0.0.1:*``, ``localhost:*``, ``[::1]:*``), so diff --git a/examples/v3_reference_seller/src/platform.py b/examples/v3_reference_seller/src/platform.py index 64f30e066..95fac055b 100644 --- a/examples/v3_reference_seller/src/platform.py +++ b/examples/v3_reference_seller/src/platform.py @@ -64,6 +64,7 @@ import asyncio import logging import random +from dataclasses import replace as _dc_replace from datetime import datetime, timezone from typing import TYPE_CHECKING, Any @@ -90,6 +91,9 @@ from adcp.decisioning.capabilities import ( Specialism as CapsSpecialism, ) +from adcp.decisioning.capabilities import ( + WebhookSigning as CapsWebhookSigning, +) from adcp.decisioning.specialisms import SalesPlatform from adcp.server import current_tenant from adcp.types import ( @@ -345,6 +349,7 @@ def __init__( mock_ad_server: MockAdServer | None = None, approval_poll_interval_s: float = 1.0, approval_poll_max_iterations: int = 60, + webhook_signing_alg: str | None = None, ) -> None: """Construct the reference seller. @@ -369,7 +374,31 @@ def __init__( ``/v1/tasks/{id}`` during async order approval. :param approval_poll_max_iterations: Maximum polls before raising ``SERVICE_UNAVAILABLE`` (transient). + :param webhook_signing_alg: When set (``"ed25519"`` or + ``"ecdsa-p256-sha256"``), this seller advertises + ``capabilities.webhook_signing.supported=True`` and the + named algorithm. ``app.main`` sets this iff a webhook-signing + key PEM is wired via env vars; the framework's #384 boot + validator then enforces that the wired + :class:`~adcp.webhook_sender.WebhookSender` produces RFC 9421 + signatures over outbound deliveries. Default ``None`` — + no signing advertised, sender wiring optional. """ + # Override the class-level capabilities iff signing is wired. + # ``dataclasses.replace`` preserves every other field from the + # class-level template — adding a new field to the template + # (e.g. ``signals=...``) propagates automatically without + # touching this override. + if webhook_signing_alg is not None: + self.capabilities = _dc_replace( + type(self).capabilities, + webhook_signing=CapsWebhookSigning( + supported=True, + profile="adcp/webhook-signing/v1", + algorithms=[webhook_signing_alg], # type: ignore[list-item] + ), + ) + self._sessionmaker = sessionmaker # Single auth instance shared across every upstream_for() call. # The framework's client cache keys on (base_url, id(auth)), diff --git a/examples/v3_reference_seller/tests/test_smoke.py b/examples/v3_reference_seller/tests/test_smoke.py index bc2246164..b65022474 100644 --- a/examples/v3_reference_seller/tests/test_smoke.py +++ b/examples/v3_reference_seller/tests/test_smoke.py @@ -157,3 +157,34 @@ async def test_buyer_registry_returns_none_without_tenant() -> None: cred = ApiKeyCredential(kind="api_key", key_id="any") assert await registry.resolve_by_agent_url("https://x/") is None assert await registry.resolve_by_credential(cred) is None + + +def test_platform_default_does_not_advertise_webhook_signing() -> None: + """Out-of-the-box, the reference seller advertises no + webhook-signing capability — the constructor flag is opt-in. Boot + succeeds without a PEM keypair. + """ + from src.platform import V3ReferenceSeller + + assert V3ReferenceSeller.capabilities.webhook_signing is None + + +def test_platform_advertises_webhook_signing_when_alg_passed() -> None: + """With ``webhook_signing_alg`` wired, the per-instance capabilities + advertise ``webhook_signing.supported=True`` and the matching + algorithm — the #384 boot validator gates on this exact shape. + """ + from src.platform import V3ReferenceSeller + + seller = V3ReferenceSeller( + sessionmaker=lambda: None, # type: ignore[arg-type] + upstream_api_key="test-key", + mock_upstream_url=None, + webhook_signing_alg="ed25519", + ) + ws = seller.capabilities.webhook_signing + assert ws is not None + assert ws.supported is True + assert ws.profile == "adcp/webhook-signing/v1" + assert ws.algorithms is not None + assert [a.value for a in ws.algorithms] == ["ed25519"] diff --git a/src/adcp/decisioning/serve.py b/src/adcp/decisioning/serve.py index 8703006ea..4399debe8 100644 --- a/src/adcp/decisioning/serve.py +++ b/src/adcp/decisioning/serve.py @@ -322,7 +322,10 @@ def create_adcp_server_from_platform( # universe). A platform that doesn't claim any # webhook-eligible-tool-bearing specialism (test fixtures, # discovery-only agents) doesn't trigger the gate. - from adcp.decisioning.webhook_emit import validate_webhook_sender_for_platform + from adcp.decisioning.webhook_emit import ( + validate_webhook_sender_for_platform, + validate_webhook_signing_for_capabilities, + ) validate_webhook_sender_for_platform( advertised_tools=handler.advertised_tools_for_instance(), @@ -331,6 +334,16 @@ def create_adcp_server_from_platform( auto_emit=auto_emit_completion_webhooks, ) + # Issue #384: a platform advertising webhook_signing.supported=True + # must wire a JWK-signing sender. The check is independent of the + # auto-emit gate above — manually-emitted webhooks signed by the + # platform handler also need to honor the capability advertisement. + validate_webhook_signing_for_capabilities( + capabilities=platform.capabilities, + sender=webhook_sender, + supervisor=webhook_supervisor, + ) + # DX #422: boot-time fail-fast on a non-conformant capabilities # projection. Same posture as validate_platform / F12 — the # operator sees one structured AdcpError before the server starts diff --git a/src/adcp/decisioning/webhook_emit.py b/src/adcp/decisioning/webhook_emit.py index 68729efed..0db99bc5f 100644 --- a/src/adcp/decisioning/webhook_emit.py +++ b/src/adcp/decisioning/webhook_emit.py @@ -50,6 +50,7 @@ ) if TYPE_CHECKING: + from adcp.decisioning.platform import DecisioningCapabilities from adcp.webhook_sender import WebhookSender from adcp.webhook_supervisor import WebhookDeliverySupervisor @@ -388,8 +389,156 @@ def validate_webhook_sender_for_platform( ) +def validate_webhook_signing_for_capabilities( + *, + capabilities: DecisioningCapabilities, + sender: WebhookSender | None, + supervisor: WebhookDeliverySupervisor | None = None, +) -> None: + """Server-boot fail-fast for the #384 capabilities-vs-wiring invariant. + + When the platform's :class:`DecisioningCapabilities` declares + ``webhook_signing.supported=True``, the AdCP capabilities schema + binds the seller to producing RFC 9421 ``Signature`` headers on + EVERY outbound webhook — the schema description on the ``supported`` + field reads "When false or absent, ... receivers MUST NOT expect a + Signature header," so by contrapositive when ``true`` they MUST. + There is no per-delivery opt-out in AdCP 3.x; ``legacy_hmac_fallback`` + is a downgrade switch for receivers that have NOT adopted RFC 9421, + not a substitute for the seller's RFC 9421 capability. + + The wired :class:`~adcp.webhook_sender.WebhookSender` MUST therefore + be configured with a JWK signing key whose ``alg`` is also present + in the advertised ``algorithms`` list. A bearer-only or HMAC sender, + or a JWK sender whose alg is not advertised, would emit deliveries + that conformant verifiers reject — silent blackout for any buyer + enforcing RFC 9421. + + The check keys on the capability advertisement, not on + ``reporting_delivery_methods=["webhook"]``: 3.x explicitly permits + HMAC/Bearer-only delivery via ``legacy_hmac_fallback``, so the + delivery-method axis is a poor gate. ``webhook_signing.supported`` + is the self-consistency contract the spec supports directly. + + Sender resolution: this validator introspects the supervisor's + ``_sender`` attribute when ``sender`` is ``None`` — both + :class:`~adcp.webhook_supervisor.InMemoryWebhookDeliverySupervisor` + and :class:`~adcp.webhook_supervisor_pg.PgWebhookDeliverySupervisor` + expose it. Custom Protocol-only supervisors without an + introspectable sender log a WARNING and skip validation; operators + wiring those impls own the contract themselves but the gap is + observable in boot logs. + + :raises AdcpError: ``code='INVALID_REQUEST'`` when capabilities + declare RFC 9421 signing support but no sender (or a non-JWK + sender, or a JWK sender whose alg doesn't match the advertised + algorithms) is wired. Matches the recovery posture of sibling + boot-time validators (terminal). + """ + webhook_signing = getattr(capabilities, "webhook_signing", None) + if webhook_signing is None or not getattr(webhook_signing, "supported", False): + return + + resolved_sender: Any = sender + if resolved_sender is None and supervisor is not None: + # Both reference supervisors store the underlying WebhookSender + # on ``_sender``. Custom Protocol-only impls (Celery/Kafka + # queue-only adopters) may not — log a WARNING so the gap is + # observable in boot logs, then skip rather than fail-noisy on + # an unknowable structure. + resolved_sender = getattr(supervisor, "_sender", None) + if resolved_sender is None: + logger.warning( + "[adcp.decisioning] capabilities.webhook_signing.supported=True " + "but supervisor %s has no introspectable _sender attribute; " + "boot validator cannot verify the wired sender produces RFC 9421 " + "headers. Operator owns the contract — confirm out-of-band that " + "outbound deliveries from this supervisor carry Signature / " + "Signature-Input.", + type(supervisor).__name__, + ) + return + + from adcp.decisioning.types import AdcpError + + if resolved_sender is None: + raise AdcpError( + "INVALID_REQUEST", + message=( + "capabilities.webhook_signing.supported=True declares this " + "platform signs outbound webhooks per RFC 9421, but neither " + "webhook_sender nor webhook_supervisor was wired. Buyers " + "enforcing RFC 9421 verification on inbound webhooks would " + "see every delivery from this seller fail signature check. " + "Either wire a WebhookSender via WebhookSender.from_jwk(...) " + "or WebhookSender.from_pem(...), or remove " + "webhook_signing.supported from the capabilities declaration." + ), + recovery="terminal", + details={ + "missing": "webhook_sender_with_rfc9421_key", + "capabilities_webhook_signing_supported": True, + }, + ) + + if not getattr(resolved_sender, "signs_with_rfc9421", False): + raise AdcpError( + "INVALID_REQUEST", + message=( + "capabilities.webhook_signing.supported=True declares this " + "platform signs outbound webhooks per RFC 9421, but the " + "wired WebhookSender is not configured for JWK signing " + "(bearer-token, AdCP-legacy HMAC, and Standard-Webhooks " + "HMAC senders do not produce RFC 9421 Signature / " + "Signature-Input headers). Reconstruct the sender via " + "WebhookSender.from_jwk(...) or WebhookSender.from_pem(...), " + "or remove webhook_signing.supported from the capabilities " + "declaration if this seller does not in fact sign per " + "RFC 9421." + ), + recovery="terminal", + details={ + "missing": "webhook_sender_with_rfc9421_key", + "capabilities_webhook_signing_supported": True, + "sender_auth_mode": type(getattr(resolved_sender, "_auth", None)).__name__, + }, + ) + + # Cross-check the wired sender's signature algorithm against the + # advertised set. A seller declaring ``algorithms=["ed25519"]`` and + # wiring an ES256 sender would emit deliveries pinned verifiers + # reject — same silent-blackout failure mode the supported-check + # closes, one axis deeper. ``algorithms`` is optional on the wire; + # skip the cross-check when omitted (no advertisement to violate). + advertised_algorithms = getattr(webhook_signing, "algorithms", None) + if advertised_algorithms: + sender_alg = getattr(getattr(resolved_sender, "_auth", None), "alg", None) + advertised_alg_values = [getattr(a, "value", a) for a in advertised_algorithms] + if sender_alg not in advertised_alg_values: + raise AdcpError( + "INVALID_REQUEST", + message=( + "capabilities.webhook_signing.algorithms advertises " + f"{advertised_alg_values!r} but the wired WebhookSender " + f"signs with {sender_alg!r}. Buyers pinning their RFC 9421 " + "verifier to the advertised algorithms reject every " + "delivery whose Signature-Input ``alg=`` is outside the " + "set. Align the sender's alg with the capability " + "declaration, or widen ``algorithms`` to include the " + "sender's value." + ), + recovery="terminal", + details={ + "missing": "webhook_signing_algorithm_alignment", + "advertised_algorithms": advertised_alg_values, + "sender_alg": sender_alg, + }, + ) + + __all__ = [ "SPEC_WEBHOOK_TASK_TYPES", "maybe_emit_sync_completion", "validate_webhook_sender_for_platform", + "validate_webhook_signing_for_capabilities", ] diff --git a/src/adcp/webhook_sender.py b/src/adcp/webhook_sender.py index 35966f72c..887615a66 100644 --- a/src/adcp/webhook_sender.py +++ b/src/adcp/webhook_sender.py @@ -497,6 +497,19 @@ def __repr__(self) -> str: # bearer token) into logs. return f"WebhookSender(auth={type(self._auth).__name__}, " f"key_id={self._key_id!r})" + @property + def signs_with_rfc9421(self) -> bool: + """``True`` iff this sender uses the RFC 9421 webhook-signing profile. + + Boot-time validators read this to enforce the + ``webhook_signing.supported=true`` capability invariant: + capabilities advertise RFC 9421 → wired sender must produce + ``Signature`` / ``Signature-Input`` headers. ``from_bearer_token``, + ``from_adcp_legacy_hmac``, and ``from_standard_webhooks_secret`` + senders return ``False``. + """ + return isinstance(self._auth, JwkSignerStrategy) + async def aclose(self) -> None: """Close the internal httpx client if we own it.""" if self._owns_client and self._client is not None: diff --git a/tests/test_webhook_signing_capabilities.py b/tests/test_webhook_signing_capabilities.py new file mode 100644 index 000000000..205a5cb73 --- /dev/null +++ b/tests/test_webhook_signing_capabilities.py @@ -0,0 +1,362 @@ +"""Tests for #384 — webhook_signing.supported boot validator. + +Covers the two acceptance criteria the issue tracks here: + +* AC4 — outbound webhooks delivered by an RFC 9421 sender carry the + ``Signature`` and ``Signature-Input`` headers conformant verifiers + gate on. +* AC5 — server boot fails when ``capabilities.webhook_signing.supported`` + is ``True`` but no JWK-signing sender is wired. + +Other auth-mode senders (bearer, AdCP-legacy HMAC, Standard-Webhooks +HMAC) MUST trip the same boot gate — capabilities advertise RFC 9421, +the wired sender does not produce ``Signature`` / ``Signature-Input``, +buyers enforcing RFC 9421 see silent blackout. +""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path +from unittest.mock import MagicMock + +import httpx +import pytest + +from adcp.decisioning.capabilities import WebhookSigning +from adcp.decisioning.types import AdcpError +from adcp.decisioning.webhook_emit import validate_webhook_signing_for_capabilities +from adcp.webhook_sender import WebhookSender +from adcp.webhook_supervisor import InMemoryWebhookDeliverySupervisor + +VECTORS_DIR = Path(__file__).parent / "conformance" / "vectors" / "request-signing" +_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. +_WEBHOOK_JWK = { + **copy.deepcopy(_REQUEST_ED25519), + "kid": "test-webhook-ed25519-2026", + "adcp_use": "webhook-signing", +} + + +def _jwk_with_private() -> dict: + return {**_WEBHOOK_JWK, "d": _WEBHOOK_JWK["_private_d_for_test_only"]} + + +class _CapturingTransport(httpx.AsyncBaseTransport): + """Records the request the sender would have emitted, returns 200.""" + + def __init__(self) -> None: + self.captured: httpx.Request | None = None + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + self.captured = request + return httpx.Response(200, content=b"{}", request=request) + + +# ----- AC4: outbound webhooks carry RFC 9421 headers ----- + + +@pytest.mark.asyncio +async def test_outbound_webhook_carries_rfc9421_signature_headers() -> None: + """A JWK-signing sender MUST attach ``Signature`` and ``Signature-Input`` + on every outbound POST. Without these headers, any buyer running an + RFC 9421 verifier (the AdCP-conformant posture) rejects the delivery. + """ + transport = _CapturingTransport() + client = httpx.AsyncClient(transport=transport, base_url="http://test") + sender = WebhookSender.from_jwk(_jwk_with_private(), client=client) + + async with sender: + result = await sender.send_mcp( + url="http://test/webhooks/adcp", + task_id="task_ac4", + task_type="create_media_buy", + status="completed", + result={"media_buy_id": "mb_1"}, + ) + + assert result.status_code == 200 + assert transport.captured is not None + headers = transport.captured.headers + assert "signature" in headers, ( + "RFC 9421 Signature header missing from outbound webhook — " + "buyers enforcing webhook-signing would reject every delivery" + ) + assert "signature-input" in headers, ( + "RFC 9421 Signature-Input header missing — verifiers cannot " + "validate without the covered-components metadata" + ) + # Content-Digest is bound into the signature per the AdCP profile; + # without it, the receiver cannot verify body integrity. + assert "content-digest" in headers + # Profile pinning: Signature-Input MUST carry ``tag="adcp/webhook-signing/v1"`` + # and ``keyid=`` so receivers can statically validate the declared + # profile and look up the signing key. Without these parameters, + # the on-wire signature is not adcp/webhook-signing/v1-conformant + # regardless of cryptographic validity. + signature_input = headers["signature-input"] + assert ( + 'tag="adcp/webhook-signing/v1"' in signature_input + ), f"Signature-Input missing profile tag: {signature_input!r}" + assert ( + 'keyid="test-webhook-ed25519-2026"' in signature_input + ), f"Signature-Input missing keyid: {signature_input!r}" + + +@pytest.mark.asyncio +async def test_bearer_sender_does_not_emit_rfc9421_headers() -> None: + """A bearer-token sender MUST NOT emit RFC 9421 headers — the + ``signs_with_rfc9421`` property is the validator's only reliable + signal, so this test pins the contract at the sender level. + """ + transport = _CapturingTransport() + client = httpx.AsyncClient(transport=transport, base_url="http://test") + sender = WebhookSender.from_bearer_token("test-token", client=client) + assert sender.signs_with_rfc9421 is False + + async with sender: + await sender.send_raw( + url="http://test/webhooks/adcp", + idempotency_key="whk_test", + payload={"x": 1}, + ) + + assert transport.captured is not None + assert "signature" not in transport.captured.headers + assert "signature-input" not in transport.captured.headers + assert transport.captured.headers["authorization"] == "Bearer test-token" + + +def test_jwk_sender_reports_signs_with_rfc9421() -> None: + """The validator reads ``signs_with_rfc9421`` to gate boot — the + JWK constructor must set it ``True`` so the gate accepts. + """ + sender = WebhookSender.from_jwk(_jwk_with_private()) + assert sender.signs_with_rfc9421 is True + + +# ----- AC5: boot fails when capabilities advertise signing but no key ----- + + +class _Caps: + """Minimal capabilities stub — the validator only reads the + ``webhook_signing`` attribute. + """ + + def __init__(self, webhook_signing: WebhookSigning | None) -> None: + self.webhook_signing = webhook_signing + + +def test_boot_passes_when_capabilities_omit_webhook_signing() -> None: + """No advertisement, no obligation — validator returns silently.""" + validate_webhook_signing_for_capabilities( + capabilities=_Caps(webhook_signing=None), + sender=None, + supervisor=None, + ) + + +def test_boot_passes_when_supported_false() -> None: + """Capabilities present but ``supported=False`` is still a non-advertisement.""" + validate_webhook_signing_for_capabilities( + capabilities=_Caps(webhook_signing=WebhookSigning(supported=False)), + sender=None, + supervisor=None, + ) + + +def test_boot_fails_when_signing_advertised_but_no_sender() -> None: + """The headline #384 failure mode: capabilities advertise signing, + nothing is wired, buyers enforcing RFC 9421 see silent blackout. + """ + with pytest.raises(AdcpError) as exc_info: + validate_webhook_signing_for_capabilities( + capabilities=_Caps(webhook_signing=WebhookSigning(supported=True)), + sender=None, + supervisor=None, + ) + assert exc_info.value.code == "INVALID_REQUEST" + assert exc_info.value.details["missing"] == "webhook_sender_with_rfc9421_key" + assert exc_info.value.details["capabilities_webhook_signing_supported"] is True + + +def test_boot_fails_when_signing_advertised_with_bearer_sender() -> None: + """A non-JWK sender (bearer / HMAC) advertised as RFC 9421 trips the + same gate — buyers see the capability but receive unsignable bytes. + """ + sender = WebhookSender.from_bearer_token("test-token") + with pytest.raises(AdcpError) as exc_info: + validate_webhook_signing_for_capabilities( + capabilities=_Caps(webhook_signing=WebhookSigning(supported=True)), + sender=sender, + supervisor=None, + ) + assert exc_info.value.code == "INVALID_REQUEST" + assert exc_info.value.details["sender_auth_mode"] == "BearerTokenStrategy" + + +def test_boot_fails_when_signing_advertised_with_legacy_hmac_sender() -> None: + """3.x's ``legacy_hmac_fallback`` is delivery-axis only — a seller + advertising ``webhook_signing.supported=True`` still owes RFC 9421 + headers. HMAC-only senders trip the gate. + """ + sender = WebhookSender.from_adcp_legacy_hmac(b"secret", key_id="hmac-1") + with pytest.raises(AdcpError) as exc_info: + validate_webhook_signing_for_capabilities( + capabilities=_Caps(webhook_signing=WebhookSigning(supported=True)), + sender=sender, + supervisor=None, + ) + assert exc_info.value.code == "INVALID_REQUEST" + assert exc_info.value.details["sender_auth_mode"] == "AdcpLegacyHmacStrategy" + + +def test_boot_passes_when_jwk_sender_wired() -> None: + """The happy path: capabilities advertise signing, a JWK sender is + wired, boot proceeds. + """ + sender = WebhookSender.from_jwk(_jwk_with_private()) + validate_webhook_signing_for_capabilities( + capabilities=_Caps(webhook_signing=WebhookSigning(supported=True)), + sender=sender, + supervisor=None, + ) + + +def test_boot_passes_when_supervisor_wraps_jwk_sender() -> None: + """The realistic adopter wiring: the supervisor owns the sender, the + framework reads it back via the convention-private ``_sender``. + """ + sender = WebhookSender.from_jwk(_jwk_with_private()) + supervisor = InMemoryWebhookDeliverySupervisor(sender=sender) + validate_webhook_signing_for_capabilities( + capabilities=_Caps(webhook_signing=WebhookSigning(supported=True)), + sender=None, + supervisor=supervisor, + ) + + +def test_boot_fails_when_supervisor_wraps_bearer_sender() -> None: + """Sender introspection through the supervisor must surface non-RFC-9421 + senders too — the supervisor wrapper does not change the auth-mode + contract on the wire. + """ + sender = WebhookSender.from_bearer_token("test-token") + supervisor = InMemoryWebhookDeliverySupervisor(sender=sender) + with pytest.raises(AdcpError) as exc_info: + validate_webhook_signing_for_capabilities( + capabilities=_Caps(webhook_signing=WebhookSigning(supported=True)), + sender=None, + supervisor=supervisor, + ) + assert exc_info.value.details["sender_auth_mode"] == "BearerTokenStrategy" + + +def test_boot_skips_validation_for_protocol_only_supervisor( + caplog: pytest.LogCaptureFixture, +) -> None: + """A custom supervisor without an introspectable ``_sender`` (e.g., a + Celery / Kafka queue-only impl) is the adopter's contract to honor. + The validator skips the check but logs a WARNING so the gap is + observable in boot logs — silent skip would mask the same + silent-blackout failure mode the gate exists to close. + """ + custom_supervisor = MagicMock(spec=[]) # no ``_sender`` attr + with caplog.at_level("WARNING", logger="adcp.decisioning.webhook_emit"): + validate_webhook_signing_for_capabilities( + capabilities=_Caps(webhook_signing=WebhookSigning(supported=True)), + sender=None, + supervisor=custom_supervisor, + ) + assert any( + "no introspectable _sender attribute" in rec.message for rec in caplog.records + ), f"expected WARNING about protocol-only supervisor; got {caplog.records!r}" + + +# ----- legacy_hmac_fallback does not relax the wired-sender requirement ----- + + +def test_boot_fails_with_hmac_sender_even_when_legacy_hmac_fallback_advertised() -> None: + """``legacy_hmac_fallback`` is the per-receiver downgrade switch + (HMAC for receivers that have not adopted RFC 9421) — NOT a + substitute for the seller's RFC 9421 capability. A seller declaring + ``supported=True, legacy_hmac_fallback=True`` still owes RFC 9421 + headers for receivers that DO support them. HMAC-only senders trip + the gate regardless of the fallback flag. + """ + sender = WebhookSender.from_adcp_legacy_hmac(b"secret", key_id="hmac-1") + with pytest.raises(AdcpError) as exc_info: + validate_webhook_signing_for_capabilities( + capabilities=_Caps( + webhook_signing=WebhookSigning( + supported=True, + legacy_hmac_fallback=True, + ), + ), + sender=sender, + supervisor=None, + ) + assert exc_info.value.details["sender_auth_mode"] == "AdcpLegacyHmacStrategy" + + +# ----- algorithm cross-check ----- + + +def test_boot_fails_when_sender_alg_not_in_advertised_algorithms() -> None: + """Advertised ``algorithms=["ecdsa-p256-sha256"]`` + ed25519 sender + is the silent-blackout case one axis deeper than the supported + check: buyers pinning their verifier to the advertised set reject + every delivery whose ``Signature-Input alg=`` is outside the set. + """ + sender = WebhookSender.from_jwk(_jwk_with_private()) # ed25519 + with pytest.raises(AdcpError) as exc_info: + validate_webhook_signing_for_capabilities( + capabilities=_Caps( + webhook_signing=WebhookSigning( + supported=True, + algorithms=["ecdsa-p256-sha256"], + ), + ), + sender=sender, + supervisor=None, + ) + assert exc_info.value.details["missing"] == "webhook_signing_algorithm_alignment" + assert exc_info.value.details["advertised_algorithms"] == ["ecdsa-p256-sha256"] + assert exc_info.value.details["sender_alg"] == "ed25519" + + +def test_boot_passes_when_sender_alg_in_advertised_algorithms() -> None: + """The happy path: advertised set includes the sender's alg.""" + sender = WebhookSender.from_jwk(_jwk_with_private()) # ed25519 + validate_webhook_signing_for_capabilities( + capabilities=_Caps( + webhook_signing=WebhookSigning( + supported=True, + algorithms=["ed25519", "ecdsa-p256-sha256"], + ), + ), + sender=sender, + supervisor=None, + ) + + +def test_boot_skips_alg_check_when_algorithms_omitted() -> None: + """``algorithms`` is optional on the wire — omission means the seller + is not pinning verifiers to a specific set, so any RFC 9421 sender + is acceptable. Cross-check skipped. + """ + sender = WebhookSender.from_jwk(_jwk_with_private()) # ed25519 + validate_webhook_signing_for_capabilities( + capabilities=_Caps( + webhook_signing=WebhookSigning(supported=True, algorithms=None), + ), + sender=sender, + supervisor=None, + )