Skip to content

feat(signing): key_origins check + brand.json chain error codes (#350 stage 4)#775

Merged
bokelley merged 4 commits into
mainfrom
bokelley/issue-350-stage-4
May 21, 2026
Merged

feat(signing): key_origins check + brand.json chain error codes (#350 stage 4)#775
bokelley merged 4 commits into
mainfrom
bokelley/issue-350-stage-4

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Stage 4 of issue #350 — the verifier-side primitives the framework needs to enforce ADCP #3690's request-signing chain. Two deliverables:

  1. Nine new request_signature_* error codes for the brand.json discovery chain + the identity.key_origins consistency check, plus their webhook mirrors and routing entries.
  2. adcp.signing.key_origins.check_key_origin_consistency() — the standalone primitive that compares a resolved jwks_uri host against the declared identity.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 BrandAuthorizationResolver gate from #770.

New error codes (errors.py)

Code When
request_signature_brand_json_url_missing Capabilities missing identity.brand_json_url
request_signature_capabilities_unreachable Transport failure fetching capabilities
request_signature_brand_json_unreachable Transport failure fetching brand.json
request_signature_brand_json_malformed Strict-parse failure on brand.json
request_signature_brand_origin_mismatch Agent eTLD+1 ≠ brand eTLD+1 AND no operator delegation
request_signature_agent_not_in_brand_json Agent URL absent from agents[] (byte-equal match)
request_signature_brand_json_ambiguous Multiple agents[] entries byte-equal the agent URL
request_signature_key_origin_mismatch Resolved jwks_uri host ≠ declared identity.key_origins.{purpose}
request_signature_key_origin_missing Signing posture asserted, but identity.key_origins.{purpose} declaration absent

Plus nine webhook_signature_* mirror constants and entries in REQUEST_TO_WEBHOOK_CODE so 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()

def check_key_origin_consistency(
    *,
    jwks_uri: str,
    key_origins: Mapping[str, str] | None,
    purpose: str,
    posture: str | None = None,
    code_family: Literal["request", "webhook"] = "request",
) -> None: ...

Pure function on (resolved jwks_uri, declared key_origins map, purpose). Raises SignatureVerificationError with code = *_key_origin_missing when purpose is absent from the map, or code = *_key_origin_mismatch when 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 stdlib encodings.idna is 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:

  • Success path: declared = resolved (literal, bare-host declaration, case-insensitive).
  • Missing declaration: purpose absent, None map, empty map, posture propagation in diagnostics.
  • Mismatch: different host, subdomain drift (origins are exact hosts, not eTLD+1), unparseable jwks_uri (fail-closed).
  • Webhook code family: both mismatch and missing routes through the webhook mirrors.

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)

  • Calling check_key_origin_consistency() from the verifier path when the JWKS source was a brand.json walk (skip when it's a publisher adagents.json signing_keys pin).
  • serve(brand_authz_resolver=...) parameter and dispatch invocation of BrandAuthorizationResolver.is_authorized() between Tier 2 registry resolution and accounts.resolve.
  • Three-tier conformance test exercising the full chain (signed-request → brand-authz → registry → accounts.resolve).

Refs #350, adcontextprotocol/adcp#3690

Test plan

  • CI green on Python 3.10–3.13
  • Argus AI reviewer pass on the canonicalization choice (IDNA 2003 vs 2008)
  • Verify new symbols importable from adcp.signing (downstream import smoke)

🤖 Generated with Claude Code

… 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>
aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes May 21, 2026
Copy link
Copy Markdown

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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): _unreachable for retryable transport, _missing / _malformed / _mismatch / _ambiguous for misconfiguration. The brand_origin_mismatch vs key_origin_mismatch split is the right granularity — different chain steps, different reject codes.
  • REQUEST_TO_WEBHOOK_CODE completeness. 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 and check_key_origin_consistency are both imported and present in __all__ in src/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 at jwks.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 the idna PyPI 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_drift asserts 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_mismatch raises. 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/...\").hostname strips port; both sides lose it in _origin_host. Per RFC 6454 §4 an origin is (scheme, host, port). Concrete scenario: operator declares https://keys.brand.com:8443, attacker brand.json points jwks_uri to https://keys.brand.com:80/attacker.jwks on 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]/...\").hostname returns \"2001:db8::1\"; \"2001:db8::1\".encode(\"idna\") raises UnicodeError on :, so _origin_host returns None and even an identical-IPv6 declaration vs resolved comparison raises _key_origin_mismatch. Short-circuit IP literals via ipaddress.ip_address(host) before the IDNA call. (key_origins.py:157-160)
  • Bare-host fallback fails closed by accident. The if not host branch 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 at key_origins.py:155, or reparse via urlsplit(\"//\" + value) and use .hostname. Same site fixes the bare-host-with-port footgun (\"keys.brand.com:8443\" parses with hostname=None and IDNA rejects the colon).
  • Empty-string declared value routes to _mismatch, not _missing. key_origins={\"request_signing\": \"\"} survives the declared is None guard, falls through to _origin_host(\"\")None_key_origin_mismatch. Adopters parsing a malformed identity.key_origins value 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-29 says "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 a KeySource = Literal[\"brand_json\", \"publisher_pin\"] wrapper that dispatches.

Minor nits (non-blocking)

  1. No IDN equivalence test. The docstring claims IDNA-A-label canonicalization makes münchen.de (U-label) compare equal to xn--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)
  2. Mismatch message can render None. When declared_host or actual_host canonicalize to None, the error message reads declares None but resolved jwks_uri host is 'keys.brand.com'. Falling back to the raw input on None would improve diagnostics. (key_origins.py:124-127)
  3. Redundant exception in the IDNA except. UnicodeEncodeError is a subclass of UnicodeError; the second name in except (UnicodeError, UnicodeEncodeError) is harmless but noise. (key_origins.py:159)
  4. 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__.py wiring 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.

@bokelley
Copy link
Copy Markdown
Contributor Author

CI summary

All stage-4-relevant checks green:

  • Test Python 3.10 / 3.11 / 3.12 / 3.13 — pass
  • Postgres conformance tests — pass
  • code_review (Argus AI reviewer) — pass
  • Downstream import smoke — pass (confirms the new symbols are importable from adcp.signing)
  • Schema validation, conventional commits, GitGuardian, CodeQL — pass

3 storyboard runners fail — pre-existing on main, unrelated to stage 4:

  • seller_agent.py / sales-proposal-mode / v3 reference seller (translator) — all three fail on main at the same upstream issue: the @adcp/sdk Node package can't find a vendored AAO catalog (reference-formats.json). Same failure shape on the merge commits of feat(adagents): fetch_agent_authorizations_from_directory for AAO inverse lookup #769, feat(signing): BrandAuthorizationResolver (Tier 3, #350 stages 1-3) #770, and earlier — this is @adcp/sdk infrastructure drift, not a regression from this PR.

    AAO catalog (reference-formats.json) not found. Looked in:
      /opt/hostedtoolcache/node/22.22.2/x64/lib/node_modules/@adcp/sdk/test/lib/v2-projection-fixtures/aao-reference-formats.json
      /opt/hostedtoolcache/node/22.22.2/x64/lib/node_modules/@adcp/sdk/.context/adcp-3307/server/src/creative-agent/reference-formats.json
    
  • The examples/multi_platform_seller storyboard runner (which doesn't depend on the AAO catalog) passes.

The stage-4 changes — error code constants + the check_key_origin_consistency() pure function + tests — touch zero code that storyboard runners exercise. Verified by running the impacted test surface locally (test_brand_jwks, test_etld, test_brand_authz, test_key_origins): 103 tests green, 4.8s.

Expert reviews (ad-tech-protocol-expert, code-reviewer, security-reviewer) running now. Will fold their feedback before requesting review.

@bokelley
Copy link
Copy Markdown
Contributor Author

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

@bokelley
Copy link
Copy Markdown
Contributor Author

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>
@bokelley
Copy link
Copy Markdown
Contributor Author

Expert review summary + fixes pushed

Three 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

# Severity Finding Fix
1 MUST-FIX (protocol) Spec mandates structured `{purpose, expected_origin, actual_origin}` / `{purpose, posture}` detail fields on the rejection; previous code stringified them into the message Extended `SignatureVerificationError` with optional `detail: Mapping[str, str]
2 HIGH (security) Bare-host vs URL canonicalization asymmetric — bare-host fallback accepted `user@host`, `host:port`, `?query`, `#fragment` verbatim, opening a capability-write-access DoS shape Factored `_extract_host` helper that re-parses bare-host inputs through `urlsplit` with a synthetic `https://` scheme. Symmetric stripping. Tests for port-in-bare-host and userinfo-in-bare-host.
3 HIGH (security) Trailing-dot FQDN asymmetry — `host.example.` vs `host.example` byte-mismatched; same DoS-the-honest-path shape Strip a single trailing dot before IDNA-encode. Test covers both directions.
4 NICE (all 3) Docstring promised IDN U-label / A-label equivalence but no test exercised it Added `münchen.example` ↔ `xn--mnchen-3ya.example` test, both directions.
5 MEDIUM (security) Publisher-pin carve-out was in the module docstring but not the function docstring Added "Caller contract" paragraph at top of `check_key_origin_consistency` docstring.
6 LOW (security) Test coverage of fail-closed direction was asymmetric (unparseable jwks_uri tested; unparseable declared origin not) Added symmetric test on the declared side.

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)

  • Empty-string `{purpose: ""}` declaration routes to `missing` not `mismatch` (code-reviewer SHOULD chore(main): release 0.1.0 #1) — defensible either way; spec doesn't require a specific direction.
  • `_origin_host` docstring length (code-reviewer NICE chore(main): release 0.1.0 #1) — kept verbose because the rationale (PSL PRIVATE / IDNA 2003 / trailing-dot / bare-host symmetry) lives at the only callsite. Trimming would push the why-comments into a separate doc.
  • `UnicodeEncodeError` subclass redundancy (code-reviewer NICE chore(main): release 0.1.1 #2) — matches `jwks.py:202` pattern. Touching for cosmetics would split the codebase pattern.

Ready for re-review.

@bokelley
Copy link
Copy Markdown
Contributor Author

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

@bokelley
Copy link
Copy Markdown
Contributor Author

Acknowledged — nothing to act on here. Standing by.


Generated by Claude Code

@bokelley
Copy link
Copy Markdown
Contributor Author

Fold suggestion from issue triage (#776): Issue #776 asks for a ResolvedKey dataclass with a source: Literal['brand_json', 'publisher_pin'] discriminant on BrandJsonJwksResolver.resolve(). This is the data shape the Stage 5 verifier will need to gate check_key_origin_consistency() calls on — the primitive you're adding here.

If this PR still has room before merge, folding item 1 from #776 (ResolvedKey dataclass + updating resolve() to return it) would let Stage 5 build on a stable shape rather than retrofitting brand_jwks.py in a follow-up. The verifier integration (acceptance criterion 2 of #776) stays Stage 5 scope regardless.

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

@bokelley
Copy link
Copy Markdown
Contributor Author

Fold candidate note from issue triage: Issue #777 (IDNA 2003 → IDNA 2008 migration across signing/) was just filed and triaged as blocked on this PR. Since #777 overlaps the exact files this PR touches — and key_origins.py was added here — the maintainer may want to fold the IDNA-2008 migration into this PR before merge rather than leave a conformance gap on main even briefly.

The migration scope from #777: add idna to pyproject.toml deps, replace host.encode("idna") with idna.encode(host, uts46=True) in jwks.py, ip_pinned_transport.py, revocation_fetcher.py, and key_origins.py, update the cross-module rationale comments, and add a regression test for straße.de → xn--strae-oqa.de.

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

@bokelley
Copy link
Copy Markdown
Contributor Author

Acknowledged — fold suggestion from #776 item 1 (ResolvedKey dataclass + updated resolve() return type) noted. Leaving the scope decision to you; #776 remains open as a standalone follow-up if you prefer to ship Stage 4 as-is.


Triaged by Claude Code. Session: https://claude.ai/code/session_01AxfrxJvor1ZyPLFxC5MKJk


Generated by Claude Code

aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes May 21, 2026
Copy link
Copy Markdown

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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), nine WEBHOOK_SIGNATURE_* mirrors (errors.py:107-115), nine new entries in REQUEST_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 plus check_key_origin_consistency re-exported from adcp.signing/__init__.py and listed in __all__. Webhook mirrors correctly stay submodule-only — matches the existing convention that adopters translate via REQUEST_TO_WEBHOOK_CODE, not by importing webhook constants directly.
  • SignatureVerificationError.detail is additive. Optional keyword-only kwarg (errors.py:32). str(exc) still renders the free-form message. Defensive dict(detail) copy at errors.py:37 prevents downstream mutation of the original mapping. Non-breaking — existing raise sites continue to work.
  • Canonicalization symmetry. Bare-host inputs route through the same urlsplit path as URLs via the synthetic https:// prepend at key_origins.py:227. Port, userinfo, query, fragment all strip identically on both sides. münchen.examplexn--mnchen-3ya.example round-trips. Trailing-dot collapse works both directions. The four edge-case tests at tests/test_key_origins.py:213-263 pin the symmetry.
  • Fail-closed on unparseable input on both sides. _origin_host returns None on IDNA failure (key_origins.py:202), and the mismatch branch at key_origins.py:139 treats None as not-equal. Tests at tests/test_key_origins.py:140-150 and tests/test_key_origins.py:286-297 cover both sides.
  • Carve-out doctrine. Helper takes no source parameter. Caller contract is documented at key_origins.py:77-85 and again in the module docstring at key_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_mismatch 401s against spec-conformant counterparties. The docstring at key_origins.py:167-171 acknowledges 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_consistency is now a public ergonomic helper. An integrator wiring a publisher adagents.json signing_keys verification 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 plumbs source through BrandJsonJwksResolver; consider whether the public helper should grow a source: 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.
  • detail fields echo attacker-controlled strings in the canonicalization-failure fallback. When _origin_host returns None, key_origins.py:153-154 surface the raw declared / jwks_uri input verbatim into detail. The spec contract is strings-in-strings-out, so this is defensible — but adopters logging detail unescaped 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 the SignatureVerificationError docstring.
  • SignatureVerificationError docstring 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)

  1. rstrip(".") strips runs, not a single dot. key_origins.py:197 says host.rstrip("."), but the docstring at key_origins.py:188 promises "strip a single trailing dot." keys.brand.com.. collapses to keys.brand.com. Not exploitable — DNS treats multi-dot as invalid — but the docstring is load-bearing on adopter audits. Either host[:-1] if host.endswith(".") else host, or update the comment to "strip trailing dots."
  2. Test assertions for the canonicalization-failure fallback pin only .code, not detail. tests/test_key_origins.py:131-139 and tests/test_key_origins.py:286-297 would still pass if a refactor silently surfaced None in detail["actual_origin"] / detail["expected_origin"] instead of the raw input. Add assert exc.detail["actual_origin"] == "not a url" and the symmetric assertion to lock the documented fallback contract on key_origins.py:153-154.
  3. 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>
aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes May 21, 2026
Copy link
Copy Markdown

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.detail is keyword-only with a None default at src/adcp/signing/errors.py:32. Existing callsites pass step= / message= by keyword. Backwards-compatible.
  • Public-API audit: 9 REQUEST_SIGNATURE_* codes + REQUEST_SIGNATURE_KEY_ORIGIN_{MISSING,MISMATCH} + check_key_origin_consistency added to src/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__.py returns nothing. Commit prefix is feat: and the diff is purely additive — no breaking-change signal needed.
  • _extract_host closes bare-host vs URL asymmetry by routing both through urlsplit with a synthesized https:// (src/adcp/signing/key_origins.py:206-228). host:port, user@host, query, fragment all strip symmetrically. Tests pin at tests/test_key_origins.py:208-238.
  • Trailing-dot FQDN stripping at src/adcp/signing/key_origins.py:197 is symmetric — test exercises both directions at tests/test_key_origins.py:191-205.
  • _MISMATCH_CODE / _MISSING_CODE tables (src/adcp/signing/key_origins.py:69-76) are exhaustive over CodeFamily = Literal[\"request\", \"webhook\"] and fail-closed via KeyError on unexpected runtime values.
  • IDN U-label ↔ A-label round-trip exercised at tests/test_key_origins.py:251-263 (münchen.examplexn--mnchen-3ya.example).
  • No layering breach — nothing under src/adcp/types/generated_poc/ touched; nothing imports from _generated.py.
  • The bundled ci(storyboard): commit c9ea04ee vendors 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 enumerate webhook_signature_brand_* or webhook_signature_key_origin_*. The 9 mirror constants in errors.py:103-116 may 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 against adcontextprotocol/adcp#3690.
  • IDNA-2008 migration tracker. key_origins.py:201, jwks.py:201, ip_pinned_transport.py:110, revocation_fetcher.py:380 all use stdlib host.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 source parameter for the publisher-pin carve-out. Per security-reviewer Low-6: making the publisher adagents.json signing_keys pin 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. Consider source: Literal[\"brand_json\", \"publisher_pin\"] with the helper no-op'ing on publisher_pin. Land in Stage 5, not here.
  • Unchecked test-plan item. PR description's [ ] Verify new symbols importable from adcp.signing is unchecked. tests/test_key_origins.py imports from adcp.signing.key_origins (submodule), not adcp.signing (the __all__ re-export). One-liner smoke test would lock the public-API surface in.

Minor nits (non-blocking)

  1. Redundant exception clause. src/adcp/signing/key_origins.py:202 catches (UnicodeError, UnicodeEncodeError). UnicodeEncodeError is a subclass of UnicodeError. Drop the second name.
  2. 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.
  3. Vendored-fixture metadata. tests/fixtures/aao-reference-formats.json (5245 lines, 145 KiB) would benefit from a .gitattributes linguist-vendored=true line 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.
  4. if posture: vs if posture is not None:. src/adcp/signing/key_origins.py:125 treats posture=\"\" the same as omitted. Switch to is not None if 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>
Copy link
Copy Markdown

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) and tests/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 at key_origins.py:194-203 and :206-228, and the regression tests pin them at tests/test_key_origins.py:222-297.
  • Public-API audit. 9 new REQUEST_SIGNATURE_* constants + check_key_origin_consistency exported from adcp.signing — all additive. SignatureVerificationError gains optional detail: Mapping[str,str] | None with default None (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.py only imports from adcp.signing.errors. No reach into generated_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 consulting key_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-reviewer traced 14 host-canonicalization inputs (URL vs bare, port, userinfo, trailing dot, IDN U/A-label, mixed case, IPv6, unparseable, whitespace) — all canonicalize symmetrically. The None-on-either-side disjunction at key_origins.py:139 correctly fail-closes; declared-side regression test at test_key_origins.py:286-297 mirrors the resolved-side one at :131-139. No High or Critical findings.
  • Carve-out design. source-parameter omission is defensible: no JwksSource discriminant exists in src/adcp/signing/ yet, and inventing one here would dictate the Stage 5 resolver shape before that resolver lands. Caller contract is documented at key_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 — the brand_origin_mismatch upstream 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 — cp overwrites 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-reviewer Major: urlsplit().hostname can raise on Python 3.12+ for inputs with invalid percent-encoding (https://%xx.com/). Today this would propagate as an uncaught ValueError from _extract_host (key_origins.py:217-219) instead of the documented fail-closed None-return. Wrap both parts.hostname accesses in try/except ValueError: return None. Defensive — spec-conforming jwks_uri values 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-reviewer Low: non-string declared origin escapes as AttributeError/TypeError. If a misbehaving capabilities response puts a non-string under identity.key_origins.request_signing (number, array, null), urlsplit doesn't coerce it. Pydantic should reject upstream, but a one-line if not isinstance(value, str): return None at the top of _extract_host matches the helper's documented "structurally invalid input → None" contract.
  • security-reviewer Low: detail.actual_origin can leak the full raw jwks_uri (including path/query/port) when canonicalization returns None — 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-expert caveat: 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 canonical adcontextprotocol/adcp security.mdx from this checkout. If the spec uses expected_host / resolved_host instead, 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)

  1. Stale "12 tests" claim in PR body. File has 20 tests post-expert-fixes — cosmetic mismatch with the description.
  2. Comment at key_origins.py:149-152 says "Use the canonicalized values when available; fall back to the raw inputs" — at that point declared and jwks_uri are the un-canonicalized originals. Tighten to "fall back to the raw input when canonicalization returned None."
  3. 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.

@bokelley bokelley merged commit 35f581d into main May 21, 2026
19 checks passed
@bokelley bokelley deleted the bokelley/issue-350-stage-4 branch May 21, 2026 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant