Skip to content

Commit 52019b8

Browse files
bokelleyclaude
andauthored
feat(signing): default replay store, signed-fetch preset, migration guide (#272)
* feat(signing): default replay store, signed-fetch preset, migration guide 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) <noreply@anthropic.com> * fix(signing): use inspect.isawaitable for capability_provider sync/async detect `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: #272 Flagged by python-expert + code-reviewer subagents. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * chore(deps): pin a2a-sdk<1.0.2 — upstream protobuf-incompat regression 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8fcc5ee commit 52019b8

8 files changed

Lines changed: 986 additions & 3 deletions

File tree

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,43 @@ signed = sign_request(
10911091
httpx.post(url, content=body, headers={**headers, **signed.as_dict()})
10921092
```
10931093

1094+
### Auto-sign on `ADCPClient`
1095+
1096+
The high-level client wires the signing event hook for you when you pass a `SigningConfig`:
1097+
1098+
```python
1099+
from adcp.client import ADCPClient
1100+
from adcp.signing import SigningConfig, load_private_key_pem
1101+
1102+
signing = SigningConfig(
1103+
private_key=load_private_key_pem(open("signing-key.pem", "rb").read()),
1104+
key_id="my-agent-2026",
1105+
)
1106+
1107+
client = ADCPClient(agent_config, signing=signing)
1108+
# Outbound calls are signed automatically per the seller's request_signing capability.
1109+
```
1110+
1111+
### Auto-sign on raw httpx (no ADCPClient)
1112+
1113+
For adapters that integrate against a seller via raw `httpx`, install the same hook on your own client:
1114+
1115+
```python
1116+
import httpx
1117+
from adcp.signing import SigningConfig, install_signing_event_hook, signing_operation
1118+
1119+
client = httpx.AsyncClient()
1120+
install_signing_event_hook(
1121+
client,
1122+
signing=signing,
1123+
seller_capability=seller_caps.request_signing,
1124+
)
1125+
1126+
async with client:
1127+
with signing_operation("create_media_buy"):
1128+
resp = await client.post("https://seller.example.com/mcp", json=payload)
1129+
```
1130+
10941131
### Verify incoming requests (FastAPI)
10951132

