diff --git a/.changeset/psl-bundled-not-pinned.md b/.changeset/psl-bundled-not-pinned.md new file mode 100644 index 00000000000..c1473f1ca4b --- /dev/null +++ b/.changeset/psl-bundled-not-pinned.md @@ -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. diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index 65df623f9b8..0ce2bc8bd44 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -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 SHOULD keep the bundled list current 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. @@ -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. @@ -1451,8 +1451,8 @@ async function resolveAgent(agentUrl: string): Promise { }); 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', }); @@ -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. diff --git a/docs/building/by-layer/L2/accounts-and-agents.mdx b/docs/building/by-layer/L2/accounts-and-agents.mdx index 1ab9f171e30..183006402e6 100644 --- a/docs/building/by-layer/L2/accounts-and-agents.mdx +++ b/docs/building/by-layer/L2/accounts-and-agents.mdx @@ -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. diff --git a/governance/decisions/DR-0023-agent-identity-is-an-origin.md b/governance/decisions/DR-0023-agent-identity-is-an-origin.md index 1623d26f38c..7682bee31d7 100644 --- a/governance/decisions/DR-0023-agent-identity-is-an-origin.md +++ b/governance/decisions/DR-0023-agent-identity-is-an-origin.md @@ -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; diff --git a/governance/decisions/DR-0037-psl-bundled-not-pinned.md b/governance/decisions/DR-0037-psl-bundled-not-pinned.md new file mode 100644 index 00000000000..3ded2a97810 --- /dev/null +++ b/governance/decisions/DR-0037-psl-bundled-not-pinned.md @@ -0,0 +1,37 @@ +--- +id: DR-0037 +title: Verifiers bundle the Public Suffix List and refresh it through dependency updates; AdCP names no snapshot +class: normative +status: ratified +date: 2026-11-02 +decided: 2026-11-02 +decided_by: maintainer approval (PR #8702), on Konstantin Mirin's proposal in #8409 +supersedes: DR-0023 # in part: spec R3a step 1's "pinned, dated" snapshot +refs: ["#8409", "PR #8702", "DR-0023", "adcp-client#3379", "adcp-client-python#1562", "adcp-go#590"] +dissent: none recorded +--- + +## Decision + +eTLD+1 is computed with a Public Suffix List (PSL) that includes the ICANN and PRIVATE sections, is bundled with the verifier, and is never fetched at runtime. A host with no defined eTLD+1 has no trust record (R3a step 1, unchanged). + +Verifiers refresh the list through normal dependency updates. AdCP names no reference snapshot, and two verifiers whose lists differ are not non-conformant against each other. `security.mdx` names no PSL library. + +This amends the "pinned, dated snapshot" wording in spec R3a step 1, which DR-0023 ratified, and removes "two verifiers running different PSL versions are non-conformant against each other" from `security.mdx`. The rest of DR-0023 stands. + +## Rationale + +Two lists disagree only on suffixes added or removed between them, mostly in the PRIVATE section. The ICANN section changes too, through second-level suffixes under country-code TLDs; a new TLD changes nothing, because the PSL default rule already treats an unlisted TLD as a suffix. For an added suffix, the verifier with the older list derives the platform's trust record instead of the tenant's, and the platform's record doesn't list the tenant, so the claim fails closed. A grantee that list doesn't treat as a registrable domain covers no claim, which also fails closed. + +The `brand_json_url` origin binding goes the other way. An older list gives two tenants of a newly listed platform one eTLD+1, so the binding passes for them without `authorized_operators[]` until the verifier updates. Every verifier behaved that way before the listing, and a pinned snapshot would keep the gap open until the next release. + +A shared pinned snapshot wouldn't reduce the risk. The tenant-collapse risk comes from a stale list, and one snapshot per release makes every verifier equally stale for a release cycle. Naming one would cost a vendored list per release, a change in each SDK (Go would replace `golang.org/x/net/publicsuffix`), vectors, and upkeep, all to make verifiers agree on old data. + +## Implications + +**This record settles:** +- that AdCP publishes no PSL snapshot and no snapshot-specific vectors; the PSL vectors in `static/test-vectors/adagents-discovery-redirects.json` use suffixes every list agrees on; +- that a verifier keeps its list current through dependency updates, and the runtime-fetch ban stays; +- that the SDK tracking issues for a pinned snapshot (adcp-client#3379, adcp-client-python#1562, adcp-go#590) close without code changes. + +**It does not settle** how often an SDK must refresh its list. That stays an SDK maintenance matter. diff --git a/specs/agent-identity-3-3.md b/specs/agent-identity-3-3.md index 4c487a4d9f3..ea234d50d7e 100644 --- a/specs/agent-identity-3-3.md +++ b/specs/agent-identity-3-3.md @@ -122,7 +122,7 @@ The result is the verified identity `O`. A signer whose trust record does not li Evaluated when something is gated on it: binding a principal (R5), evaluating a grant (R6), and `open` onboarding (R7). It is never evaluated on the request-signature path. For a JWS artifact, R4 derives it to select the profile. -1. **Derive.** `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.** `R = https://{eTLD+1(host(O))}/.well-known/trust.json`, using a Public Suffix List bundled with the verifier (not fetched at runtime) that includes the ICANN and PRIVATE sections, refreshed through dependency updates (DR-0037). - An `O` whose host is an IP literal or a public suffix, or has no defined eTLD+1, has no trust record. - Fetch `R` and any hop under the `security.mdx` Webhook URL validation SSRF rules, with the trust-record body cap and strict parsing. - Follow at most one `authoritative_location` hop. The target MUST NOT itself carry `authoritative_location`. @@ -273,7 +273,7 @@ The window of exposure is the time between compromise and the revocation reachin | Trust-record fetches used as an amplifier or network probe | Evaluated only at gates and, for a JWS artifact, to select the profile (R4); never on the request-signature path. One hop, fetch limits, and one external authorization code (R3a). | | Signature errors used to probe which check failed | WBA status and headers only; no signature-specific body detail (R2). | | Posture used as an onboarding oracle | Seller-wide, unauthenticated, uniform block. Per-agent state only in authenticated `get_principal` (R7, R8). | -| PSL drift between verifiers | Pinned, dated snapshot, as `security.mdx` already requires. | +| PSL drift between verifiers | Fails closed: a verifier whose list lacks a newly added suffix derives the platform's trust record, which doesn't list the tenant. Lists are bundled, never fetched at runtime, and refreshed through dependency updates (DR-0037). | ## Known risks diff --git a/specs/brand-identity-trust-split.md b/specs/brand-identity-trust-split.md index df0ad24a3b8..f7e52a41813 100644 --- a/specs/brand-identity-trust-split.md +++ b/specs/brand-identity-trust-split.md @@ -146,7 +146,7 @@ In AdCP, request signing, webhook signing, governance JWS `iss`, rights attestat - The pointer MUST be exactly `https://{H}/.well-known/trust.json`. In AdCP it is `identity.trust_url`. - Fetch it with zero HTTP redirects, following at most one `authoritative_location` hop, also with zero redirects. The operator is `H`. - If the legacy `identity.brand_json_url` is also present, its host MUST equal `H`. -3. **Origin binding.** eTLD+1(`A`) MUST equal eTLD+1(`H`), using the pinned PSL snapshot. +3. **Origin binding.** eTLD+1(`A`) MUST equal eTLD+1(`H`), using the verifier's bundled Public Suffix List, as `security.mdx` requires (DR-0037). - There is no hosting-delegation fallback. - Cross-organization cases, such as a platform running an agent for a brand, go through a grant: the platform lists the agent in its own record, and the brand grants the platform. 4. **Match the agent.** Exactly one `agents[]` entry must canonically equal `A`. No match → `request_signature_agent_not_in_trust_record`. More than one → `…_ambiguous`. diff --git a/specs/capabilities-brand-url.md b/specs/capabilities-brand-url.md index 2a7ea785afe..428a3e56a27 100644 --- a/specs/capabilities-brand-url.md +++ b/specs/capabilities-brand-url.md @@ -322,7 +322,7 @@ A per-process SDK resolver doesn't have the same threat model as a hosted endpoi - Timeouts: connect 5 s, per-stage 4 s, total 10 s. - Redirects disallowed (`maxRedirects: 0`). - Per-upstream-host rate limit (10 req/s/host keyed on eTLD+1) — politeness to operator infra, not amplification defense. -- Public Suffix List from a pinned, dated snapshot bumped via dependency update — not a runtime fetch. +- A Public Suffix List bundled with the verifier and refreshed through dependency updates (DR-0037) — not a runtime fetch. - JWKS cache: propagate upstream `Cache-Control` byte-for-byte. **No stale-while-revalidate on JWKS** — a rotated-out compromised key MUST NOT be served past its TTL. ### Why this works without a hosted layer @@ -381,7 +381,7 @@ What we lose vs. the hosted shape: the three-line `createRemoteJWKSet(aaoUrl)` i - **Protocol (required-when on `supported` is wrong cut)**: tightened to `supported_for[]`/`required_for[]` non-empty for `request_signing` (a no-op declaration with empty arrays doesn't bind any operation, so it shouldn't drag in `key_origins`). `webhook_signing.supported === true` cut unchanged. - **Protocol (consistency check breaks sell-side webhooks)**: step 7 now skips the origin check for purposes whose JWKS source was a publisher `adagents.json signing_keys` pin — the pin is an intentional override and may legitimately point at a different host than the operator's `key_origins` declaration. Operator-side purposes still enforced. - **Protocol (loose SHOULDs)**: tightened to MUSTs at lines 143 (production verifiers MUST use native), 195 (verifiers MUST honor upstream `Cache-Control`), and the trust-posture paragraph. -- **Security (PSL pinning)**: SSRF hardening now requires a pinned, dated PSL snapshot, not a runtime fetch. +- **Security (PSL pinning)**: SSRF hardening now requires a Public Suffix List bundled with the verifier and refreshed through dependency updates (DR-0037), not a runtime fetch. - **Security (IPv6 ULA + cloud metadata)**: SSRF list expanded with explicit IPv6 unique-local, IPv4-mapped IPv6, multicast, AWS/GCP/Azure metadata IPs, bracketed-IPv6 zone-ID handling. - **Security (DNS rebinding ordering)**: address-family filter MUST run before DNS-pin. - **Security (per-caller < per-host rate cap)**: explicit invariant added so a single caller cannot exhaust an operator's quota. diff --git a/static/schemas/source/trust/v1/trust.json b/static/schemas/source/trust/v1/trust.json index 58b1e1ba7a0..edd53a17f59 100644 --- a/static/schemas/source/trust/v1/trust.json +++ b/static/schemas/source/trust/v1/trust.json @@ -231,7 +231,7 @@ "properties": { "grantee": { "$ref": "#/definitions/domain", - "description": "Registrable domain (eTLD+1) of the organization receiving the grant, under the pinned Public Suffix List snapshot, ICANN and private sections (spec R3a step 1). Its agents resolve to this domain through its own trust.json. A host below a registrable domain (t1.vendor.example) is not one, and neither is a public suffix itself (github.io); alice.github.io is one, because github.io is in the private section. A grant whose grantee is not a registrable domain covers no claim, as L2 §Grants in trust records describes (docs/building/by-layer/L2/accounts-and-agents.mdx). A pattern can't evaluate the Public Suffix List, so verifiers apply the rule. To limit a grant to some of the grantee's agents, list them in agents[]." + "description": "Registrable domain (eTLD+1) of the organization receiving the grant, under the verifier's bundled Public Suffix List, ICANN and private sections (spec R3a step 1). Its agents resolve to this domain through its own trust.json. A host below a registrable domain (t1.vendor.example) is not one, and neither is a public suffix itself (github.io); alice.github.io is one, because github.io is in the private section. A grant whose grantee is not a registrable domain covers no claim, as L2 §Grants in trust records describes (docs/building/by-layer/L2/accounts-and-agents.mdx). A pattern can't evaluate the Public Suffix List, so verifiers apply the rule. To limit a grant to some of the grantee's agents, list them in agents[]." }, "agents": { "type": "array",