From 0f84c9da811d1fcd5aebf56191d40271d4f84e22 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 8 Nov 2026 13:49:26 +0000 Subject: [PATCH] docs(signing): verify against a counterparty's several agents VerifyOptions.agent_url read like an expected-agent check, which led an adopter to model one agent per principal (#1214, prebid/salesagent#2276). It is attribution only: the verifier copies it onto VerifiedSigner and never compares it. Which agent signed follows from the key resolver. Document that on the field, and add a recipe to the request-signing guide for accepting any of a counterparty's agents in one verification: - framework verification maps every agent in one SignerKeyResolver and each agent URL to the same principal; - direct verify_request_signature callers wrap the resolver as the framework does, narrow it to the principal's canonicalized agent set at step 7 (no nonce claimed for a key outside it), and take the agent from the key resolved in that call, never from shared "last matched" state; - the 3.3 path binds each origin to one principal through OriginBindings. The RFC 9421 recipe is marked deprecated: AdCP 3.3 deprecates the profile and 4.0 removes it with brand.json agents[] and jwks_uri (adcontextprotocol/adcp#8664). No API changes. Refs #1214 Co-Authored-By: Claude Opus 5.5 --- docs/agent-identity-3-3-migration.md | 2 + docs/request-signing-migration.md | 115 +++++++++++++++++++++++++++ src/adcp/signing/verifier.py | 7 ++ 3 files changed, 124 insertions(+) diff --git a/docs/agent-identity-3-3-migration.md b/docs/agent-identity-3-3-migration.md index 394ca8a144..a078ed76b7 100644 --- a/docs/agent-identity-3-3-migration.md +++ b/docs/agent-identity-3-3-migration.md @@ -149,6 +149,8 @@ await records.bind(verified_signer, principal_id="principal_123", bindings=bindi The signer must carry a current revocation state (`verified_signer.revocation == "current"`; the framework's verification sets it), and the record must currently list exactly one agent at the verified origin with `adcp:buying`. Migrating an existing RFC 9421 principal requires `existing_rfc9421_principal=True, migration_approved=True` and a seller-side approval decision. The same origin cannot be rebound to another principal. Once bound, legacy signatures for that origin are rejected (no downgrade). Persist bindings and the origin's commercial identity in the seller's registry; the registry still governs suspension, billing and account access. +A principal may own several origins. When one counterparty runs several agents (staging and production, regional stacks, an endpoint migration), each agent is its own origin. Bind each one, as it first signs, to the same `principal_id`. You don't need a second principal. The RFC 9421 equivalent is in [Verify against a counterparty's several agents](request-signing-migration.md#verify-against-a-counterpartys-several-agents). + The framework preserves the verified origin as caller identity and exposes the binding's principal ID in `context.metadata["adcp.auth_info"].extra["principal_id"]`. The verified signer is read per request from the request scope, so a stateful MCP session never attributes one request's signer to another. ### Delisting and trust-record outages diff --git a/docs/request-signing-migration.md b/docs/request-signing-migration.md index 1445290515..517db42cfd 100644 --- a/docs/request-signing-migration.md +++ b/docs/request-signing-migration.md @@ -159,6 +159,121 @@ What the framework does: The staged rollout below applies unchanged — each step is a change to the `request_signing` block you advertise, and the framework enforces it. The hand-wired snippets are for servers that don't use `serve()`. +### Verify against a counterparty's several agents + +> **Deprecated profile.** The recipe in this section uses the RFC 9421 +> profile, which AdCP 3.3 deprecates. It works through 3.x and is removed in +> 4.0, together with brand.json `agents[]` and `jwks_uri` +> ([adcontextprotocol/adcp#8664](https://github.com/adcontextprotocol/adcp/issues/8664)). +> For new integrations, use the 3.3 path at the end of this section. + +One counterparty often runs several agents: staging and production, two +regional stacks, an old and a new endpoint during a migration. One +verification can accept any of them, with one key resolution and one nonce +claim. You don't need to call the verifier once per candidate URL. + +`VerifyOptions.agent_url` is attribution only. The verifier copies it onto +`VerifiedSigner.agent_url` and never compares it with anything, so leaving it +`None` skips no check. Which agent signed follows from the key: the resolver +returns keys only for the agents it maps, and each resolved key names its +agent. + +**With framework verification**, map every agent you onboarded in one +`SignerKeyResolver`, such as `StaticSignerKeys` or `JwksUriSignerKeys`. Then map +each agent URL to the same principal in your `BuyerAgentRegistry`. The +framework takes `caller_identity` from the key that verified, so a signature +from any of the counterparty's agents resolves to that one principal. + +**When you call `verify_request_signature` yourself**, for example to check a +signature against a principal you already identified from a bearer token, +adapt the resolver the way the framework does, and narrow it to that +principal's agents: + +```python +import time +from dataclasses import replace + +import anyio.from_thread +import anyio.to_thread + +from adcp.signing import ( + ResolvedSignerKey, + SignerKeyResolver, + VerifiedSigner, + VerifierCapability, + VerifyOptions, + canonicalize_target_uri, + verify_request_signature, +) + + +async def verify_counterparty_request( + request, # a Starlette request + *, + signer_keys: SignerKeyResolver, # every onboarded agent, built once + counterparty_agents: frozenset[str], # this principal's agents, canonicalized + capability: VerifierCapability, + operation: str, + replay_store, # shared, built once +) -> VerifiedSigner: + body = await request.body() + resolved: dict[str, ResolvedSignerKey] = {} # this call's state only + + def jwks_resolver(keyid: str) -> dict | None: + # The verifier calls this at checklist step 7, on the worker thread. + key = anyio.from_thread.run(signer_keys, keyid) + if key is None or canonicalize_target_uri(key.agent_url) not in counterparty_agents: + return None # request_signature_key_unknown; no nonce is claimed + resolved[keyid] = key + return dict(key.jwk) + + options = VerifyOptions( + now=time.time(), + capability=capability, + operation=operation, + jwks_resolver=jwks_resolver, + replay_store=replay_store, + ) + signer = await anyio.to_thread.run_sync( + lambda: verify_request_signature( + method=request.method, + url=str(request.url), + headers=dict(request.headers), + body=body, + options=options, + raw_headers=request.headers.raw, + ) + ) + return replace(signer, agent_url=resolved[signer.key_id].agent_url) +``` + +Build `counterparty_agents` with `canonicalize_target_uri` too, so spelling +differences such as a default port or letter case can't cause a mismatch. A key +from an agent outside the set is unknown at step 7: it reaches neither +signature verification nor the replay store. + +Keep the agent per call, as `resolved` does here. Never keep a "last matched +agent" on a resolver that requests share: under concurrency one request would +be attributed to another request's agent. `SignerKeyResolver` returns the agent +with the key for this reason. + +A `kid` must name one agent. `StaticSignerKeys` refuses a `kid` that two agents +publish when you construct it. `JwksUriSignerKeys` treats such a `kid` as +unknown and logs it. + +Once you know the agent, check what it may do with that agent URL. For example, +call `BrandJsonAuthorizationResolver.check(agent_url=signer.agent_url, ...)`. It +takes one URL because the signature named exactly one agent, and it reports +that agent as `matched_agent_url`. + +**On the 3.3 path**, identity is an origin, with one agent per origin +(DR-0023). `DiscoveredSignerKeys` resolves any signer from the origin its +covered `Signature-Agent` names, and `OriginBindings` lets one principal own +several origins, while each origin maps to only one principal. To accept a +counterparty's staging and production agents, bind each verified origin to the +same `principal_id`. See [Identity-established callers and +binding](agent-identity-3-3-migration.md#identity-established-callers-and-binding). + ## 2. Staged enforcement (per operation) Never flip an operation straight from unsigned to required. Stage it through three stops. diff --git a/src/adcp/signing/verifier.py b/src/adcp/signing/verifier.py index 8e572fd6d9..6f9389168a 100644 --- a/src/adcp/signing/verifier.py +++ b/src/adcp/signing/verifier.py @@ -216,6 +216,13 @@ class VerifyOptions: #: single-value ``expected_adcp_use`` behavior. accepted_adcp_uses: frozenset[str] = frozenset() allowed_algs: frozenset[str] = ALLOWED_ALGS + #: Attribution only: copied onto :attr:`VerifiedSigner.agent_url` and never + #: compared with anything, so leaving it ``None`` skips no check. Which agent + #: signed follows from ``jwks_resolver``, which returns keys only for the + #: agents it maps. To accept any of a counterparty's agents in one + #: verification, resolve across them with a :class:`~adcp.signing.SignerKeyResolver` + #: and take the agent from the key it resolves; see "Verify against a + #: counterparty's several agents" in ``docs/request-signing-migration.md``. agent_url: str | None = None operator_brand_json: dict[str, Any] | None = None #: Trusted, negotiated AdCP signing profile. 3.0/3.1 accept the historical