From 305ff5cab5aca7aa2f4a7f84d899381474450a27 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 7 Oct 2026 00:09:28 +0000 Subject: [PATCH] docs(signing): clarify signing-agent operator key discovery --- docs/agent-resolution-33.md | 133 +++++++++++++++++++++++------- docs/request-signing-migration.md | 39 ++++++++- src/adcp/signing/brand_jwks.py | 30 +++++-- 3 files changed, 161 insertions(+), 41 deletions(-) diff --git a/docs/agent-resolution-33.md b/docs/agent-resolution-33.md index d8dce7c59..964262173 100644 --- a/docs/agent-resolution-33.md +++ b/docs/agent-resolution-33.md @@ -5,27 +5,116 @@ at commit `175bcd3b023244aeb97dbdcce8fed674ebd61302`. The protocol change is still under review. Historical bundled 3.1/3.2 schemas retain their published verifier constraints; these helpers implement the new canonical matching rules. -Agent URLs are the primary selector. Scheme/host case, default ports, dot -segments, and percent-encoded unreserved characters normalize; trailing slashes -and tenant paths remain significant. Optional type and id selectors only narrow -a URL match. Duplicate canonical matches within one collection fail, including -duplicates with identical ids. House Portfolio operator discovery scans -`house.agents[]` and all inline `brands[].agents[]`; repeated attestations across -collections count once when their type and resolved JWKS source agree. +> **Signing agent's operator:** The operator here publishes the `brand.json` +> listing the signing agent and its keys. Discover that record from the agent's +> `get_adcp_capabilities` response (`identity.brand_json_url`), or configure it +> at onboarding. It never means the `operator` or `brand` in the request's account. +> These are different roles even when their domains coincide. Do not derive key +> discovery from request-body fields. + +## Discover from the agent URL + +Start with the signing agent's URL from your trusted counterparty configuration. +For request verification, use `verify_from_agent_url`; for key discovery alone, +use `async_resolve_agent`: + +```python +from adcp.signing import InMemoryReplayStore, async_resolve_agent, verify_from_agent_url + +# Single-process example: create once outside the request handler. +# Across replicas, use a shared replay-store implementation instead. +replay_store = InMemoryReplayStore() +buyer_agent_url = "https://buying-agent.example.com/mcp" # onboarded agent URL + +# In your request handler: +verified = await verify_from_agent_url( + request, + agent_url=buyer_agent_url, + operation="create_media_buy", + replay_store=replay_store, +) + +# Alternatively, discover keys without verifying a request: +resolution = await async_resolve_agent(buyer_agent_url) +``` + +These helpers invoke `get_adcp_capabilities` via MCP by default, or A2A with +`protocol="a2a"`. They use the advertised `identity.brand_json_url`, enforce +origin binding, and check every declared key origin. Cross-domain origin binding +accepts `authorized_operators` only on a House Portfolio; its account-level +brand/country scopes do not restrict key discovery. Discovery does not reuse +onboarding key mappings, so each call reconfirms the advertised operator record. + +[Discovery step 7](https://github.com/adcontextprotocol/adcp/blob/175bcd3b023244aeb97dbdcce8fed674ebd61302/docs/building/by-layer/L1/security.mdx#L1331) +checks every advertised `identity.key_origins` purpose against the selected +agent's JWKS source, including purposes other than the signature being verified. +A different host for any purpose rejects discovery with +`request_signature_key_origin_mismatch`, even when the active purpose matches. +The SDK follows this explicit all-purpose rule. The draft's separate guidance +on origin separation remains in tension with that rule pending clarification. + +`verify_from_agent_url` always takes the signer identity (`VerifiedSigner.agent_url`) +and the replay namespace from the URL the caller passed. A brand.json entry's `url` +never supplies them. Discovery selects the entry by that URL. A resolution whose +entry names a different URL fails closed with +`request_signature_agent_not_in_brand_json`. A record that lists a victim's URL +with its own keys therefore cannot verify as the victim. + +## Verify onboarded counterparties -Pass `agent_url` to direct resolver construction and to the shared resolver -builder: +For framework verification with `serve()`, configure `JwksUriSignerKeys` or +`StaticSignerKeys` with mappings keyed by the signing agent's URL. The endpoint +or public keys must come from trusted onboarding, rather than the account: + +```python +from adcp.signing import JwksUriSignerKeys, StaticSignerKeys + +signer_keys = JwksUriSignerKeys({ + "https://buying-agent.example.com/mcp": "https://buying-agent.example.com/.well-known/jwks.json", +}) + +# Alternative for public keys exchanged at onboarding: +signer_keys = StaticSignerKeys({ + "https://buying-agent.example.com/mcp": {"keys": [buyer_public_jwk]}, +}) +``` + +These resolvers map a request's `keyid` to a configured agent and its key; they +do not discover unknown signers. When onboarding mappings replace discovery, +the [3.3 draft's shortcut rules](https://github.com/adcontextprotocol/adcp/blob/175bcd3b023244aeb97dbdcce8fed674ebd61302/docs/building/by-layer/L1/security.mdx#L1319) +require callers to establish or reconfirm the agent-to-operator mapping against +the agent's `identity.brand_json_url`, then reconfirm it within the brand.json +cache lifetime. These mapping resolvers do not perform that discovery or refresh +automatically. See the +[framework verification guide](request-signing-migration.md#framework-verification) +for wiring them into `serve()`. + +## Lower-level resolver construction + +Construct `BrandJsonJwksResolver` directly only when you already trust the +applicable `brand.json` record, for example from onboarding or agent discovery. +Direct construction does not perform the capabilities-based origin binding +above. Pass `agent_url` to direct resolver construction and to the shared +resolver builder: ```python from adcp.signing import BrandJsonJwksResolver resolver = BrandJsonJwksResolver( - "https://operator.example.com/brand.json", - agent_url="https://operator.example.com/sales", + "https://signing-agent-operator.example.com/brand.json", # trusted operator record + agent_url="https://signing-agent-operator.example.com/sales", agent_type="sales", ) ``` +Agent URLs are the primary selector. Scheme/host case, default ports, dot +segments, and percent-encoded unreserved characters normalize; trailing slashes +and tenant paths remain significant. Optional type and id selectors only narrow +a URL match. Duplicate canonical matches within one collection fail, including +duplicates with identical ids. House Portfolio operator discovery scans +`house.agents[]` and all inline `brands[].agents[]`; repeated attestations across +collections count once when their type and resolved JWKS source agree. + Omitting `agent_url` is deprecated in 8.1. It emits a `DeprecationWarning` and is rejected in the next major release. Without it, `agent_type` is required and selection falls back to the 8.0 role-based scope: `brands[brand_id].agents` @@ -46,28 +135,8 @@ Direct resolvers use operator-style collection selection, including all sibling collections in a House Portfolio unless `brand_id` selects one inline brand. For a relying-party record, select the surface's applicable collection explicitly; use `resolve_governance_jwks` for governance rather than an unscoped direct resolver. -For operator discovery, `async_resolve_agent` and `verify_from_agent_url` invoke -`get_adcp_capabilities` via MCP by default, or A2A with `protocol="a2a"`. They use -the advertised `identity.brand_json_url`, enforce origin binding, and check every -declared key origin. Cross-domain origin binding accepts `authorized_operators` -only on a House Portfolio; its account-level brand/country scopes do not restrict -key discovery. Discovery does not reuse onboarding mappings, so each call -reconfirms the advertised operator record. -[Discovery step 7](https://github.com/adcontextprotocol/adcp/blob/175bcd3b023244aeb97dbdcce8fed674ebd61302/docs/building/by-layer/L1/security.mdx#L1331) -checks every advertised `identity.key_origins` purpose against the selected -agent's JWKS source, including purposes other than the signature being verified. -A different host for any purpose rejects discovery with -`request_signature_key_origin_mismatch`, even when the active purpose matches. -The SDK follows this explicit all-purpose rule. The draft's separate guidance -on origin separation remains in tension with that rule pending clarification. - -`verify_from_agent_url` always takes the signer identity (`VerifiedSigner.agent_url`) -and the replay namespace from the URL the caller passed. A brand.json entry's `url` -never supplies them. Discovery selects the entry by that URL. A resolution whose -entry names a different URL fails closed with -`request_signature_agent_not_in_brand_json`. A record that lists a victim's URL -with its own keys therefore cannot verify as the victim. +## Webhooks and governance For webhooks, use the asynchronous discovery helper: diff --git a/docs/request-signing-migration.md b/docs/request-signing-migration.md index f7848065b..01b9127c3 100644 --- a/docs/request-signing-migration.md +++ b/docs/request-signing-migration.md @@ -4,6 +4,13 @@ Rolling out RFC 9421 request signing against an existing AdCP integration is a t This guide covers the operator-facing mechanics. Spec reference: [Signed Requests (Transport Layer)](https://adcontextprotocol.org/docs/building/implementation/security#signed-requests-transport-layer). +> **Signing agent's operator:** For key discovery, the operator publishes the +> `brand.json` listing the signing agent and its keys. Discover it from the +> agent's `get_adcp_capabilities` response (`identity.brand_json_url`), or +> configure it at onboarding. It never means the `operator` or `brand` in the +> request's account. These are different roles even when their domains coincide. +> Do not derive key discovery from request-body fields. + 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. `ADCPClient` derives the signing wire profile from its trusted @@ -65,6 +72,31 @@ request_signing = RequestSigning( ) ``` +### Choose verification keys from the signing agent + +Start from the signing agent's URL in your trusted counterparty configuration. +Use `await verify_from_agent_url(request, agent_url=buyer_agent_url, +operation="create_media_buy", replay_store=replay_store)` to discover its +operator record and verify a request, or `await async_resolve_agent(buyer_agent_url)` +for discovery alone. These helpers read `identity.brand_json_url` from +`get_adcp_capabilities` and enforce agent/operator origin binding and declared +key origins. See the [agent discovery examples](agent-resolution-33.md#discover-from-the-agent-url). + +For onboarded counterparties using framework verification, configure +`JwksUriSignerKeys({agent_url: jwks_uri})` or +`StaticSignerKeys({agent_url: {"keys": [public_jwk]}})` with the signing agent's +URL as the key and endpoints or public keys obtained at onboarding. +When these mappings replace discovery under the 3.3 profile, establish or +reconfirm the agent-to-operator mapping against the agent's +`identity.brand_json_url` and reconfirm it within the brand.json cache lifetime. +These resolvers do not perform that discovery or refresh automatically; see the +[onboarding guidance](agent-resolution-33.md#verify-onboarded-counterparties). + +Direct `BrandJsonJwksResolver` construction is a lower-level option for a +`brand.json` record you already trust. It does not perform capabilities-based +origin binding. Never construct its URL from `account.operator` or +`account.brand`; see [direct resolver construction](agent-resolution-33.md#lower-level-resolver-construction). + ### Framework verification A seller built on `adcp.server.serve` / `adcp.decisioning.serve` does not need to hand-wire the verifier. Opt in, and the framework verifies every JSON-RPC POST on the MCP and A2A legs **before dispatch**, enforcing the `request_signing` block you advertise: @@ -76,7 +108,7 @@ from adcp.signing import JwksUriSignerKeys, PgReplayStore serve( platform, # platform.capabilities.request_signing is the enforced policy signer_keys=JwksUriSignerKeys({ - "https://buyer.example.com": "https://buyer.example.com/.well-known/jwks.json", + "https://buying-agent.example.com/mcp": "https://buying-agent.example.com/.well-known/jwks.json", }), signature_replay_store=PgReplayStore(pool), # shared across replicas buyer_agent_registry=registry, @@ -131,7 +163,7 @@ Never flip an operation straight from unsigned to required. Stage it through thr 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: +The Python middleware stays permissive — wire `replay_store` and `jwks_resolver`, but leave `required_for` empty. The lower-level example below uses a signing agent's JWKS endpoint obtained at onboarding: ```python from adcp.signing import ( @@ -142,7 +174,7 @@ from adcp.signing import ( verify_starlette_request, ) -jwks_resolver = CachingJwksResolver(jwks_uri="https://buyer.example.com/.well-known/jwks.json") +jwks_resolver = CachingJwksResolver(jwks_uri="https://buying-agent.example.com/.well-known/jwks.json") # Build the replay store ONCE and share it. `VerifyOptions` is per request # (`now` changes), and omitting `replay_store` gives each options instance @@ -261,6 +293,7 @@ Ordering is different — the old kid must stop being trusted *before* anything ## 4. Common pitfalls +- **Resolving keys from the account's operator or brand.** `account.operator` names the entity operating the account, such as an agency or an advertiser operating directly; `account.brand` identifies the advertiser. Neither selects the signing agent's keys, even when the domains coincide. Building a `BrandJsonJwksResolver` URL from these fields can resolve an unrelated record and reject requests with `request_signature_jwks_untrusted`. Use agent discovery or key mappings obtained at onboarding instead. - **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. diff --git a/src/adcp/signing/brand_jwks.py b/src/adcp/signing/brand_jwks.py index 9ccd9dcb4..752780786 100644 --- a/src/adcp/signing/brand_jwks.py +++ b/src/adcp/signing/brand_jwks.py @@ -9,15 +9,20 @@ **Why this exists.** The seller's verifier never trusts an ``agent_url/.well-known/jwks.json`` directly — that would let any agent -self-attest its own keys. Per ADCP, keys root through the brand: the -brand's ``/.well-known/brand.json`` lists each authorized agent and -its ``jwks_uri``, operator-attested. This resolver walks brand.json, +self-attest its own keys. Per ADCP, keys root through the signing agent's +operator: its ``brand.json`` lists each authorized agent and its +``jwks_uri``, operator-attested. This role is distinct from the ``operator`` or +``brand`` in the request's account, even when their domains coincide. Do not +derive key discovery from request-body fields. This resolver walks brand.json, picks the right agent entry, and delegates JWK fetch to the inner JWKS resolver pinned to that ``jwks_uri``. -For discovery from an agent URL, use ``async_resolve_agent``: it binds the -operator record to capabilities and checks the operator origin. Direct -construction is for a relying-party record the caller already trusts. +For discovery from an agent URL, use ``verify_from_agent_url`` or +``async_resolve_agent``: they discover the signing agent's operator record from +``get_adcp_capabilities``' ``identity.brand_json_url`` and enforce origin binding. +For onboarded counterparties, use ``JwksUriSignerKeys`` or ``StaticSignerKeys`` +keyed by agent URL. Direct construction is the lower-level option for an +applicable record the caller already trusts, such as one configured at onboarding. Hand the resulting instance to ``verify_request_signature`` (or ``verify_starlette_request``) as the ``jwks`` dependency. The @@ -367,6 +372,19 @@ async def _do_refresh(self) -> _BrandJsonSnapshot: class BrandJsonJwksResolver: """JWKS resolver backed by a sender's ``brand.json``. + The operator is the signing agent's operator, whose ``brand.json`` lists + the agent and its keys. Discover it from the agent's + ``get_adcp_capabilities`` response (``identity.brand_json_url``), or + configure it at onboarding. It never means the ``operator`` or ``brand`` + in the request's account. These are different roles even when their domains + coincide. Do not derive key discovery from request-body fields. + + Prefer ``verify_from_agent_url`` / ``async_resolve_agent`` for discovery, + or ``JwksUriSignerKeys`` / ``StaticSignerKeys`` keyed by agent URL for + onboarded counterparties. Direct construction is a lower-level option + for an applicable record you already trust; it does not perform + capabilities-based origin binding. + Implements :class:`adcp.signing.AsyncJwksResolver` (callable as ``await resolver(kid)``). Construct one per counterparty (or per ``brand.json`` URL + agent selector tuple) and hand it to the