Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/psl-bundled-not-pinned.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"adcontextprotocol": patch
---

Public Suffix List (#8409, DR-0037): verifiers compute eTLD+1 with a PSL bundled with the verifier, ICANN and PRIVATE sections, never fetched at runtime, and refreshed through normal dependency updates. AdCP names no reference snapshot, and `security.mdx` no longer says two verifiers on different PSL versions are non-conformant against each other, or names PSL libraries. On the trust-record path, a verifier whose list lacks a newly added PRIVATE suffix fails closed. Spec R3a step 1, the threat table, L2 §Grants in trust records, and the trust.json `grantee` description now say the same.
10 changes: 5 additions & 5 deletions docs/building/by-layer/L1/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1381,7 +1381,7 @@ The `identity.brand_json_url` field on `get_adcp_capabilities` (added in 3.x, se

1. Fetch `A`'s `get_adcp_capabilities` response with SSRF validation per [Webhook URL validation](#webhook-url-validation-ssrf) (HTTPS only — the URL `A` is supplied by the caller and MUST go through the same address-family + private-IP filtering used for webhook callbacks). On unreachable/timeout, reject with `request_signature_capabilities_unreachable`.
2. Read `identity.brand_json_url`. If absent and the request is signed, reject with `request_signature_brand_json_url_missing`. Reject with the same code if the value is non-HTTPS (the schema enforces `^https://` but verifiers MUST restate the check; a 3.x parser tolerating a malformed value MUST NOT proceed). The required-when rule is: `identity.brand_json_url` MUST be present when the agent declares `request_signing.supported_for`/`required_for` non-empty, `webhook_signing.supported === true`, or any field under `identity.key_origins`. This is storyboard-enforced in 3.x. In 4.0 the rule becomes schema-required when the response declares `supported_versions` containing any 4.x release; cross-version verifiers (4.0 talking to a 3.x agent that does not advertise 4.x support) MUST continue to accept absent `identity.brand_json_url`.
3. **Origin binding.** The agent URL `A`'s host eTLD+1 MUST equal the `brand_json_url`'s host eTLD+1. eTLD+1 computation MUST use a pinned, dated [Public Suffix List](https://publicsuffix.org/list/public_suffix_list.dat) snapshot (ICANN+PRIVATE sections both in scope so platforms like `vercel.app`, `pages.dev`, `github.io` are treated as suffixes); two verifiers running different PSL versions are non-conformant against each other. If eTLD+1 mismatches, fetch brand.json and check that `authorized_operators[]` lists `A`'s eTLD+1. Only a House Portfolio document carries `authorized_operators[]`; for any other variant there is no fallback. Any entry whose `domain` equals `A`'s eTLD+1 satisfies the binding; that entry's `brands` and `countries` scope account authorization, not key resolution. If neither holds, reject with `request_signature_brand_origin_mismatch`. This closes the shared-tenancy spoofing vector where an attacker stands up an agent on `attacker.example/mcp` and points its `brand_json_url` at an unrelated operator's brand.json that happens to legitimately list `attacker.example/mcp` (e.g., a SaaS multi-tenant deployment).
3. **Origin binding.** The agent URL `A`'s host eTLD+1 MUST equal the `brand_json_url`'s host eTLD+1. eTLD+1 computation MUST use a [Public Suffix List](https://publicsuffix.org/list/public_suffix_list.dat) bundled with the verifier, never fetched at runtime, with the ICANN and PRIVATE sections both in scope so platforms like `vercel.app`, `pages.dev`, `github.io` are treated as suffixes. Verifiers refresh the list through normal dependency updates; AdCP names no snapshot, and two verifiers may differ on a suffix added between their lists ([DR-0037](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0037-psl-bundled-not-pinned.md)). If eTLD+1 mismatches, fetch brand.json and check that `authorized_operators[]` lists `A`'s eTLD+1. Only a House Portfolio document carries `authorized_operators[]`; for any other variant there is no fallback. Any entry whose `domain` equals `A`'s eTLD+1 satisfies the binding; that entry's `brands` and `countries` scope account authorization, not key resolution. If neither holds, reject with `request_signature_brand_origin_mismatch`. This closes the shared-tenancy spoofing vector where an attacker stands up an agent on `attacker.example/mcp` and points its `brand_json_url` at an unrelated operator's brand.json that happens to legitimately list `attacker.example/mcp` (e.g., a SaaS multi-tenant deployment).
4. Fetch brand.json at the exact `brand_json_url` with SSRF validation per [Webhook URL validation](#webhook-url-validation-ssrf). Verifiers MUST NOT follow HTTP redirects on this explicit-URL fetch. This is distinct from a domain-derived `/.well-known/brand.json` bootstrap and from JSON document-indirection variants. Budgets: connect 5 s and total deadline 10 s (recommended); the body cap of 2 MiB (`MAX_BRAND_JSON_BYTES`, 2,097,152 bytes) is normative. The cap is a streaming limit: the verifier MUST count bytes as they arrive and abort the connection as soon as the count exceeds the cap, never buffer the body and then check its length; MAY reject up front when a `Content-Length` header exceeds the cap, but MUST still enforce the cap while streaming. When the response is content-encoded, the cap applies to the decoded bytes, so a small compressed body cannot expand past it. The total deadline covers reading the whole body, which bounds slow-body connections. The cap applies to brand.json fetches only; the JWKS and capabilities caps (`MAX_JWKS_BYTES`, `MAX_CAPABILITIES_BYTES`) are unchanged. Verifiers MUST NOT read more than 2 MiB, and SHOULD accept any brand.json up to that size. Verifiers SHOULD send `Accept-Encoding: identity`. Parse cost scales with size, so the strict parser MUST also enforce a maximum JSON nesting depth of 32 while parsing (reject deeper documents as `request_signature_brand_json_malformed`; legitimate brand.json is far shallower), and SHOULD bound the total node count and parse off the request hot path. Because a brand.json can now be large, a verifier MUST run its cheap checks (signature-input parse, request-level rate limiting) before fetching brand.json, MUST bound the number of concurrent in-flight trust-root fetches, and MAY negative-cache cap, depth, and duplicate-key failures for the full 60 s so rotating URLs cannot force repeated large fetches. Verifiers SHOULD revalidate with `ETag` / `If-None-Match` when the origin supports it and SHOULD single-flight concurrent fetches of the same URL rather than refetching per request. Cache TTL on a successful fetch MUST be bounded above by the JWKS revocation polling interval (so a key rotation cannot be masked by a stale brand.json). Negative responses (404, network failure) MUST NOT be cached for more than 60 s — operators fixing a misconfiguration must not be locked out for a full revocation cycle.
5. Canonicalize `A` and every candidate `agents[].url` with the shared [AdCP URL canonicalization algorithm](/docs/reference/url-canonicalization), then find entries whose canonical URL equals canonical `A`. The candidates are those in [Agent resolution](#agent-resolution) step 1: the top-level `agents[]`, or for a House Portfolio, `house.agents[]` and every `brands[].agents[]`, with matches that agree on `type` and resolved JWKS source counted once. If none matches, reject with `request_signature_agent_not_in_brand_json`. If multiple entries match after canonicalization, reject with `request_signature_brand_json_ambiguous`; never select the first. Retain the matched canonical URL as the verified Agent identity if the remaining key checks succeed. A trailing slash remains significant under the shared algorithm, while scheme/host case, default ports, dot segments, and percent-encoded unreserved characters normalize as specified there.
6. Resolve the JWKS source from the matched `agents[]` entry's `jwks_uri`, defaulting to `/.well-known/jwks.json` at the origin of that entry's `url` when absent. This holds for every surface and role. For a signature the agent makes in its sell-side role about a publisher's inventory, such as a webhook about a media buy, then apply that publisher's `adagents.json` `signing_keys` pin as a narrowing intersection per [Publisher pin precedence](#agent-key-publication). The pin does not apply to the agent's other traffic and does not imply a separate `adcp_use: "webhook-signing"` key purpose.
Expand Down Expand Up @@ -1425,7 +1425,7 @@ Mirrors the [request-signing quickstart](#quickstart-opt-into-request-signing-in

1. **Fetch capabilities** for the signing agent's URL `A`. This is a **protocol-level** call — invoke `get_adcp_capabilities` via the agent's declared transport (MCP `tools/call` or A2A skill invocation), not a raw HTTP `GET` against `A`. The agent URL is the protocol endpoint, not a JSON capabilities document. Use SSRF-safe transport per [Webhook URL validation](#webhook-url-validation-ssrf): HTTPS only, address-family + private-IP filtering, no redirects, with budgets `{ connect: 5000, total: 10000, body: MAX_CAPABILITIES_BYTES, maxRedirects: 0 }`.
2. **Read `identity.brand_json_url`.** Reject `request_signature_brand_json_url_missing` if absent (and the request is signed) or non-HTTPS.
3. **eTLD+1 origin binding.** Compute `eTLD+1(A)` and `eTLD+1(brand_json_url)` using a pinned PSL snapshot. Use [`tldts`](https://www.npmjs.com/package/tldts) (TS), [`publicsuffixlist`](https://pypi.org/project/publicsuffixlist/) (Python), or [`golang.org/x/net/publicsuffix`](https://pkg.go.dev/golang.org/x/net/publicsuffix) (Go) with a vendored, dated snapshot. Do NOT fetch the PSL at runtime — a runtime fetch creates a denial-of-service oracle and a non-deterministic eTLD+1 across deployments. If they match, proceed. Otherwise fetch `brand.json` and check `authorized_operators[]` (present only on a House Portfolio) — if `eTLD+1(A)` is delegated, proceed. Else reject `request_signature_brand_origin_mismatch`. Origin comparisons throughout this algorithm MUST canonicalize both sides: ASCII-lowercase the host, then convert to IDNA-2008 A-label form (Punycode) before byte-equality. A non-canonical comparison (e.g., raw `Example.COM` vs `example.com`, or U-label vs A-label) silently rejects legitimate traffic.
3. **eTLD+1 origin binding.** Compute `eTLD+1(A)` and `eTLD+1(brand_json_url)` with the bundled Public Suffix List that step 3 of [Discovering an agent's signing keys](#discovering-an-agents-signing-keys-via-brand_json_url) requires. Do NOT fetch the PSL at runtime — a runtime fetch creates a denial-of-service oracle. If they match, proceed. Otherwise fetch `brand.json` and check `authorized_operators[]` (present only on a House Portfolio) — if `eTLD+1(A)` is delegated, proceed. Else reject `request_signature_brand_origin_mismatch`. Origin comparisons throughout this algorithm MUST canonicalize both sides: ASCII-lowercase the host, then convert to IDNA-2008 A-label form (Punycode) before byte-equality. A non-canonical comparison (e.g., raw `Example.COM` vs `example.com`, or U-label vs A-label) silently rejects legitimate traffic.
4. **Fetch `brand.json`** with the same SSRF rules + no redirects, body cap `MAX_BRAND_JSON_BYTES`, connect 5 s, total 10 s. Parse with a strict JSON parser that rejects duplicate keys and enforces the nesting-depth limit while parsing (in Python, the stdlib `json.JSONDecoder` with an `object_pairs_hook` that raises on duplicates; in TS and Go the stock parsers are last-wins on duplicate keys, so scan the token stream for duplicates before or while decoding — `secure-json-parse` guards prototype poisoning only, and `Decoder.DisallowUnknownFields` does not detect duplicates) — duplicate keys are the parser-differential vector that step 14 closes on the request surface, and the same trust-root document MUST NOT parse to two different shapes across verifiers. On duplicate-key detection, reject `request_signature_brand_json_malformed`. Cache successful responses up to (but no longer than) the JWKS revocation polling interval; cache failures for at most 60 s.
5. **Find the `agents[]` entry.** Canonicalize `A` and every candidate `agents[].url` (top-level, or for a House Portfolio `house.agents[]` and every `brands[].agents[]`) with the shared [AdCP URL canonicalization algorithm](/docs/reference/url-canonicalization). Reject `request_signature_agent_not_in_brand_json` on no canonical match and `request_signature_brand_json_ambiguous` on multiple canonical matches. Retain the one matched canonical URL as the Agent identity after signature verification succeeds.
6. **Resolve `jwks_uri`** from the matched entry (default: `/.well-known/jwks.json` at the origin of the matched entry's `url`). For a sell-side signature about a publisher's inventory, keep only keys whose RFC 7638 thumbprint matches an entry in that publisher's `adagents.json` `signing_keys` pin, when one exists.
Expand All @@ -1451,8 +1451,8 @@ async function resolveAgent(agentUrl: string): Promise<AgentResolution> {
});
const brandJsonUrl = caps.identity?.brand_json_url;
if (!brandJsonUrl?.startsWith('https://')) throw new Err('brand_json_url_missing'); // step 2
const agentEtld1 = etldPlusOne(new URL(agentUrl).hostname, PINNED_PSL_SNAPSHOT); // step 3
const brandEtld1 = etldPlusOne(new URL(brandJsonUrl).hostname, PINNED_PSL_SNAPSHOT);
const agentEtld1 = etldPlusOne(new URL(agentUrl).hostname); // step 3, bundled PSL
const brandEtld1 = etldPlusOne(new URL(brandJsonUrl).hostname);
const brandJson = await safeFetch(brandJsonUrl, { // step 4
...FETCH_BUDGETS, body: MAX_BRAND_JSON_BYTES, ssrf: true, parse: 'strict-json',
});
Expand Down Expand Up @@ -1516,7 +1516,7 @@ There is no capabilities fetch and no brand.json lookup, and verification create

**Evaluating the trust record.** The operator's trust record is evaluated when something is gated on it: binding a principal, evaluating a grant ([Grants in trust records](/docs/building/by-layer/L2/accounts-and-agents#grants-in-trust-records-experimental)), or `open` onboarding. It is never evaluated on the request-signature path. For a JWS artifact, step 1 derives it to select the profile, as the JWS artifacts rule under Coexistence below says.

1. **Derive it.** `R = https://{eTLD+1(host(O))}/.well-known/trust.json`, using a pinned, dated Public Suffix List snapshot that includes the ICANN and PRIVATE sections.
1. **Derive it.** `R = https://{eTLD+1(host(O))}/.well-known/trust.json`, using the Public Suffix List as step 3 of [Discovering an agent's signing keys](#discovering-an-agents-signing-keys-via-brand_json_url) requires.
- An `O` whose host is an IP literal or a public suffix, or has no defined eTLD+1, has no trust record.
- Fetch `R` under the [Webhook URL validation](#webhook-url-validation-ssrf) rules, with a 2 MiB streaming body cap and strict parsing (duplicate keys rejected, nesting depth 32).
- Follow at most one `authoritative_location` hop, with zero HTTP redirects. A hop target that itself carries `authoritative_location` is rejected.
Expand Down
4 changes: 2 additions & 2 deletions docs/building/by-layer/L2/accounts-and-agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,8 @@ From 3.3, a brand's parent organization can publish demand-side grants in its `/
This section is the normative home for evaluating grants. Spec R6 and DR-0025 record the design and point here. The `delegation-chains` set in [Reference test vectors](/docs/reference/test-vectors) covers the rule.

**How a seller evaluates acts-for authority:**
- **`grantee` is a registrable domain.** A grant's `grantee` MUST be a registrable domain under the pinned Public Suffix List snapshot from step 1 of [Web Bot Auth identities](/docs/building/by-layer/L1/security#web-bot-auth-identities) (spec R3a step 1). A grant whose `grantee` is not one covers no claim. To limit a grant to some of the grantee's agents, list them in `agents[]`.
- **Domains compare as registrable domains.** `account.operator` and the brand's domain are compared with `grantee`, with the signer's operator, and with each other, as registrable domains under the same snapshot.
- **`grantee` is a registrable domain.** A grant's `grantee` MUST be a registrable domain under the Public Suffix List from step 1 of [Web Bot Auth identities](/docs/building/by-layer/L1/security#web-bot-auth-identities) (spec R3a step 1). A grant whose `grantee` is not one covers no claim. To limit a grant to some of the grantee's agents, list them in `agents[]`.
- **Domains compare as registrable domains.** `account.operator` and the brand's domain are compared with `grantee`, with the signer's operator, and with each other, as registrable domains under the same list.
- **One record authorizes: the parent organization's.** The parent organization is the one the brand's brand.json names (`house` or `house_domain`), as the [mutual-assertion trust model](/docs/brand-protocol/brand-json#mutual-assertion-trust-model) resolves it, and otherwise the brand's domain itself. The seller reads the parent organization's record at its registrable domain, `https://{eTLD+1(house)}/.well-known/trust.json`, as step 1 of [Web Bot Auth identities](/docs/building/by-layer/L1/security#web-bot-auth-identities) derives the operator's record. No other record starts a delegation chain, including a record at the brand's own domain when the model resolves a parent organization, so the two never compete. The buyer's operator record never authorizes a brand outside the operator's registrable domain.
- **Implicit authority needs no grant.** It holds when `account.operator` and the signer's operator are each the brand or its parent organization, in either combination. This extends self-authorization above, which compares the `operator` domain with the brand's domain exactly, to the registrable domain, because the signer's operator is a registrable domain, and to the parent organization, because its record is the one that authorizes and an organization's own agents act for it without a grant.
- **Otherwise a delegation chain must reach the signer's operator,** with a scope that covers the operation (`adcp:media_buying` for buys). A delegation chain has at most two grants. The parent organization grants a domain, and that domain's own trust.json, read at its registrable domain, can grant the next one. `account.operator` MUST be the brand, the parent organization, or the grantee of the first grant; otherwise the chain doesn't cover the claim.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ The 3.2 profile is deprecated now because `versioning.mdx` requires 6 months' no

## Implications

The design spec is `specs/agent-identity-3-3.md`. It defines the rules (R0–R8), the threat-model deltas, and the P1–P9 implementation plan. The WG approved it at the working session on 2026-10-12 (decision brief on #7878); PR #8119 records it.
The design spec is `specs/agent-identity-3-3.md`. It defines the rules (R0–R8), the threat-model deltas, and the P1–P9 implementation plan. The WG approved it at the working session on 2026-10-12 (decision brief on #7878); PR #8119 records it. *Amended by DR-0037: R3a step 1 uses a Public Suffix List bundled with the verifier and refreshed through dependency updates; AdCP names no snapshot.*

**This record settles:**
- origin identity;
Expand Down
Loading
Loading