10961133
```python
@@ -1116,6 +1153,9 @@ async def create_media_buy(request: Request):
11161153
operation="create_media_buy",
11171154
jwks_resolver=jwks,
11181155
)
1156+
# `replay_store` defaults to a fresh InMemoryReplayStore when omitted.
1157+
# Wire an explicit shared store (PgReplayStore via [pg] extra, or your
1158+
# own ReplayStore Protocol implementation) for multi-replica deployments.
11191159
try:
11201160
signer = await verify_starlette_request(request, options=options)
11211161
except SignatureVerificationError as exc:
@@ -1130,6 +1170,10 @@ async def create_media_buy(request: Request):
11301170

11311171
Flask has an equivalent synchronous helper `verify_flask_request`.
11321172

1173+
### Migration & rollout
1174+
1175+
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.
1176+
11331177
### Conformance
11341178

11351179
The verifier passes all 28 AdCP request-signing conformance vectors (8 positive,

docs/request-signing-migration.md

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
# Migrating to signed AdCP requests
2+
3+
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.
4+
5+
This guide covers the operator-facing mechanics. Spec reference: [Signed Requests (Transport Layer)](https://adcontextprotocol.org/docs/building/implementation/security#signed-requests-transport-layer).
6+
7+
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.
8+
9+
## 1. Bootstrap
10+
11+
One-time work to make an agent able to sign (as a buyer) or verify (as a seller).
12+
13+
### Generate and publish a key
14+
15+
```bash
16+
adcp-keygen --alg ed25519 --kid my-agent-2026-01 --purpose request-signing \
17+
--out signing.pem
18+
```
19+
20+
Or programmatically — the same spine the CLI uses:
21+
22+
```python
23+
from adcp.signing import generate_signing_keypair
24+
25+
pem, public_jwk = generate_signing_keypair(
26+
alg="ed25519",
27+
kid="my-agent-2026-01",
28+
purpose="request-signing",
29+
)
30+
```
31+
32+
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.
33+
34+
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`:
35+
36+
```json
37+
{ "keys": [ { "kid": "my-agent-2026-01", "kty": "OKP", "crv": "Ed25519", "x": "...", "use": "sig", "key_ops": ["verify"], "adcp_use": "request-signing" } ] }
38+
```
39+
40+
Hold the PEM in your secret store — environment variable, GCP Secret Manager, AWS Secrets Manager. Never commit it.
41+
42+
### Advertise signing on `get_adcp_capabilities`
43+
44+
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.
45+
46+
```python
47+
from adcp.types.generated_poc.protocol.get_adcp_capabilities_response import (
48+
CoversContentDigest,
49+
RequestSigning,
50+
)
51+
52+
request_signing = RequestSigning(
53+
supported=True,
54+
covers_content_digest=CoversContentDigest.either,
55+
required_for=[],
56+
warn_for=[],
57+
supported_for=[],
58+
)
59+
```
60+
61+
## 2. Staged enforcement (per operation)
62+
63+
Never flip an operation straight from unsigned to required. Stage it through three stops.
64+
65+
### Step A — `supported_for`
66+
67+
Add the operation to `supported_for`. Counterparties **MAY** sign; your verifier **MUST** accept signed requests but does not yet reject unsigned ones.
68+
69+
The Python middleware stays permissive — wire `replay_store` and `jwks_resolver`, but leave `required_for` empty:
70+
71+
```python
72+
from adcp.signing import (
73+
CachingJwksResolver,
74+
InMemoryReplayStore,
75+
VerifierCapability,
76+
VerifyOptions,
77+
verify_starlette_request,
78+
)
79+
80+
jwks_resolver = CachingJwksResolver(jwks_uri="https://buyer.example.com/.well-known/jwks.json")
81+
82+
# `replay_store` defaults to a fresh InMemoryReplayStore — single-process
83+
# deployments don't need to wire one explicitly. Pass an explicit shared
84+
# store (Redis-backed, custom Postgres) for multi-replica setups.
85+
options = VerifyOptions(
86+
now=time.time(),
87+
capability=VerifierCapability(
88+
covers_content_digest="either",
89+
required_for=frozenset(), # nothing rejected yet
90+
supported_for=frozenset({"create_media_buy"}),
91+
),
92+
operation="create_media_buy",
93+
jwks_resolver=jwks_resolver,
94+
)
95+
96+
verified = await verify_starlette_request(request, options=options)
97+
# verified.key_id is the buyer's signing identity.
98+
```
99+
100+
Success signal: signed requests arrive, `verify_starlette_request` returns a `VerifiedSigner` rather than raising.
101+
102+
### Step B — `warn_for`
103+
104+
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.
105+
106+
The spec calls this shadow mode. Python doesn't have a built-in `observe_only` flag yet — approximate by catching `SignatureVerificationError` and logging:
107+
108+
```python
109+
from adcp.signing import SignatureVerificationError
110+
111+
try:
112+
verified = await verify_starlette_request(request, options=options)
113+
except SignatureVerificationError as exc:
114+
# WARNING: step-B shim only. Delete before enabling required_for in step C —
115+
# this turns every required-signed op into an unsigned op.
116+
logger.warning(
117+
"signature would reject", extra={"code": exc.code, "step": exc.step}
118+
)
119+
verified = None # fall through unsigned
120+
```
121+
122+
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.
123+
124+
### Step C — `required_for`
125+
126+
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:
127+
128+
```python
129+
options = VerifyOptions(
130+
now=time.time(),
131+
capability=VerifierCapability(
132+
covers_content_digest="either",
133+
required_for=frozenset({"create_media_buy"}),
134+
),
135+
operation="create_media_buy",
136+
jwks_resolver=jwks_resolver,
137+
)
138+
139+
# Remove the step-B shim. Let SignatureVerificationError propagate to your
140+
# 401 response builder.
141+
try:
142+
verified = await verify_starlette_request(request, options=options)
143+
except SignatureVerificationError as exc:
144+
return JSONResponse(
145+
status_code=401,
146+
headers=unauthorized_response_headers(exc),
147+
content={"error": exc.code, "message": str(exc)},
148+
)
149+
```
150+
151+
A rollout later, once you've confirmed no `request_signature_digest_mismatch` in step-B logs, tighten to `covers_content_digest="required"`:
152+
153+
```python
154+
capability=VerifierCapability(
155+
covers_content_digest="required",
156+
required_for=frozenset({"create_media_buy"}),
157+
)
158+
```
159+
160+
### Rollback from step C
161+
162+
If production breaks after flipping `required_for`:
163+
164+
1. Revert the verifier config — drop the operation from `VerifierCapability.required_for` (and from `covers_content_digest="required"` if set). Redeploy.
165+
2. Do **not** touch `jwks_uri` or your revocation list. Counterparties that are already signing correctly will keep doing so, harmlessly.
166+
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.
167+
168+
Returning to step B's shadow mode is the safe resting state while you diagnose.
169+
170+
## 3. Key rotation
171+
172+
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.
173+
174+
### Routine rotation
175+
176+
Two JWKS publishes plus a signer cutover:
177+
178+
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.
179+
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`.
180+
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.
181+
4. **Remove the old kid.** Publish a JWKS with only the new kid.
182+
183+
### Compromise rotation
184+
185+
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:
186+
187+
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.
188+
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.
189+
3. **Cut over the signer** to the new kid on every instance.
190+
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).
191+
192+
## 4. Common pitfalls
193+
194+
- **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.
195+
- **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.
196+
- **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.
197+
- **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.
198+
- **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.
199+
- **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.
200+
- **`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.
201+
202+
## 5. Verification checklist before enforcing
203+
204+
- [ ] 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`.
205+
- [ ] `jwks_uri` returns your current kid (and only your current kid, once rotation is complete).
206+
- [ ] `get_adcp_capabilities.request_signing.required_for` matches `VerifierCapability.required_for`.
207+
- [ ] Revocation source is configured (`revocation_checker` or `revocation_list` is non-None) if you've published any revocations — otherwise revoked keys are accepted silently.
208+
- [ ] Replay store strategy is explicit: default `InMemoryReplayStore` for single-instance, `PgReplayStore` (or equivalent shared store) for multi-instance.
209+
- [ ] Logs from step B show zero unexpected failures over at least one full deploy cycle of your slowest counterparty.
210+
- [ ] No step-B shim (`try/except SignatureVerificationError → log → continue`) remains in the verifier wiring.
211+
- [ ] Redirect-following is disabled on every signing client.
212+
- [ ] Clock sync monitoring is in place on signer and verifier hosts.
213+
214+
## Related
215+
216+
- [`adcp.signing.install_signing_event_hook`](../src/adcp/signing/client.py) — buyer-side preset for adapters not using `ADCPClient`.
217+
- [`ADCPClient(signing=...)`](../src/adcp/client.py) — the higher-level client integration; signing is wired automatically once you pass a `SigningConfig`.
218+
- [adcp-go's MIGRATION.md](https://github.com/adcontextprotocol/adcp-go/blob/main/adcp/signing/MIGRATION.md) — Go sibling guide.
219+
- [AdCP signing guide (docs PR)](https://github.com/adcontextprotocol/adcp/pull/3064) — the cross-language end-user walkthrough.

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@ dependencies = [
4242
# server-side JSON-RPC route factory, which dual-serves the AgentCard
4343
# and preserves 0.3 JSON shapes outbound for existing 0.3 clients.
4444
# No coordinated buyer migration needed.
45-
"a2a-sdk>=1.0.1,<2.0",
45+
# Pinned <1.0.2 due to an upstream regression: a2a-sdk 1.0.2 calls
46+
# `field.label` on the protobuf C-extension FieldDescriptor, which the
47+
# current protobuf releases no longer expose. Surfaces as
48+
# `'google._upb._message.FieldDescriptor' object has no attribute 'label'`
49+
# on every A2A test that exercises proto_utils. Lift the upper bound
50+
# once a2a-sdk ships a fix.
51+
"a2a-sdk>=1.0.1,<1.0.2",
4652
"sse-starlette>=2.0", # required by a2a-sdk v0.3 compat adapter
4753
"mcp>=1.23.2",
4854
"email-validator>=2.0.0",

src/adcp/signing/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@
9999
canonicalize_target_uri,
100100
parse_signature_input_header,
101101
)
102+
from adcp.signing.client import (
103+
CapabilityProvider,
104+
install_signing_event_hook,
105+
signing_operation,
106+
)
102107
from adcp.signing.constants import (
103108
DEFAULT_EXPIRES_IN_SECONDS,
104109
DEFAULT_SKEW_SECONDS,
@@ -245,6 +250,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
245250
"AsyncRevocationListFetcher",
246251
"CachingJwksResolver",
247252
"CachingRevocationChecker",
253+
"CapabilityProvider",
248254
"DEFAULT_EXPIRES_IN_SECONDS",
249255
"DEFAULT_GRACE_MULTIPLIER",
250256
"DEFAULT_SKEW_SECONDS",
@@ -318,6 +324,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
318324
"extract_signature_bytes",
319325
"format_signature_header",
320326
"generate_signing_keypair",
327+
"install_signing_event_hook",
321328
"load_private_key_pem",
322329
"operation_needs_signing",
323330
"parse_signature_input_header",
@@ -326,6 +333,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
326333
"resolve_and_validate_host",
327334
"sign_request",
328335
"sign_signature_base",
336+
"signing_operation",
329337
"unauthorized_response_headers",
330338
"validate_jwks_uri",
331339
"verify_detached_jws",

0 commit comments

Comments
 (0)