Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 101 additions & 32 deletions docs/agent-resolution-33.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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:

Expand Down
39 changes: 36 additions & 3 deletions docs/request-signing-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 24 additions & 6 deletions src/adcp/signing/brand_jwks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading