feat(signing): key_origins check + brand.json chain error codes (#350 stage 4)#775
Conversation
… codes (#350 stage 4) Per ADCP request-signing spec #3690, an agent advertising signing posture declares ``identity.key_origins`` on its capabilities response — keyed by purpose (request_signing / webhook_signing / governance_signing / tmp_signing) and valued with the origin URI that hosts the JWKS for that purpose. After resolving an agent's keys via the brand.json chain, the verifier MUST confirm the resolved jwks_uri host matches the declared origin for the purpose under check. **4a — new error codes** (errors.py): Nine new ``request_signature_*`` codes covering the brand.json discovery chain (#3690 §"Discovering an agent's signing keys") and the ``identity.key_origins`` consistency check: - _brand_json_url_missing (capabilities.identity.brand_json_url absent) - _capabilities_unreachable (transport failure fetching capabilities) - _brand_json_unreachable (transport failure fetching brand.json) - _brand_json_malformed (strict-parse failure on brand.json) - _brand_origin_mismatch (agent eTLD+1 ≠ brand eTLD+1 + no delegation) - _agent_not_in_brand_json (agent URL absent from agents[]) - _brand_json_ambiguous (multiple agents[] entries byte-equal) - _key_origin_mismatch (resolved jwks_uri host ≠ declared origin) - _key_origin_missing (signing posture asserted, declaration absent) Plus the nine ``webhook_signature_*`` mirror constants and entries in REQUEST_TO_WEBHOOK_CODE so the webhook verifier wrapper retags request-family codes into webhook-family ones without each callsite re-declaring the mapping. **4b — key_origins consistency check** (key_origins.py, new): ``check_key_origin_consistency(jwks_uri, key_origins, purpose, posture=None, code_family="request")`` — the standalone primitive for the consistency check. Pure function on (resolved jwks_uri, declared key_origins map, purpose); raises SignatureVerificationError with the right spec code. ``code_family="webhook"`` swaps to the webhook code family. Canonicalization: ASCII-lowercase + stdlib host.encode("idna") to A-label form. The spec asks for IDNA-2008 strictly while stdlib encodings.idna is IDNA-2003; this divergence is rare in practice and matching the package's existing convention (jwks.py:201, ip_pinned_transport.py:110, revocation_fetcher.py:380) keeps the canonicalization story coherent. A future IDNA-2008 migration would update all four callsites together. The verifier integration (calling this check after RFC 9421 verification when the JWKS source was a brand.json walk vs. a publisher pin) belongs to stage 5's dispatch wire-up — the primitive lands here, ready to compose with the BrandAuthorizationResolver gate. 12 new tests cover: success (declared matches resolved), missing declaration, mismatch on different host, mismatch on subdomain drift, case-insensitive comparison, posture propagation in diagnostics, bare-host declarations, ``None`` and empty key_origins maps, invalid jwks_uri (fail-closed), and webhook code-family routing. Re-exports from ``adcp.signing`` so adopters can import: - check_key_origin_consistency - All 9 new REQUEST_SIGNATURE_* code constants (Webhook mirrors stay submodule-only; webhook code routing is internal to the webhook verifier wrapper.) Refs #350, adcontextprotocol/adcp#3690 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
LGTM. Follow-ups noted below. The right shape for stage 4: pure primitive, fail-closed, kw-only API, deferred verifier wire-up to stage 5 where it belongs.
Things I checked
- Naming taxonomy. All nine new
request_signature_*codes follow the existing in-repo convention (errors.py:26-43):_unreachablefor retryable transport,_missing/_malformed/_mismatch/_ambiguousfor misconfiguration. Thebrand_origin_mismatchvskey_origin_mismatchsplit is the right granularity — different chain steps, different reject codes. REQUEST_TO_WEBHOOK_CODEcompleteness. All nine new request codes have webhook mirrors and entries in the dict (errors.py:126-134). The pre-existing 18 entries are unchanged. No drift.- Public-API surface. All nine new
REQUEST_SIGNATURE_*constants andcheck_key_origin_consistencyare both imported and present in__all__insrc/adcp/signing/__init__.py. Webhook mirrors are intentionally submodule-only per PR body — that's right. - Canonicalization precedent.
host.encode(\"idna\").decode(\"ascii\").lower()matches the existing convention atjwks.py:201,ip_pinned_transport.py:110,revocation_fetcher.py:383. Diverging in one place would be worse than mismatching the spec everywhere consistently.ad-tech-protocol-expert: sound-with-caveats — file a tracking issue to migrate all four sites together when adopting IDNA-2008 (UTS-46 via theidnaPyPI package), and document the divergence. - Exact host vs eTLD+1. Exact-host is correct shape — eTLD+1 would let multi-tenant subdomains spoof siblings.
test_consistency_raises_mismatch_on_subdomain_driftasserts this. Matches RFC 8414 §3 issuer-host comparison. - Fail-closed posture on the load-bearing case. When operator declares one origin and brand.json points elsewhere, resolved host won't match declared host →
*_key_origin_mismatchraises.security-reviewer: no High findings — the primary defense holds. - Tests. 12 cases cover success, missing (None / empty / absent purpose), mismatch (different host / subdomain drift / unparseable URI), webhook code-family routing, posture diagnostics.
Follow-ups (non-blocking — file as issues)
- Port omission weakens the load-bearing defense.
urlsplit(\"https://keys.brand.com:8443/...\").hostnamestrips port; both sides lose it in_origin_host. Per RFC 6454 §4 an origin is(scheme, host, port). Concrete scenario: operator declareshttps://keys.brand.com:8443, attacker brand.json pointsjwks_uritohttps://keys.brand.com:80/attacker.jwkson the same host — the host string matches and the check passes. The other layers (TLS pinning, the actual JWKS fetch) usually preclude this, but this primitive is the belt-and-suspenders defense and it does not check port. Compare(host, port)tuples; treat bare-host (port-absent) declaration as wildcard explicitly if that's the spec's intent. (key_origins.py:149) - IPv6 literals always mismatch.
urlsplit(\"https://[2001:db8::1]/...\").hostnamereturns\"2001:db8::1\";\"2001:db8::1\".encode(\"idna\")raisesUnicodeErroron:, so_origin_hostreturnsNoneand even an identical-IPv6 declaration vs resolved comparison raises_key_origin_mismatch. Short-circuit IP literals viaipaddress.ip_address(host)before the IDNA call. (key_origins.py:157-160) - Bare-host fallback fails closed by accident. The
if not hostbranch lets?,#,;,@past its/-and-space gate; only IDNA's NamePrep happens to reject them. The fail-closed posture is incidental, not intentional. Reject the punctuation set explicitly atkey_origins.py:155, or reparse viaurlsplit(\"//\" + value)and use.hostname. Same site fixes the bare-host-with-port footgun (\"keys.brand.com:8443\"parses withhostname=Noneand IDNA rejects the colon). - Empty-string declared value routes to
_mismatch, not_missing.key_origins={\"request_signing\": \"\"}survives thedeclared is Noneguard, falls through to_origin_host(\"\")→None→_key_origin_mismatch. Adopters parsing a malformedidentity.key_originsvalue may find that surprising. Treat empty / whitespace-only declared strings as missing. (key_origins.py:107-128) - Publisher-pin carve-out is caller responsibility but the function gives callers no helper. The docstring at
key_origins.py:26-29says "skip this check for the (agent, purpose, role) tuple sourced from a publisher pin" — adopters will forget and reject legitimate publisher-pinned signatures with_key_origin_missing. Stage 5 should introduce aKeySource = Literal[\"brand_json\", \"publisher_pin\"]wrapper that dispatches.
Minor nits (non-blocking)
- No IDN equivalence test. The docstring claims IDNA-A-label canonicalization makes
münchen.de(U-label) compare equal toxn--mnchen-3ya.de(A-label), but no test asserts it. That equivalence is the whole point of the IDNA path. Add one test. (tests/test_key_origins.py) - Mismatch message can render
None. Whendeclared_hostoractual_hostcanonicalize toNone, the error message readsdeclares None but resolved jwks_uri host is 'keys.brand.com'. Falling back to the raw input onNonewould improve diagnostics. (key_origins.py:124-127) - Redundant exception in the IDNA
except.UnicodeEncodeErroris a subclass ofUnicodeError; the second name inexcept (UnicodeError, UnicodeEncodeError)is harmless but noise. (key_origins.py:159) - Test-plan honesty. Three checkboxes unchecked —
[ ] CI green on Python 3.10–3.13(CI gates),[ ] Argus AI reviewer pass on canonicalization(this review — IDNA-2003 choice approved with the migration follow-up above),[ ] Verify new symbols importable from adcp.signing. The third is a one-liner; the__init__.pywiring is direct, but worth running before merging stage 5 atop this.
Approving on the strength of the clean primitive shape plus the protocol-expert verdict on naming and exact-host comparison. The port and IPv6 follow-ups should land before stage 5 wires this into the verifier — easier to fix the primitive than to debug a false-positive at the dispatch layer.
CI summaryAll stage-4-relevant checks green:
3 storyboard runners fail — pre-existing on main, unrelated to stage 4:
The stage-4 changes — error code constants + the Expert reviews (ad-tech-protocol-expert, code-reviewer, security-reviewer) running now. Will fold their feedback before requesting review. |
|
Thanks for the CI summary. Stage-4-relevant checks all green, and the three storyboard failures look genuinely pre-existing (same shape on #769/#770 merge commits). No action needed on those from this PR's perspective. Will stand by for the expert review feedback before requesting review. Generated by Claude Code |
|
Acknowledged — standing by while you wait for expert review feedback. No action needed on my end. Generated by Claude Code |
Folds three expert review passes into the stage 4 PR (ad-tech-protocol-expert, code-reviewer, security-reviewer all run in parallel on the original commit 62d2379). **1. Spec-mandated structured detail fields (MUST-FIX, protocol expert)** ADCP #3690 security.mdx step 7 mandates that ``request_signature_key_origin_mismatch`` carry ``{purpose, expected_origin, actual_origin}`` and ``_key_origin_missing`` carry ``{purpose, posture}`` as structured fields, not just an opaque message string. Middleware adapters surface them on the 401 / in a DLQ; without this, the SDK can't be spec- conformant once Stage 5 wires the verifier. - Extend ``SignatureVerificationError`` with optional ``detail: Mapping[str, str] | None``. ``str(exc)`` still renders the free-form message for unstructured logs; structured callers read ``exc.detail``. - ``check_key_origin_consistency`` now passes the spec-mandated keys. - Two new tests pin the detail shape for both code paths. **2. Bare-host vs URL canonicalization asymmetry (HIGH, security reviewer)** The previous ``_origin_host`` fallback path for bare-host inputs only guarded against ``/`` and space — accepting ``user@host``, ``host:port``, ``host?query``, ``host#fragment`` verbatim. An attacker with capability-write access could declare a bare-host-with-port (``keys.brand.com:8443``) to force a mismatch against the operator's brand.json origin and DoS the honest verification path. Fix: factor a ``_extract_host`` helper that re-parses bare-host inputs through ``urlsplit`` with a synthetic ``https://`` scheme prepended. URL form and bare-host form now strip port / userinfo / query / fragment symmetrically. Two new tests cover ``host:port`` and ``user@host`` declaration shapes. **3. Trailing-dot FQDN asymmetry (HIGH, security reviewer)** ``host.example.`` and ``host.example`` are the same FQDN — the trailing dot denotes the root zone. Previous code preserved the dot, so a brand.json serving the dot form against a capability declaring the no-dot form (or vice versa) byte-mismatched. An attacker controlling capabilities could weaponize this to deny verification against the real counterparty. Fix: strip a single trailing dot before IDNA-encoding. Test covers both directions. **4. IDN U-label vs A-label equivalence test (NICE, all three)** The docstring promised IDN canonicalization but no test exercised it. Added a test using ``münchen.example`` ↔ ``xn--mnchen-3ya.example`` in both directions. **5. Carve-out for publisher-pin source in function docstring (MEDIUM, security)** Previously the publisher-pin skip was documented only in the module docstring. A caller reading just the function doc would miss it. Added a "Caller contract" paragraph at the top of the function docstring flagging that callers MUST skip this call for publisher-pinned tuples. **6. Symmetric fail-closed test on declared side (LOW, security)** The existing ``test_consistency_raises_mismatch_on_invalid_jwks_uri`` covers the resolved side but not the declared side. Added a symmetric test so a future refactor can't silently invert the fail direction on one side. **Deferred to follow-up issues** (not in scope for this PR): - Plumbing the ``source`` discriminant ("brand.json walk" vs "publisher adagents.json pin") through ``BrandJsonJwksResolver`` so Stage 5 can enforce the carve-out automatically. Belongs in the Stage 5 verifier-integration PR. - Migrating all four codebase callsites (jwks.py, ip_pinned_transport.py, revocation_fetcher.py, key_origins.py) from stdlib IDNA-2003 to the ``idna`` PyPI package's IDNA-2008 in one commit. Package-wide conformance pass, separate concern. Tests: 20 in test_key_origins (up from 12). Full signing surface (582 tests across tests/test_*.py + tests/conformance/signing/) remains green. ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expert review summary + fixes pushedThree expert reviewers (ad-tech-protocol-expert, code-reviewer, security-reviewer) ran in parallel against 62d23796. Folded the actionable findings into c0ed690c. Fixed on this PR
20 tests in `test_key_origins` (up from 12). Full signing surface (`tests/test_*.py` + `tests/conformance/signing/`) — 582 tests — remains green. ruff + mypy clean. Filed as follow-up issues (out of scope for this PR)
Reviewer feedback not addressed (with reasons)
Ready for re-review. |
|
Thanks for the detailed write-up. The fix table and the "not addressed / reasons" section are exactly the right format for reviewers — no questions from me. Standing by for CI and human sign-off. Generated by Claude Code |
|
Acknowledged — nothing to act on here. Standing by. Generated by Claude Code |
|
Fold suggestion from issue triage (#776): Issue #776 asks for a If this PR still has room before merge, folding item 1 from #776 ( No action required — just flagging so you can decide whether to expand scope here or leave #776 as a standalone follow-up after this merges. Generated by Claude Code |
|
Fold candidate note from issue triage: Issue #777 (IDNA 2003 → IDNA 2008 migration across The migration scope from #777: add This is a suggestion only — happy to leave it as a follow-up if you prefer to keep this PR's scope tight. Triaged by Claude Code (issue #777). Session: https://claude.ai/code/session_01M9KeN5g7p8jSJRLPJL99pU Generated by Claude Code |
|
Acknowledged — fold suggestion from #776 item 1 ( Triaged by Claude Code. Session: https://claude.ai/code/session_01AxfrxJvor1ZyPLFxC5MKJk Generated by Claude Code |
There was a problem hiding this comment.
Approving. Stage 4 lands the right thing in the right place — nine wire-shape error codes, a pure consistency check, and a webhook-family translation table that doesn't fragment across callsites. Verifier integration belongs to stage 5; the primitive is correctly inert until then.
Things I checked
- Webhook mirror alignment. Nine new
REQUEST_SIGNATURE_*codes (errors.py:65-79), nineWEBHOOK_SIGNATURE_*mirrors (errors.py:107-115), nine new entries inREQUEST_TO_WEBHOOK_CODE(errors.py:140-148). 1:1 across all three. No code can raise a request-family string that the webhook wrapper can't retag. - Public surface hygiene. All nine new
REQUEST_SIGNATURE_*constants pluscheck_key_origin_consistencyre-exported fromadcp.signing/__init__.pyand listed in__all__. Webhook mirrors correctly stay submodule-only — matches the existing convention that adopters translate viaREQUEST_TO_WEBHOOK_CODE, not by importing webhook constants directly. SignatureVerificationError.detailis additive. Optional keyword-only kwarg (errors.py:32).str(exc)still renders the free-form message. Defensivedict(detail)copy aterrors.py:37prevents downstream mutation of the original mapping. Non-breaking — existing raise sites continue to work.- Canonicalization symmetry. Bare-host inputs route through the same
urlsplitpath as URLs via the synthetichttps://prepend atkey_origins.py:227. Port, userinfo, query, fragment all strip identically on both sides.münchen.example↔xn--mnchen-3ya.exampleround-trips. Trailing-dot collapse works both directions. The four edge-case tests attests/test_key_origins.py:213-263pin the symmetry. - Fail-closed on unparseable input on both sides.
_origin_hostreturnsNoneon IDNA failure (key_origins.py:202), and the mismatch branch atkey_origins.py:139treatsNoneas not-equal. Tests attests/test_key_origins.py:140-150andtests/test_key_origins.py:286-297cover both sides. - Carve-out doctrine. Helper takes no
sourceparameter. Caller contract is documented atkey_origins.py:77-85and again in the module docstring atkey_origins.py:26-29. Stage 5 owns the branching at the verifier callsite. Right shape — keeps this primitive total on its inputs. - Conventional-commit semver.
feat(signing):for purely additive public surface. Correct. No!warranted.
Follow-ups (non-blocking — file as issues)
- IDNA-2003 vs 2008 divergence is real, not theoretical.
host.encode("idna")produces different A-labels than IDNA-2008-strict peers for ß, ς, ZWJ, ZWNJ. For ASCII signing-origin deployments this never fires; for German or Greek brand domains it produces false-negative_key_origin_mismatch401s against spec-conformant counterparties. The docstring atkey_origins.py:167-171acknowledges the debt and ties the migration to all four package callsites (jwks.py:201,ip_pinned_transport.py:110,revocation_fetcher.py:380, this one) — keeping the convention coherent is the right call. File the package-wide IDNA-2008 migration as a follow-up so it doesn't get lost. - Publisher-pin carve-out is English, not type.
adcp.signing.check_key_origin_consistencyis now a public ergonomic helper. An integrator wiring a publisheradagents.json signing_keysverification path will reach for the obvious helper, call it on a publisher-pinned tuple, and the helper will fail-closed against legitimate keys. Stage 5 plumbssourcethroughBrandJsonJwksResolver; consider whether the public helper should grow asource: Literal["operator_brand_json", "publisher_pin"]parameter that short-circuits on"publisher_pin"— moves the carve-out from docstring to signature. The PR description already defers this to stage 5; just flagging it doesn't get dropped on the floor. detailfields echo attacker-controlled strings in the canonicalization-failure fallback. When_origin_hostreturnsNone,key_origins.py:153-154surface the rawdeclared/jwks_uriinput verbatim intodetail. The spec contract is strings-in-strings-out, so this is defensible — but adopters loggingdetailunescaped could see CR/LF/ANSI from a hostile brand.json. Defense-in-depth: consider repr-ing the fallback values, or document the encoding requirement in theSignatureVerificationErrordocstring.SignatureVerificationErrordocstring promises{agent_url}on_brand_json_url_missing(errors.py:18-20), but no raise site in this PR delivers that detail — stage 5 raisers will. Worth pinning in a test once those raisers land so the docstring claim doesn't drift.
Minor nits (non-blocking)
rstrip(".")strips runs, not a single dot.key_origins.py:197sayshost.rstrip("."), but the docstring atkey_origins.py:188promises "strip a single trailing dot."keys.brand.com..collapses tokeys.brand.com. Not exploitable — DNS treats multi-dot as invalid — but the docstring is load-bearing on adopter audits. Eitherhost[:-1] if host.endswith(".") else host, or update the comment to "strip trailing dots."- Test assertions for the canonicalization-failure fallback pin only
.code, notdetail.tests/test_key_origins.py:131-139andtests/test_key_origins.py:286-297would still pass if a refactor silently surfacedNoneindetail["actual_origin"]/detail["expected_origin"]instead of the raw input. Addassert exc.detail["actual_origin"] == "not a url"and the symmetric assertion to lock the documented fallback contract onkey_origins.py:153-154. - Conventional-commit message body is denser than the diff. The second commit folds three expert-review passes into a stage-4 PR titled the same as the first — readers tracing the chain see two commits with near-identical headlines. Not blocking; future stages might split fold-in commits into their own PRs.
Approving on the strength of the symmetric fail-closed coverage plus the clean 1:1 webhook mirror table.
…nstall
Three of the four storyboard runners (seller_agent.py, sales-proposal-mode,
v3 reference seller (translator)) have been failing on main and on every
PR for the same upstream reason: ``@adcp/sdk@latest`` (which the storyboard
jobs install unpinned for drift-detection) does not ship
``aao-reference-formats.json`` in its npm tarball. Every step that
touches AAO format resolution rejects with:
AAO catalog (reference-formats.json) not found. Looked in:
.../node_modules/@adcp/sdk/test/lib/v2-projection-fixtures/aao-reference-formats.json
.../node_modules/@adcp/sdk/.context/adcp-3307/server/src/creative-agent/reference-formats.json
Vendor a copy at test/lib/v2-projection-fixtures/aao-reference-formats.json.
The SDK's error message itself recommends vendoring the file at the
expected path. Upstream tracking issue is adcp#3307.
Fix:
- Vendor the file at ``tests/fixtures/aao-reference-formats.json``
(sourced from ``adcontextprotocol/adcp:server/src/creative-agent/reference-formats.json``,
the canonical reference catalog the SDK is supposed to ship).
- Add three lines to each of the four storyboard jobs' ``Pre-install
@adcp/sdk`` steps to drop the file into
``$(npm root -g)/@adcp/sdk/test/lib/v2-projection-fixtures/`` after
the npm install. Idempotent — if upstream later ships the file the
``cp`` overwrites with the same bytes; if upstream moves it the
overwrite is a safer floor than the missing-file failure.
The four storyboard jobs affected:
- AdCP storyboard runner — examples/seller_agent.py
- AdCP storyboard runner — examples/multi_platform_seller (PlatformRouter)
(currently passing — doesn't exercise the AAO path — but covered for
symmetry against future drift)
- AdCP storyboard runner — v3 reference seller (translator)
- AdCP storyboard runner — sales-proposal-mode (proposal_finalize)
The vendored file is 145 KiB JSON with 50 format entries. Not committed
to the package distribution (lives under ``tests/fixtures/``).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Approving. Stage 4 lands the primitive cleanly: 9 spec-mandated codes match security.mdx §step-7 verbatim, structured {purpose, expected_origin, actual_origin} detail is the right shape, and the webhook-mirror constants are kept submodule-only so adopters can't pin to strings the spec doesn't yet enumerate.
Things I checked
SignatureVerificationError.detailis keyword-only with aNonedefault atsrc/adcp/signing/errors.py:32. Existing callsites passstep=/message=by keyword. Backwards-compatible.- Public-API audit: 9
REQUEST_SIGNATURE_*codes +REQUEST_SIGNATURE_KEY_ORIGIN_{MISSING,MISMATCH}+check_key_origin_consistencyadded tosrc/adcp/signing/__init__.py__all__. Webhook brand/key_origin mirrors stay submodule-only —grep -n "WEBHOOK_SIGNATURE_BRAND\|WEBHOOK_SIGNATURE_KEY_ORIGIN" src/adcp/signing/__init__.pyreturns nothing. Commit prefix isfeat:and the diff is purely additive — no breaking-change signal needed. _extract_hostcloses bare-host vs URL asymmetry by routing both throughurlsplitwith a synthesizedhttps://(src/adcp/signing/key_origins.py:206-228).host:port,user@host, query, fragment all strip symmetrically. Tests pin attests/test_key_origins.py:208-238.- Trailing-dot FQDN stripping at
src/adcp/signing/key_origins.py:197is symmetric — test exercises both directions attests/test_key_origins.py:191-205. _MISMATCH_CODE/_MISSING_CODEtables (src/adcp/signing/key_origins.py:69-76) are exhaustive overCodeFamily = Literal[\"request\", \"webhook\"]and fail-closed viaKeyErroron unexpected runtime values.- IDN U-label ↔ A-label round-trip exercised at
tests/test_key_origins.py:251-263(münchen.example↔xn--mnchen-3ya.example). - No layering breach — nothing under
src/adcp/types/generated_poc/touched; nothing imports from_generated.py. - The bundled
ci(storyboard):commitc9ea04eevendors the AAO reference-formats fixture as a separate commit with its own conventional-commit scope — release-please reads commit-level prefixes so the changelog stays accurate.
Follow-ups (non-blocking — file as issues)
- Confirm webhook-mirror strings with the spec author before Stage 5 wires them up.
ad-tech-protocol-expert: security.mdx §"Verifier checklist for webhooks" reuses request-family codes for the discovery chain as a shared preamble; the webhook code table does not enumeratewebhook_signature_brand_*orwebhook_signature_key_origin_*. The 9 mirror constants inerrors.py:103-116may be inventing strings. Kept submodule-only so this PR can land without exposing them — Stage 5 needs to either get them blessed upstream or route discovery-chain errors through the request family. File againstadcontextprotocol/adcp#3690. - IDNA-2008 migration tracker.
key_origins.py:201,jwks.py:201,ip_pinned_transport.py:110,revocation_fetcher.py:380all use stdlibhost.encode(\"idna\")(IDNA-2003) while the spec asks for IDNA-2008. The helper that exists to enforce step 7 inheriting the divergence is the awkward seam — practical exploit requires the attacker to also serve JWKS at the IDNA-2003-equivalent host (security-reviewer: Low), so deferral is acceptable, but file the package-wide migration tracker so this doesn't get forgotten. - Typed
sourceparameter for the publisher-pin carve-out. Persecurity-reviewerLow-6: making the publisheradagents.json signing_keyspin skip a caller-responsibility docstring contract is a footgun — Stage 5 dispatch wires this from two branches, and forgetting the skip silently over-rejects legitimate publisher-pinned keys. Considersource: Literal[\"brand_json\", \"publisher_pin\"]with the helper no-op'ing onpublisher_pin. Land in Stage 5, not here. - Unchecked test-plan item. PR description's
[ ] Verify new symbols importable from adcp.signingis unchecked.tests/test_key_origins.pyimports fromadcp.signing.key_origins(submodule), notadcp.signing(the__all__re-export). One-liner smoke test would lock the public-API surface in.
Minor nits (non-blocking)
- Redundant exception clause.
src/adcp/signing/key_origins.py:202catches(UnicodeError, UnicodeEncodeError).UnicodeEncodeErroris a subclass ofUnicodeError. Drop the second name. - PR body claims 12 new tests; file has 20. Description got out of sync between the original commit and the expert-review fold-in. Notable — fold-in commits routinely leave the description trailing the code.
- Vendored-fixture metadata.
tests/fixtures/aao-reference-formats.json(5245 lines, 145 KiB) would benefit from a.gitattributeslinguist-vendored=trueline so it doesn't dominate repo language stats, plus a top-of-file or sidecar comment pinning the upstream source path (adcontextprotocol/adcp:server/src/creative-agent/reference-formats.json) so future contributors know where to refresh from. Currently only in the commit message. if posture:vsif posture is not None:.src/adcp/signing/key_origins.py:125treatsposture=\"\"the same as omitted. Switch tois not Noneif empty-string posture might ever carry meaning to an adapter; current behavior is fine otherwise.
LGTM. Follow-ups noted below.
The AAO reference-formats fix in the previous commit cleared one of the
two missing-fixture errors and unmasked a second one of the same shape:
v1-canonical-mapping.json not found. Looked in:
.../node_modules/@adcp/sdk/schemas/cache/3.1.0-beta.2/registries/v1-canonical-mapping.json
.../node_modules/@adcp/sdk/schemas/cache/3.1.0-beta.1/registries/v1-canonical-mapping.json
.../node_modules/@adcp/sdk/schemas/cache/3.1.0-beta.0/registries/v1-canonical-mapping.json
.../node_modules/@adcp/sdk/schemas/cache/latest/registries/v1-canonical-mapping.json
Run `npm run sync-schemas` for a 3.1+ AdCP version.
Same root cause: ``@adcp/sdk`` published an npm tarball that omits the
v1-canonical-mapping schema cache for 3.1.0-beta.* versions. The
storyboard runner's v1→v2 format projection walks this registry; missing
the file causes a cascade of step failures (and incorrectly-routed error
codes like ``PRODUCT_NOT_FOUND`` instead of ``TERMS_REJECTED`` because
the canonical mapping is consulted before product lookup).
Vendor ``v1-canonical-mapping.json`` from
``adcontextprotocol/adcp:dist/schemas/3.1.0-beta.2/registries/``
(14 KiB) into ``tests/fixtures/`` and drop it into the SDK install path
the SDK looks up first (the SDK's lookup order falls back through
.2 → .1 → .0 → latest, so vendoring .2 is sufficient).
Same four storyboard jobs as the prior fix:
- examples/seller_agent.py
- examples/multi_platform_seller (PlatformRouter)
- v3 reference seller (translator)
- sales-proposal-mode (proposal_finalize)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
LGTM. Stage 4 lands the right surface area at the right layering — a pure host-comparison primitive plus the 9 reject codes the brand.json chain needs, with verifier wiring correctly deferred to Stage 5.
Things I checked
- Largest-file rule. Read
src/adcp/signing/key_origins.py(234 lines new) andtests/test_key_origins.py(297 lines new) cover-to-cover. The expert-review fixes from c0ed690 (port stripping, trailing-dot equality, IDN U/A-label equivalence, declared-side fail-closed) are correctly applied atkey_origins.py:194-203and:206-228, and the regression tests pin them attests/test_key_origins.py:222-297. - Public-API audit. 9 new
REQUEST_SIGNATURE_*constants +check_key_origin_consistencyexported fromadcp.signing— all additive.SignatureVerificationErrorgains optionaldetail: Mapping[str,str] | Nonewith defaultNone(errors.py:32-37) — non-breaking.feat(signing):is the right conventional-commit prefix; no!/BREAKING CHANGE:needed. Downstream import smoke check is green. - Type-system layering.
key_origins.pyonly imports fromadcp.signing.errors. No reach intogenerated_poc/or_generated.py. Clean. - Webhook mirror exhaustiveness. All 9 new request codes have webhook mirrors in
REQUEST_TO_WEBHOOK_CODE(errors.py:140-148).ad-tech-protocol-expert: webhook mirrors are correct — webhook verification walks the same brand.json chain consultingkey_origins.webhook_signing, so omitting any mirror would force webhook callers to fall back to request-family codes. None should be dropped. - Fail-closed posture.
security-reviewertraced 14 host-canonicalization inputs (URL vs bare, port, userinfo, trailing dot, IDN U/A-label, mixed case, IPv6, unparseable, whitespace) — all canonicalize symmetrically. TheNone-on-either-side disjunction atkey_origins.py:139correctly fail-closes; declared-side regression test attest_key_origins.py:286-297mirrors the resolved-side one at:131-139. No High or Critical findings. - Carve-out design.
source-parameter omission is defensible: noJwksSourcediscriminant exists insrc/adcp/signing/yet, and inventing one here would dictate the Stage 5 resolver shape before that resolver lands. Caller contract is documented atkey_origins.py:77-85. Acceptable because the symbol is brand-new. - Canonicalization choice (the PR explicitly asked). Stdlib IDNA-2003 matches the existing convention at
jwks.py:201,ip_pinned_transport.py:110,revocation_fetcher.py:380. The German-ß / Greek-final-sigma divergence from IDNA-2008 is real but not exploitable here — thebrand_origin_mismatchupstream gate uses the same canonicalization across the chain, and registry/CA practice rejects ß-collisions for exactly the ASCII-collision reason. Accept the convention; track the package-wide migration separately. - CI fixture vendoring (c9ea04e, 649a429). Idempotent —
cpoverwrites with the same bytes if upstream later ships the fixtures, and is a safer floor than the current missing-file failure. The 4-job duplication is tolerable behind the comment trail.
Follow-ups (non-blocking — file as issues)
code-reviewerMajor:urlsplit().hostnamecan raise on Python 3.12+ for inputs with invalid percent-encoding (https://%xx.com/). Today this would propagate as an uncaughtValueErrorfrom_extract_host(key_origins.py:217-219) instead of the documented fail-closedNone-return. Wrap bothparts.hostnameaccesses intry/except ValueError: return None. Defensive — spec-conformingjwks_urivalues never trigger it, and Stage 5's verifier integration will catch any exception type at the dispatch boundary regardless. Worth fixing in a follow-up so the helper's docstring matches its actual behavior.security-reviewerLow: non-string declared origin escapes asAttributeError/TypeError. If a misbehaving capabilities response puts a non-string underidentity.key_origins.request_signing(number, array,null),urlsplitdoesn't coerce it. Pydantic should reject upstream, but a one-lineif not isinstance(value, str): return Noneat the top of_extract_hostmatches the helper's documented "structurally invalid input →None" contract.security-reviewerLow:detail.actual_origincan leak the full rawjwks_uri(including path/query/port) when canonicalization returnsNone— the fallback at key_origins.py:153-154 surfaces the unparseable input verbatim into the structured detail that adapters publish on the 401 / DLQ. Spec-conforming inputs never hit this branch; tighten if a real verifier ever surfaces internal-network IPv6 literals.ad-tech-protocol-expertcaveat: verbatim spec field names unverified. The PR claims #3690 security.mdx step 7 mandates{purpose, expected_origin, actual_origin}. The protocol expert could not verify those exact field names against the canonicaladcontextprotocol/adcpsecurity.mdx from this checkout. If the spec usesexpected_host/resolved_hostinstead, the field-name rename will be more painful after Stage 5 ships. Cross-check against the spec SHA referenced in #350 before the verifier wires this in.- IDNA-2003 → 2008 migration tracking. File an issue covering all four callsites (
jwks.py:201,ip_pinned_transport.py:110,revocation_fetcher.py:380,key_origins.py:201) and link from each docstring. The current "promised in PR text" is fine but the tracking issue would survive the PR.
Minor nits (non-blocking)
- Stale "12 tests" claim in PR body. File has 20 tests post-expert-fixes — cosmetic mismatch with the description.
- Comment at
key_origins.py:149-152says "Use the canonicalized values when available; fall back to the raw inputs" — at that pointdeclaredandjwks_uriare the un-canonicalized originals. Tighten to "fall back to the raw input when canonicalization returnedNone." - CI fixture-vendor block is repeated four times (ci.yml:405-412, 569-576, 781-792, 887-900). A composite action would dedupe ~32 lines, but the per-job comment trail makes the duplication tolerable for now.
Two ci(storyboard): commits riding inside a feat(signing): PR is a notable choice — release-please will follow the squash-merge title, which is the right feat(signing): signal. Just worth flagging if the merged history ends up with mixed-scope second-parent commits.
Approving on the strength of the post-expert-fix regression coverage at test_key_origins.py:222-297 plus three independent expert passes converging on no Blocker / no High / no Critical.
Summary
Stage 4 of issue #350 — the verifier-side primitives the framework needs to enforce ADCP #3690's request-signing chain. Two deliverables:
request_signature_*error codes for the brand.json discovery chain + theidentity.key_originsconsistency check, plus their webhook mirrors and routing entries.adcp.signing.key_origins.check_key_origin_consistency()— the standalone primitive that compares a resolvedjwks_urihost against the declaredidentity.key_origins.{purpose}and raises the right spec code on missing/mismatch.The verifier integration (calling this check after RFC 9421 verification when the JWKS source was a brand.json walk vs. a publisher pin) belongs to stage 5's dispatch wire-up — the primitive lands here, ready to compose with the
BrandAuthorizationResolvergate from #770.New error codes (errors.py)
request_signature_brand_json_url_missingidentity.brand_json_urlrequest_signature_capabilities_unreachablerequest_signature_brand_json_unreachablerequest_signature_brand_json_malformedrequest_signature_brand_origin_mismatchrequest_signature_agent_not_in_brand_jsonagents[](byte-equal match)request_signature_brand_json_ambiguousagents[]entries byte-equal the agent URLrequest_signature_key_origin_mismatchjwks_urihost ≠ declaredidentity.key_origins.{purpose}request_signature_key_origin_missingidentity.key_origins.{purpose}declaration absentPlus nine
webhook_signature_*mirror constants and entries inREQUEST_TO_WEBHOOK_CODEso the webhook verifier wrapper continues to retag request-family codes into webhook-family ones without each callsite re-declaring the mapping.check_key_origin_consistency()Pure function on (resolved
jwks_uri, declaredkey_originsmap, purpose). RaisesSignatureVerificationErrorwithcode = *_key_origin_missingwhenpurposeis absent from the map, orcode = *_key_origin_mismatchwhen the declared origin differs from the resolved host (after canonicalization).code_family="webhook"swaps to the webhook code family.Canonicalization choice
ASCII-lowercase + stdlib
host.encode("idna")to A-label form. The spec asks for IDNA-2008 strictly while stdlibencodings.idnais IDNA-2003 — the divergence is rare in practice (some emoji domains, German β handling), and matching the package's existing convention (jwks.py:201,ip_pinned_transport.py:110,revocation_fetcher.py:380) keeps the canonicalization story coherent. A future IDNA-2008 migration would update all four callsites together; the helper is not the place to fragment the convention.Tests
12 new tests in
tests/test_key_origins.py:Nonemap, empty map, posture propagation in diagnostics.jwks_uri(fail-closed).103 tests total across stages 1–4 (
test_brand_jwks,test_etld,test_brand_authz,test_key_origins). All green, ruff + mypy clean.What's still outside this PR (Stage 5)
check_key_origin_consistency()from the verifier path when the JWKS source was a brand.json walk (skip when it's a publisheradagents.json signing_keyspin).serve(brand_authz_resolver=...)parameter and dispatch invocation ofBrandAuthorizationResolver.is_authorized()between Tier 2 registry resolution andaccounts.resolve.Refs #350, adcontextprotocol/adcp#3690
Test plan
adcp.signing(downstream import smoke)🤖 Generated with Claude Code