From eb4894f26edb9548b49d122e55ac6d57ea1824ac Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 24 Apr 2026 19:47:40 -0400 Subject: [PATCH 1/4] feat(signing): default replay store, signed-fetch preset, migration guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three buyer/seller ergonomic upgrades to bring the Python SDK to parity with the TypeScript ergonomics shipped in adcp-client #917: 1. VerifyOptions.replay_store defaults to a fresh InMemoryReplayStore when omitted. Defaulting to None silently disabled replay protection for callers who forgot to wire one — exactly the regression AdCP verifier checklist step 12 exists to prevent. Each VerifyOptions instance gets its own default store via field(default_factory=...); pass replay_store=None explicitly if you genuinely want to bypass the check (uncommon — typically only short-lived integration tests). 2. install_signing_event_hook + signing_operation context manager. Buyer-side preset for adapters that don't use ADCPClient — same shape ADCPClient uses internally, exposed as a public surface so raw-httpx orchestrators get the same auto-sign UX. Supports both static seller_capability and a sync/async capability_provider callable for lazy lookups. 3. docs/request-signing-migration.md mirrors adcp-go's MIGRATION.md shape — bootstrap, staged enforcement (supported_for → warn_for → required_for), key rotation (routine + compromise paths), common pitfalls, pre-enforcement checklist. README signing section gains short pointers to the new preset and the migration guide. Tests: 15 new (5 for VerifyOptions defaults, 10 for the buyer hook). Full suite: 2188 passed (was 2173). ruff clean. mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 44 +++ docs/request-signing-migration.md | 219 +++++++++++++ src/adcp/signing/__init__.py | 8 + src/adcp/signing/client.py | 221 +++++++++++++ src/adcp/signing/verifier.py | 22 +- .../test_install_signing_event_hook.py | 302 ++++++++++++++++++ .../signing/test_verifier_defaults.py | 73 +++++ 7 files changed, 887 insertions(+), 2 deletions(-) create mode 100644 docs/request-signing-migration.md create mode 100644 src/adcp/signing/client.py create mode 100644 tests/conformance/signing/test_install_signing_event_hook.py create mode 100644 tests/conformance/signing/test_verifier_defaults.py diff --git a/README.md b/README.md index 73ae2f97..89774655 100644 --- a/README.md +++ b/README.md @@ -1091,6 +1091,43 @@ signed = sign_request( httpx.post(url, content=body, headers={**headers, **signed.as_dict()}) ``` +### Auto-sign on `ADCPClient` + +The high-level client wires the signing event hook for you when you pass a `SigningConfig`: + +```python +from adcp.client import ADCPClient +from adcp.signing import SigningConfig, load_private_key_pem + +signing = SigningConfig( + private_key=load_private_key_pem(open("signing-key.pem", "rb").read()), + key_id="my-agent-2026", +) + +client = ADCPClient(agent_config, signing=signing) +# Outbound calls are signed automatically per the seller's request_signing capability. +``` + +### Auto-sign on raw httpx (no ADCPClient) + +For adapters that integrate against a seller via raw `httpx`, install the same hook on your own client: + +```python +import httpx +from adcp.signing import SigningConfig, install_signing_event_hook, signing_operation + +client = httpx.AsyncClient() +install_signing_event_hook( + client, + signing=signing, + seller_capability=seller_caps.request_signing, +) + +async with client: + with signing_operation("create_media_buy"): + resp = await client.post("https://seller.example.com/mcp", json=payload) +``` + ### Verify incoming requests (FastAPI) ```python @@ -1116,6 +1153,9 @@ async def create_media_buy(request: Request): operation="create_media_buy", jwks_resolver=jwks, ) + # `replay_store` defaults to a fresh InMemoryReplayStore when omitted. + # Wire an explicit shared store (PgReplayStore via [pg] extra, or your + # own ReplayStore Protocol implementation) for multi-replica deployments. try: signer = await verify_starlette_request(request, options=options) except SignatureVerificationError as exc: @@ -1130,6 +1170,10 @@ async def create_media_buy(request: Request): Flask has an equivalent synchronous helper `verify_flask_request`. +### Migration & rollout + +Rolling signing out against an existing integration is a staged exercise — bootstrap, then advance each operation through `supported_for` → `warn_for` → `required_for`. See [`docs/request-signing-migration.md`](docs/request-signing-migration.md) for the full walkthrough including key rotation, common pitfalls, and a pre-enforcement checklist. + ### Conformance The verifier passes all 28 AdCP request-signing conformance vectors (8 positive, diff --git a/docs/request-signing-migration.md b/docs/request-signing-migration.md new file mode 100644 index 00000000..a068674e --- /dev/null +++ b/docs/request-signing-migration.md @@ -0,0 +1,219 @@ +# Migrating to signed AdCP requests + +Rolling out RFC 9421 request signing against an existing AdCP integration is a two-track exercise: **bootstrap** once, then **enforce** in stages per operation. Key **rotation** follows the same pattern and is meant to be routine. + +This guide covers the operator-facing mechanics. Spec reference: [Signed Requests (Transport Layer)](https://adcontextprotocol.org/docs/building/implementation/security#signed-requests-transport-layer). + +The Python SDK ships parallel ergonomics to [adcp-go's MIGRATION guide](https://github.com/adcontextprotocol/adcp-go/blob/main/adcp/signing/MIGRATION.md) — same staged rollout, same key-rotation pattern, different language idioms. + +## 1. Bootstrap + +One-time work to make an agent able to sign (as a buyer) or verify (as a seller). + +### Generate and publish a key + +```bash +adcp-keygen --alg ed25519 --kid my-agent-2026-01 --purpose request-signing \ + --out signing.pem +``` + +Or programmatically — the same spine the CLI uses: + +```python +from adcp.signing import generate_signing_keypair + +pem, public_jwk = generate_signing_keypair( + alg="ed25519", + kid="my-agent-2026-01", + purpose="request-signing", +) +``` + +Prefer Ed25519 over ES256 unless a regulatory constraint forces NIST curves. Ed25519 is deterministic by construction — no RNG participates at sign time, which simplifies replay analysis. + +The command writes `signing.pem` (PKCS#8 private key) and prints/returns a JWK with `use: "sig"`, `key_ops: ["verify"]`, and `adcp_use: "request-signing"`. Publish the JWK at your agent's `jwks_uri`: + +```json +{ "keys": [ { "kid": "my-agent-2026-01", "kty": "OKP", "crv": "Ed25519", "x": "...", "use": "sig", "key_ops": ["verify"], "adcp_use": "request-signing" } ] } +``` + +Hold the PEM in your secret store — environment variable, GCP Secret Manager, AWS Secrets Manager. Never commit it. + +### Advertise signing on `get_adcp_capabilities` + +Set `request_signing` on your capabilities response with empty `supported_for` / `warn_for` / `required_for` to start. Counterparties probing your capabilities can now see the block exists. + +```python +from adcp.types.generated_poc.protocol.get_adcp_capabilities_response import ( + CoversContentDigest, + RequestSigning, +) + +request_signing = RequestSigning( + supported=True, + covers_content_digest=CoversContentDigest.either, + required_for=[], + warn_for=[], + supported_for=[], +) +``` + +## 2. Staged enforcement (per operation) + +Never flip an operation straight from unsigned to required. Stage it through three stops. + +### Step A — `supported_for` + +Add the operation to `supported_for`. Counterparties **MAY** sign; your verifier **MUST** accept signed requests but does not yet reject unsigned ones. + +The Python middleware stays permissive — wire `replay_store` and `jwks_resolver`, but leave `required_for` empty: + +```python +from adcp.signing import ( + CachingJwksResolver, + InMemoryReplayStore, + VerifierCapability, + VerifyOptions, + verify_starlette_request, +) + +jwks_resolver = CachingJwksResolver(jwks_uri="https://buyer.example.com/.well-known/jwks.json") + +# `replay_store` defaults to a fresh InMemoryReplayStore — single-process +# deployments don't need to wire one explicitly. Pass an explicit shared +# store (Redis-backed, custom Postgres) for multi-replica setups. +options = VerifyOptions( + now=time.time(), + capability=VerifierCapability( + covers_content_digest="either", + required_for=frozenset(), # nothing rejected yet + supported_for=frozenset({"create_media_buy"}), + ), + operation="create_media_buy", + jwks_resolver=jwks_resolver, +) + +verified = await verify_starlette_request(request, options=options) +# verified.key_id is the buyer's signing identity. +``` + +Success signal: signed requests arrive, `verify_starlette_request` returns a `VerifiedSigner` rather than raising. + +### Step B — `warn_for` + +Move the operation to `warn_for`. Verification still runs, failures are logged, traffic is unaffected. Watch your failure rate and walk down the long tail of "some counterparty is misbehaving" before flipping to reject. + +The spec calls this shadow mode. Python doesn't have a built-in `observe_only` flag yet — approximate by catching `SignatureVerificationError` and logging: + +```python +from adcp.signing import SignatureVerificationError + +try: + verified = await verify_starlette_request(request, options=options) +except SignatureVerificationError as exc: + # WARNING: step-B shim only. Delete before enabling required_for in step C — + # this turns every required-signed op into an unsigned op. + logger.warning( + "signature would reject", extra={"code": exc.code, "step": exc.step} + ) + verified = None # fall through unsigned +``` + +Success signal: search your logs for `"signature would reject"` and watch the rate fall to zero — or to a known-and-tolerated set of counterparties — over a window long enough to cover your slowest integrator's deploy cadence. + +### Step C — `required_for` + +Move the operation to `required_for` and populate `VerifierCapability.required_for`. **Don't enable `covers_content_digest="required"` yet** — body-modifying intermediaries are the most common surprise failure in production rollouts and they only break the digest path. Land `required_for` first: + +```python +options = VerifyOptions( + now=time.time(), + capability=VerifierCapability( + covers_content_digest="either", + required_for=frozenset({"create_media_buy"}), + ), + operation="create_media_buy", + jwks_resolver=jwks_resolver, +) + +# Remove the step-B shim. Let SignatureVerificationError propagate to your +# 401 response builder. +try: + verified = await verify_starlette_request(request, options=options) +except SignatureVerificationError as exc: + return JSONResponse( + status_code=401, + headers=unauthorized_response_headers(exc), + content={"error": exc.code, "message": str(exc)}, + ) +``` + +A rollout later, once you've confirmed no `request_signature_digest_mismatch` in step-B logs, tighten to `covers_content_digest="required"`: + +```python +capability=VerifierCapability( + covers_content_digest="required", + required_for=frozenset({"create_media_buy"}), +) +``` + +### Rollback from step C + +If production breaks after flipping `required_for`: + +1. Revert the verifier config — drop the operation from `VerifierCapability.required_for` (and from `covers_content_digest="required"` if set). Redeploy. +2. Do **not** touch `jwks_uri` or your revocation list. Counterparties that are already signing correctly will keep doing so, harmlessly. +3. Update `get_adcp_capabilities.request_signing.required_for` on the next deploy to match the rolled-back verifier — counterparties probing capabilities must not be told "required" while the verifier is back to permissive. + +Returning to step B's shadow mode is the safe resting state while you diagnose. + +## 3. Key rotation + +Schedule rotation routinely — monthly to quarterly is the common range — so the path is exercised and a compromise-driven rotation isn't the first time you run it. + +### Routine rotation + +Two JWKS publishes plus a signer cutover: + +1. **Publish new kid alongside old kid.** Update `jwks_uri` to list both keys. Wait for counterparties' JWKS caches to refresh — `CachingJwksResolver` holds a 30-second refetch cooldown on kid-miss, so one minute is a safe floor. +2. **Cut over the signer.** Flip `SigningConfig.key_id` / `SigningConfig.private_key` on every instance. Adapters using `ADCPClient(signing=...)` reconstruct the client with the new config; adapters using `install_signing_event_hook` recreate the hook against the new `SigningConfig`. +3. **Grace period.** Hold both kids in the JWKS for at least **2× the max validity window** (10 minutes with the default 300-second window). Reasoning: one window for the last request you signed under the old kid to reach its `expires`, plus one window for its replay-cache entry to age out so you can't distinguish a real replay from a drained entry. Add operational headroom on top (1–2× the deploy cadence of your slowest counterparty) so in-flight retries under the old kid still verify. +4. **Remove the old kid.** Publish a JWKS with only the new kid. + +### Compromise rotation + +Ordering is different — the old kid must stop being trusted *before* anything else happens, because during routine step 1 ("publish both"), counterparties still accept signatures under the old kid for the full JWKS-propagation window: + +1. **Revoke first.** Add the burned kid to your revocation list with a timestamp covering the compromise window. Counterparties polling the revocation list (via `CachingRevocationChecker`) will reject anything signed by the burned kid, even if their JWKS cache hasn't refreshed yet. +2. **Publish the new kid.** Update `jwks_uri` to include the new kid. The old kid can stay in the JWKS or be removed immediately — revocation beats presence. +3. **Cut over the signer** to the new kid on every instance. +4. **Remove the old kid** from the JWKS once the compromise-window revocation entry is no longer needed. Keep the revocation entry active for at least the historical audit window (whatever your governance requires). + +## 4. Common pitfalls + +- **Body-modifying intermediaries break `content-digest` coverage.** CDNs, WAFs, and API gateways that recompress or re-serialize request bodies cause `request_signature_digest_mismatch`. Diagnose by comparing signer-side body bytes to verifier-side body bytes — they must be byte-identical. Either preserve bytes end-to-end or stay on `covers_content_digest="either"` for the affected operation. +- **Forgetting to disable redirect-following on signed clients.** `@target-uri` is part of the signature base. If the server returns a 3xx redirect, the signature still binds to the original URL. Configure `httpx.AsyncClient(follow_redirects=False)` or implement a redirect handler that re-signs. +- **Clock skew > 60s.** Verifiers reject with `request_signature_window_invalid` when `created` is more than `max_skew_seconds` in the future or `expires` is past. NTP-sync both sides; investigate container hosts that drift after suspend/resume. +- **Custom JWKS fetcher losing SSRF protection.** If you implement a custom `JwksFetcher`, gate it through `validate_jwks_uri` / `resolve_and_validate_host` — otherwise an attacker who controls a `jwks_uri` can pivot against your internal network. +- **Per-keyid replay cap.** The default `InMemoryReplayStore` caps at 1,000,000 entries per keyid (configurable via `per_keyid_cap`). Sustained > 3k QPS per signing key will trip `request_signature_rate_abuse`. For multi-process deployments, use `PgReplayStore` (the `[pg]` extra) or roll your own backing the `ReplayStore` Protocol. +- **Step-B shim surviving into production.** The `try/except SignatureVerificationError → log → fall through` pattern from step B silently turns every required-signed op into an unsigned op once `required_for` is set. Before flipping, grep your codebase for `SignatureVerificationError` and confirm the shim is gone. Months later, if you inherit the repo, re-grep. +- **`current_operation` ContextVar leaking into background tasks.** `signing_operation()` sets a ContextVar that copies into `asyncio.create_task` children. Don't spawn unrelated network calls inside a signing scope — they'd inherit the operation name and sign requests the caller didn't intend to sign. + +## 5. Verification checklist before enforcing + +- [ ] At least one signed request from a staging counterparty has been verified end-to-end — `verify_starlette_request` returns a `VerifiedSigner` in your handler — before flipping `required_for`. +- [ ] `jwks_uri` returns your current kid (and only your current kid, once rotation is complete). +- [ ] `get_adcp_capabilities.request_signing.required_for` matches `VerifierCapability.required_for`. +- [ ] Revocation source is configured (`revocation_checker` or `revocation_list` is non-None) if you've published any revocations — otherwise revoked keys are accepted silently. +- [ ] Replay store strategy is explicit: default `InMemoryReplayStore` for single-instance, `PgReplayStore` (or equivalent shared store) for multi-instance. +- [ ] Logs from step B show zero unexpected failures over at least one full deploy cycle of your slowest counterparty. +- [ ] No step-B shim (`try/except SignatureVerificationError → log → continue`) remains in the verifier wiring. +- [ ] Redirect-following is disabled on every signing client. +- [ ] Clock sync monitoring is in place on signer and verifier hosts. + +## Related + +- [`adcp.signing.install_signing_event_hook`](../src/adcp/signing/client.py) — buyer-side preset for adapters not using `ADCPClient`. +- [`ADCPClient(signing=...)`](../src/adcp/client.py) — the higher-level client integration; signing is wired automatically once you pass a `SigningConfig`. +- [adcp-go's MIGRATION.md](https://github.com/adcontextprotocol/adcp-go/blob/main/adcp/signing/MIGRATION.md) — Go sibling guide. +- [AdCP signing guide (docs PR)](https://github.com/adcontextprotocol/adcp/pull/3064) — the cross-language end-user walkthrough. diff --git a/src/adcp/signing/__init__.py b/src/adcp/signing/__init__.py index 4dc40d82..6761cf0e 100644 --- a/src/adcp/signing/__init__.py +++ b/src/adcp/signing/__init__.py @@ -99,6 +99,11 @@ canonicalize_target_uri, parse_signature_input_header, ) +from adcp.signing.client import ( + CapabilityProvider, + install_signing_event_hook, + signing_operation, +) from adcp.signing.constants import ( DEFAULT_EXPIRES_IN_SECONDS, DEFAULT_SKEW_SECONDS, @@ -245,6 +250,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "AsyncRevocationListFetcher", "CachingJwksResolver", "CachingRevocationChecker", + "CapabilityProvider", "DEFAULT_EXPIRES_IN_SECONDS", "DEFAULT_GRACE_MULTIPLIER", "DEFAULT_SKEW_SECONDS", @@ -318,6 +324,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "extract_signature_bytes", "format_signature_header", "generate_signing_keypair", + "install_signing_event_hook", "load_private_key_pem", "operation_needs_signing", "parse_signature_input_header", @@ -326,6 +333,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "resolve_and_validate_host", "sign_request", "sign_signature_base", + "signing_operation", "unauthorized_response_headers", "validate_jwks_uri", "verify_detached_jws", diff --git a/src/adcp/signing/client.py b/src/adcp/signing/client.py new file mode 100644 index 00000000..33cbd529 --- /dev/null +++ b/src/adcp/signing/client.py @@ -0,0 +1,221 @@ +"""Buyer-side ergonomic preset for adapters that don't use ``ADCPClient``. + +When you build on :class:`ADCPClient` / :class:`ADCPMultiAgentClient`, +passing ``signing=SigningConfig(...)`` is enough — the client wires the +RFC 9421 signing event hook for you. This module is the equivalent for +adapters integrating against a seller via raw ``httpx`` (custom +orchestrators, edge proxies, anything the higher-level client doesn't +fit). + +Two pieces: + +* :func:`install_signing_event_hook` — installs a request event hook on + an existing :class:`httpx.AsyncClient` that signs outbound requests + per the seller's advertised ``request_signing`` policy. +* :func:`signing_operation` — context manager that sets the AdCP + operation name on the shared :data:`current_operation` ContextVar so + the hook knows which capability list to consult on each request. + +Usage:: + + import httpx + from adcp.signing import ( + SigningConfig, + VerifierCapability, # if you mirror the seller's advertisement locally + install_signing_event_hook, + signing_operation, + ) + + signing = SigningConfig(private_key=..., key_id="my-agent-2026") + + seller_capability = await fetch_seller_request_signing_capability() + + client = httpx.AsyncClient() + install_signing_event_hook( + client, + signing=signing, + seller_capability=seller_capability, + ) + + async with client: + with signing_operation("create_media_buy"): + resp = await client.post("https://seller.example.com/mcp", json=payload) + +The ``ADCPClient(signing=...)`` path remains the right shape when you +are using the SDK's high-level client — its built-in hook does the same +work plus capability prefetching, and you should not double-install. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable, Iterator +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any + +from adcp.signing.autosign import ( + SigningConfig, + current_operation, + operation_needs_signing, +) +from adcp.signing.signer import sign_request + +if TYPE_CHECKING: + import httpx + + from adcp.types.generated_poc.protocol.get_adcp_capabilities_response import ( + RequestSigning, + ) + +logger = logging.getLogger(__name__) + + +CapabilityProvider = Callable[[], "RequestSigning | None | Awaitable[RequestSigning | None]"] +"""Returns the seller's ``request_signing`` capability block. + +May be sync (``-> RequestSigning | None``) or async +(``-> Awaitable[RequestSigning | None]``). The hook awaits an +awaitable result so callers can plug in a capability fetcher backed by +a network call. +""" + + +def install_signing_event_hook( + client: httpx.AsyncClient, + *, + signing: SigningConfig, + seller_capability: RequestSigning | None = None, + capability_provider: CapabilityProvider | None = None, +) -> None: + """Install an RFC 9421 request-signing event hook on ``client``. + + The hook reads the AdCP operation name from + :data:`adcp.signing.current_operation` (set by + :func:`signing_operation`), consults the seller's advertised + ``request_signing`` policy, and attaches ``Signature`` / + ``Signature-Input`` / ``Content-Digest`` headers when the operation + is in ``required_for`` / ``warn_for`` / ``supported_for``. + + Pass exactly one of ``seller_capability`` or ``capability_provider``. + + Parameters + ---------- + client: + An :class:`httpx.AsyncClient`. The hook is appended to its + existing ``event_hooks["request"]`` list. + signing: + Buyer credentials. Same shape used by ``ADCPClient(signing=...)``. + seller_capability: + The seller's ``request_signing`` block. Use this when the value + is known up front (you've already called ``get_adcp_capabilities`` + once at boot and you're caching it). + capability_provider: + Callable returning the capability per request. Use when the + capability needs lazy / re-resolved lookup. Sync and async are + both supported. Returning ``None`` means "seller doesn't sign; + skip every operation." + + Notes + ----- + Operations not yet bracketed in :func:`signing_operation` (e.g. + health-check probes, metrics scrapes that share the client) pass + through unsigned — same carve-out as ``ADCPClient``. + """ + if (seller_capability is None) == (capability_provider is None): + raise ValueError( + "install_signing_event_hook requires exactly one of " + "`seller_capability` or `capability_provider`." + ) + + async def _hook(request: httpx.Request) -> None: + operation = current_operation.get() + # Unset ContextVar → out-of-band call (health check, manual + # probe). Skip without consulting capability. + # ``get_adcp_capabilities`` is the bootstrap carve-out: signing + # it would require capabilities we don't have yet. Mirrors + # ADCPClient._sign_outgoing_request. + if operation is None or operation == "get_adcp_capabilities": + return + + if seller_capability is not None: + capability = seller_capability + else: + assert capability_provider is not None + result = capability_provider() + if hasattr(result, "__await__"): + capability = await result # type: ignore[misc] + else: + capability = result # type: ignore[assignment] + + decision = operation_needs_signing(capability, operation) + if decision == "skip": + return + + covers_policy: str | None = None + if capability is not None and capability.covers_content_digest is not None: + covers_policy = capability.covers_content_digest.value + if covers_policy == "forbidden": + cover_digest = False + elif covers_policy == "required": + cover_digest = True + else: + # "either" / absent — signer's choice; pick the stricter + # body-bound option so the seller's content-digest verify + # never rejects on a "missing optional component" path. + cover_digest = True + + signed = sign_request( + method=request.method, + url=str(request.url), + headers=dict(request.headers), + body=request.content, + private_key=signing.private_key, + key_id=signing.key_id, + alg=signing.alg, + cover_content_digest=cover_digest, + tag=signing.tag, + ) + # pop-then-set so our values are authoritative even if an + # earlier layer set the same header in a different case. + for header_name, header_value in signed.as_dict().items(): + request.headers.pop(header_name, None) + request.headers[header_name] = header_value + + hooks = client.event_hooks + request_hooks: list[Any] = list(hooks.get("request") or []) + request_hooks.append(_hook) + hooks["request"] = request_hooks + client.event_hooks = hooks + + +@contextmanager +def signing_operation(name: str) -> Iterator[None]: + """Set :data:`adcp.signing.current_operation` for the duration of the block. + + ``install_signing_event_hook`` reads this ContextVar to decide + whether an outbound request should be signed and what the signing + policy is for that operation. + + :: + + with signing_operation("create_media_buy"): + resp = await client.post(url, json=payload) + + ContextVar values copy into ``asyncio.create_task`` children, so a + background task spawned inside this block inherits the operation + name. Don't spawn unrelated network calls inside a signing scope — + they'd be classified under whichever operation is on the + ContextVar at the moment they fire. + """ + token = current_operation.set(name) + try: + yield + finally: + current_operation.reset(token) + + +__all__ = [ + "CapabilityProvider", + "install_signing_event_hook", + "signing_operation", +] diff --git a/src/adcp/signing/verifier.py b/src/adcp/signing/verifier.py index 2090f806..b0a0bac7 100644 --- a/src/adcp/signing/verifier.py +++ b/src/adcp/signing/verifier.py @@ -55,7 +55,7 @@ SignatureVerificationError, ) from adcp.signing.jwks import JwksResolver -from adcp.signing.replay import ReplayStore +from adcp.signing.replay import InMemoryReplayStore, ReplayStore from adcp.signing.revocation import RevocationChecker, RevocationList CoversDigestPolicy = Literal["required", "forbidden", "either"] @@ -99,11 +99,29 @@ class VerifierCapability: @dataclass(frozen=True, kw_only=True) class VerifyOptions: + """Options bag passed to ``verify_request_signature``. + + ``replay_store`` defaults to a fresh :class:`InMemoryReplayStore` so the + verifier always enforces nonce uniqueness on every request — defaulting + to ``None`` would silently disable replay protection for callers who + forget to wire a store, the exact security regression the AdCP profile's + step 12 exists to prevent. Wire an explicit shared store (Redis, Postgres, + etc.) for multi-replica deployments where replay state must be + coordinated across processes; pass ``replay_store=None`` if you genuinely + need to bypass the check (uncommon — typically only short-lived + integration tests). + + ``revocation_checker`` and ``revocation_list`` remain optional — + most agents don't track key revocations at runtime, and the verifier + correctly skips the check when both are absent. Wire one when you + publish a revocation list or expose an admin tool for emergency rotation. + """ + now: float capability: VerifierCapability operation: str jwks_resolver: JwksResolver - replay_store: ReplayStore | None = None + replay_store: ReplayStore | None = field(default_factory=InMemoryReplayStore) revocation_checker: RevocationChecker | None = None revocation_list: RevocationList | None = None max_skew_seconds: int = DEFAULT_SKEW_SECONDS diff --git a/tests/conformance/signing/test_install_signing_event_hook.py b/tests/conformance/signing/test_install_signing_event_hook.py new file mode 100644 index 00000000..3a8300de --- /dev/null +++ b/tests/conformance/signing/test_install_signing_event_hook.py @@ -0,0 +1,302 @@ +"""Tests for the buyer-side preset for raw-httpx adapters. + +``install_signing_event_hook`` mirrors what ``ADCPClient`` does +internally — for adapters that don't use the high-level client. Each +test goes outbound through the hook and verifies the produced +signature with the RFC 9421 verifier, the same end-to-end shape used +by the existing ``test_autosign_hook.py`` suite. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from adcp.signing import ( + SigningConfig, + StaticJwksResolver, + VerifierCapability, + VerifyOptions, + install_signing_event_hook, + private_key_from_jwk, + signing_operation, + verify_request_signature, +) +from adcp.types.generated_poc.protocol.get_adcp_capabilities_response import ( + CoversContentDigest, + RequestSigning, +) + +VECTORS_DIR = Path(__file__).parent.parent / "vectors" / "request-signing" +KEYS = json.loads((VECTORS_DIR / "keys.json").read_text())["keys"] +ED25519_KEY = next(k for k in KEYS if k["kid"] == "test-ed25519-2026") + + +def _config() -> SigningConfig: + return SigningConfig( + private_key=private_key_from_jwk(ED25519_KEY, d_field="_private_d_for_test_only"), + key_id=ED25519_KEY["kid"], + alg="ed25519", + ) + + +def _capability( + *, + required: list[str] | None = None, + warn: list[str] | None = None, + supported_for: list[str] | None = None, + covers: CoversContentDigest = CoversContentDigest.either, + signing_supported: bool = True, +) -> RequestSigning: + return RequestSigning( + supported=signing_supported, + covers_content_digest=covers, + required_for=required or [], + warn_for=warn or [], + supported_for=supported_for, + ) + + +def _verify( + request: httpx.Request, + body: bytes, + *, + operation: str, + covers_policy: str = "either", + required_for: frozenset[str] = frozenset(), +) -> None: + jwks_resolver = StaticJwksResolver({"keys": [ED25519_KEY]}) + options = VerifyOptions( + now=float(int(time.time())), + capability=VerifierCapability( + covers_content_digest=covers_policy, # type: ignore[arg-type] + required_for=required_for, + ), + operation=operation, + jwks_resolver=jwks_resolver, + ) + verify_request_signature( + method=request.method, + url=str(request.url), + headers=dict(request.headers), + body=body, + options=options, + ) + + +@pytest.mark.asyncio +async def test_signs_required_for_operation() -> None: + body = b'{"plan_id":"p1"}' + request = httpx.Request( + method="POST", + url="https://seller.example.com/adcp/create_media_buy", + headers={"Content-Type": "application/json"}, + content=body, + ) + + capability = _capability(required=["create_media_buy"]) + client = httpx.AsyncClient() + install_signing_event_hook(client, signing=_config(), seller_capability=capability) + [hook] = client.event_hooks["request"] + + with signing_operation("create_media_buy"): + await hook(request) + + assert "Signature" in request.headers + assert "Signature-Input" in request.headers + _verify( + request, + body, + operation="create_media_buy", + required_for=frozenset({"create_media_buy"}), + ) + + +@pytest.mark.asyncio +async def test_skips_unsigned_operation_not_in_any_list() -> None: + request = httpx.Request( + method="POST", + url="https://seller.example.com/adcp/get_products", + headers={"Content-Type": "application/json"}, + content=b"{}", + ) + + capability = _capability(required=["create_media_buy"]) + client = httpx.AsyncClient() + install_signing_event_hook(client, signing=_config(), seller_capability=capability) + [hook] = client.event_hooks["request"] + + with signing_operation("get_products"): + await hook(request) + + assert "Signature" not in request.headers + + +@pytest.mark.asyncio +async def test_skips_when_current_operation_unset() -> None: + """No ContextVar → out-of-band call (health check, capability prefetch).""" + request = httpx.Request( + method="POST", + url="https://seller.example.com/adcp/create_media_buy", + headers={"Content-Type": "application/json"}, + content=b"{}", + ) + + capability = _capability(required=["create_media_buy"]) + client = httpx.AsyncClient() + install_signing_event_hook(client, signing=_config(), seller_capability=capability) + [hook] = client.event_hooks["request"] + + # No `signing_operation` block — current_operation stays None. + await hook(request) + + assert "Signature" not in request.headers + + +@pytest.mark.asyncio +async def test_skips_get_adcp_capabilities() -> None: + """Bootstrap carve-out: signing the capability prefetch would recurse.""" + request = httpx.Request( + method="POST", + url="https://seller.example.com/adcp/get_adcp_capabilities", + headers={"Content-Type": "application/json"}, + content=b"{}", + ) + + capability = _capability(required=["get_adcp_capabilities"]) + client = httpx.AsyncClient() + install_signing_event_hook(client, signing=_config(), seller_capability=capability) + [hook] = client.event_hooks["request"] + + with signing_operation("get_adcp_capabilities"): + await hook(request) + + assert "Signature" not in request.headers + + +@pytest.mark.asyncio +async def test_supports_async_capability_provider() -> None: + """A provider that returns an awaitable resolves correctly.""" + body = b'{"x":1}' + request = httpx.Request( + method="POST", + url="https://seller.example.com/adcp/sync_creatives", + headers={"Content-Type": "application/json"}, + content=body, + ) + + capability = _capability(supported_for=["sync_creatives"]) + + async def provider() -> RequestSigning | None: + return capability + + client = httpx.AsyncClient() + install_signing_event_hook(client, signing=_config(), capability_provider=provider) + [hook] = client.event_hooks["request"] + + with signing_operation("sync_creatives"): + await hook(request) + + assert "Signature" in request.headers + + +@pytest.mark.asyncio +async def test_supports_sync_capability_provider() -> None: + capability = _capability(required=["create_media_buy"]) + + def provider() -> RequestSigning | None: + return capability + + body = b'{"plan_id":"p1"}' + request = httpx.Request( + method="POST", + url="https://seller.example.com/adcp/create_media_buy", + headers={"Content-Type": "application/json"}, + content=body, + ) + + client = httpx.AsyncClient() + install_signing_event_hook(client, signing=_config(), capability_provider=provider) + [hook] = client.event_hooks["request"] + + with signing_operation("create_media_buy"): + await hook(request) + + assert "Signature" in request.headers + + +def test_requires_exactly_one_of_capability_or_provider() -> None: + config = _config() + client = httpx.AsyncClient() + + with pytest.raises(ValueError, match="exactly one"): + install_signing_event_hook(client, signing=config) + + capability = _capability() + + def provider() -> RequestSigning | None: + return capability + + with pytest.raises(ValueError, match="exactly one"): + install_signing_event_hook( + client, + signing=config, + seller_capability=capability, + capability_provider=provider, + ) + + +def test_signing_operation_resets_context_var_on_exit() -> None: + from adcp.signing.autosign import current_operation + + assert current_operation.get() is None + with signing_operation("create_media_buy"): + assert current_operation.get() == "create_media_buy" + assert current_operation.get() is None + + +def test_signing_operation_resets_on_exception() -> None: + from adcp.signing.autosign import current_operation + + assert current_operation.get() is None + with pytest.raises(RuntimeError): + with signing_operation("create_media_buy"): + raise RuntimeError("boom") + assert current_operation.get() is None + + +@pytest.mark.asyncio +async def test_appends_to_existing_event_hooks() -> None: + """Pre-existing request hooks are preserved; the signer is appended.""" + pre_existing_called: list[Any] = [] + + async def existing_hook(_request: httpx.Request) -> None: + pre_existing_called.append(True) + + client = httpx.AsyncClient(event_hooks={"request": [existing_hook]}) + install_signing_event_hook( + client, + signing=_config(), + seller_capability=_capability(required=["create_media_buy"]), + ) + + request = httpx.Request( + method="POST", + url="https://seller.example.com/adcp/create_media_buy", + headers={"Content-Type": "application/json"}, + content=b"{}", + ) + + hooks = client.event_hooks["request"] + assert len(hooks) == 2 + with signing_operation("create_media_buy"): + for hook in hooks: + await hook(request) + + assert pre_existing_called == [True] + assert "Signature" in request.headers diff --git a/tests/conformance/signing/test_verifier_defaults.py b/tests/conformance/signing/test_verifier_defaults.py new file mode 100644 index 00000000..7eee1505 --- /dev/null +++ b/tests/conformance/signing/test_verifier_defaults.py @@ -0,0 +1,73 @@ +"""Defaults shipped on ``VerifyOptions``. + +The verifier's ``replay_store`` defaults to a fresh +:class:`InMemoryReplayStore` so callers who omit it still get nonce-replay +protection — the alternative ("``None`` skips the check") was a silent +security regression for any caller who forgot to wire one. +""" + +from __future__ import annotations + +from adcp.signing.replay import InMemoryReplayStore +from adcp.signing.verifier import ( + VerifierCapability, + VerifyOptions, +) + + +class _StubResolver: + def __call__(self, _kid: str) -> None: + return None + + +def _opts(**overrides) -> VerifyOptions: + base = { + "now": 0.0, + "capability": VerifierCapability(), + "operation": "create_media_buy", + "jwks_resolver": _StubResolver(), + } + base.update(overrides) + return VerifyOptions(**base) + + +def test_default_replay_store_is_in_memory() -> None: + opts = _opts() + assert isinstance(opts.replay_store, InMemoryReplayStore) + + +def test_explicit_none_replay_store_preserved() -> None: + """Callers who genuinely want to bypass replay protection (e.g. integration + tests) can still pass ``replay_store=None`` and get the previous skip- + the-check behavior.""" + opts = _opts(replay_store=None) + assert opts.replay_store is None + + +def test_each_verify_options_instance_gets_its_own_default_store() -> None: + """``field(default_factory=...)`` constructs a fresh store per instance — + so a replay seen on one verifier doesn't leak into another, the same + isolation TS authenticator instances enforce.""" + a = _opts() + b = _opts() + assert a.replay_store is not b.replay_store + + +def test_default_replay_store_actually_dedup_replays() -> None: + """End-to-end: the default store remembers a nonce across calls on the + same VerifyOptions instance.""" + opts = _opts() + store = opts.replay_store + assert store is not None # for mypy; preserved by the default + assert not store.seen("kid", "nonce-1") + store.remember("kid", "nonce-1", ttl_seconds=300) + assert store.seen("kid", "nonce-1") + + +def test_revocation_stays_optional() -> None: + """``revocation_checker`` and ``revocation_list`` are not defaulted — + most agents don't track revocations at runtime, and the verifier + correctly skips the check when both are absent.""" + opts = _opts() + assert opts.revocation_checker is None + assert opts.revocation_list is None From 6767ad57dffcaa8f41d5f576407e354732375261 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 24 Apr 2026 19:57:16 -0400 Subject: [PATCH 2/4] fix(signing): use inspect.isawaitable for capability_provider sync/async detect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hasattr(result, "__await__")` returns true for `unittest.mock.Mock` because Mock synthesizes any attribute access — a sync Mock returning a RequestSigning would be detected as awaitable and crash on `await`. `inspect.isawaitable` covers coroutines, futures, and any __await__-defining object correctly. Adds tests: - Mock provider regression (catches the hasattr footgun). - Provider returning None skips signing. - covers_content_digest='forbidden' omits content-digest coverage. Tests: 18/18 in the new suite. Full suite: 2191 passed (was 2188). Review: https://github.com/adcontextprotocol/adcp-client-python/pull/272 Flagged by python-expert + code-reviewer subagents. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/signing/client.py | 11 ++- .../test_install_signing_event_hook.py | 86 +++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/adcp/signing/client.py b/src/adcp/signing/client.py index 33cbd529..8916197f 100644 --- a/src/adcp/signing/client.py +++ b/src/adcp/signing/client.py @@ -48,6 +48,7 @@ from __future__ import annotations +import inspect import logging from collections.abc import Awaitable, Callable, Iterator from contextlib import contextmanager @@ -142,10 +143,14 @@ async def _hook(request: httpx.Request) -> None: else: assert capability_provider is not None result = capability_provider() - if hasattr(result, "__await__"): - capability = await result # type: ignore[misc] + # `inspect.isawaitable` covers coroutines, futures, and any + # `__await__`-defining object — and unlike `hasattr(result, + # "__await__")` it doesn't treat a Mock() as awaitable just + # because Mock synthesizes any attribute access. + if inspect.isawaitable(result): + capability = await result else: - capability = result # type: ignore[assignment] + capability = result decision = operation_needs_signing(capability, operation) if decision == "skip": diff --git a/tests/conformance/signing/test_install_signing_event_hook.py b/tests/conformance/signing/test_install_signing_event_hook.py index 3a8300de..e7893d94 100644 --- a/tests/conformance/signing/test_install_signing_event_hook.py +++ b/tests/conformance/signing/test_install_signing_event_hook.py @@ -230,6 +230,92 @@ def provider() -> RequestSigning | None: assert "Signature" in request.headers +@pytest.mark.asyncio +async def test_mock_capability_provider_does_not_get_awaited() -> None: + """Regression for the `hasattr(result, "__await__")` footgun. + + `unittest.mock.Mock` synthesizes any attribute access, so a sync + Mock that returns a RequestSigning would be detected as awaitable + by `hasattr(__await__)` and crash. `inspect.isawaitable` correctly + treats it as sync. + """ + from unittest.mock import Mock + + capability = _capability(required=["create_media_buy"]) + provider = Mock(return_value=capability) + + body = b'{"plan_id":"p1"}' + request = httpx.Request( + method="POST", + url="https://seller.example.com/adcp/create_media_buy", + headers={"Content-Type": "application/json"}, + content=body, + ) + + client = httpx.AsyncClient() + install_signing_event_hook(client, signing=_config(), capability_provider=provider) + [hook] = client.event_hooks["request"] + + with signing_operation("create_media_buy"): + await hook(request) + + provider.assert_called_once() + assert "Signature" in request.headers + + +@pytest.mark.asyncio +async def test_capability_provider_returning_none_skips_signing() -> None: + """Provider returns None ⇒ seller doesn't sign ⇒ skip every operation.""" + + def provider() -> RequestSigning | None: + return None + + request = httpx.Request( + method="POST", + url="https://seller.example.com/adcp/create_media_buy", + headers={"Content-Type": "application/json"}, + content=b"{}", + ) + + client = httpx.AsyncClient() + install_signing_event_hook(client, signing=_config(), capability_provider=provider) + [hook] = client.event_hooks["request"] + + with signing_operation("create_media_buy"): + await hook(request) + + assert "Signature" not in request.headers + + +@pytest.mark.asyncio +async def test_forbidden_covers_content_digest_omits_digest_coverage() -> None: + """Capability with covers_content_digest='forbidden' ⇒ signature must NOT cover content-digest.""" + body = b'{"plan_id":"p1"}' + request = httpx.Request( + method="POST", + url="https://seller.example.com/adcp/create_media_buy", + headers={"Content-Type": "application/json"}, + content=body, + ) + + capability = _capability( + required=["create_media_buy"], + covers=CoversContentDigest.forbidden, + ) + + client = httpx.AsyncClient() + install_signing_event_hook(client, signing=_config(), seller_capability=capability) + [hook] = client.event_hooks["request"] + + with signing_operation("create_media_buy"): + await hook(request) + + assert "Signature" in request.headers + sig_input = request.headers["Signature-Input"] + # The covered-components list lives between parens before the `;` params block. + assert "content-digest" not in sig_input.lower(), sig_input + + def test_requires_exactly_one_of_capability_or_provider() -> None: config = _config() client = httpx.AsyncClient() From aaa86037c816e6c3480d9681348e82aad89d1c6e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 24 Apr 2026 20:06:33 -0400 Subject: [PATCH 3/4] fix(signing): explicit capability annotation for mypy strict-mode Python 3.11 mypy in CI inferred `capability: RequestSigning` from the first assignment branch (`seller_capability is not None`), then rejected the second branch where `capability = await result` produces `RequestSigning | None`. The local mypy run with implicit-reveal inferred the union and let it through. Annotate `capability: RequestSigning | None` at first reference so the union is the explicit ground truth on both branches. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/signing/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/adcp/signing/client.py b/src/adcp/signing/client.py index 8916197f..fcfa43fe 100644 --- a/src/adcp/signing/client.py +++ b/src/adcp/signing/client.py @@ -138,6 +138,7 @@ async def _hook(request: httpx.Request) -> None: if operation is None or operation == "get_adcp_capabilities": return + capability: RequestSigning | None if seller_capability is not None: capability = seller_capability else: From 5ff176961cbcfff843f0dc13d2dd28446bde8c69 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 24 Apr 2026 20:13:37 -0400 Subject: [PATCH 4/4] =?UTF-8?q?chore(deps):=20pin=20a2a-sdk<1.0.2=20?= =?UTF-8?q?=E2=80=94=20upstream=20protobuf-incompat=20regression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a2a-sdk 1.0.2 (released 2026-04-24) calls `field.label` on the protobuf C-extension FieldDescriptor in `proto_utils.py:217`. Newer protobuf releases (current 7.34.1) no longer expose `label` on the upb-backed FieldDescriptor, so every A2A test that exercises proto_utils fails with: AttributeError: 'google._upb._message.FieldDescriptor' object has no attribute 'label' CI started installing 1.0.2 transitively the moment it dropped, which took #272 red on Python 3.11 (12 A2A tests failing). The signing work in this PR is unaffected — failures are 100% pre-existing A2A integration tests broken by the dependency bump. Pin <1.0.2 with an explanatory comment so the next maintainer can lift the bound once a2a-sdk ships a fix. This is an unrelated infrastructure pin bundled into this PR purely to unblock CI; fine to revert once the upstream lands a release that handles the new protobuf FieldDescriptor surface. Verified locally: full 2191-test suite passes with a2a-sdk 1.0.1, fails on a2a-sdk 1.0.2. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b4ca7730..acf5c498 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,13 @@ dependencies = [ # server-side JSON-RPC route factory, which dual-serves the AgentCard # and preserves 0.3 JSON shapes outbound for existing 0.3 clients. # No coordinated buyer migration needed. - "a2a-sdk>=1.0.1,<2.0", + # Pinned <1.0.2 due to an upstream regression: a2a-sdk 1.0.2 calls + # `field.label` on the protobuf C-extension FieldDescriptor, which the + # current protobuf releases no longer expose. Surfaces as + # `'google._upb._message.FieldDescriptor' object has no attribute 'label'` + # on every A2A test that exercises proto_utils. Lift the upper bound + # once a2a-sdk ships a fix. + "a2a-sdk>=1.0.1,<1.0.2", "sse-starlette>=2.0", # required by a2a-sdk v0.3 compat adapter "mcp>=1.23.2", "email-validator>=2.0.0",