|
| 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. |
0 commit comments