feat(signing): verifier key_origins check + IDNA-2008 canonicalization (closes #776, #777)#789
Conversation
There was a problem hiding this comment.
Holding for two blockers in the dependency floor and the production-helper wiring — both fail-open where this PR claims to fail-closed. IDNA-2008 migration and the verifier-side _maybe_check_key_origin are otherwise the right shape.
Must fix before merge
1. idna>=3.0 floor admits CVE-2024-3651. pyproject.toml:108. idna 3.0–3.6 has a quadratic-complexity DoS in idna.encode() — the exact function this PR adds at four hot verifier callsites. An attacker-controlled IDN host in a counterparty's jwks_uri triggers the bug on every signature verification. The PR's own justification for stating the dep directly is "closes the supply-chain contract" — then close it: pin idna>=3.7,<4. One character; half the point of pinning the floor.
2. verify_from_agent_url bypasses the new check. src/adcp/signing/agent_resolver.py:608-618. The SDK's recommended buyer-side entry point builds StaticJwksResolver(resolution.jwks) and never sets expected_key_origins. _maybe_check_key_origin skips on both counts (jwks_source absent, expected_key_origins=None). The brand.json walk that produced resolution.jwks IS the operator-attested source the new check defends — that's exactly where the shared-tenancy spoof bites. As shipped, the closes-#776 wiring engages only when an adopter hand-builds VerifyOptions; the production helper is unprotected. Either (a) wrap the resolved JWKS in a jwks_source = \"brand_json\" marker resolver and surface identity.key_origins from the capabilities fetch into AgentResolution, or (b) call check_key_origin_consistency directly inside verify_from_agent_url after resolution. If staging that as a follow-up PR is preferred, file the issue and edit this PR's body to state the production wiring is deferred — but as written the SDK ships a defense its own recommended helper doesn't use.
Things I checked
- Four IDNA-2008 callsites use byte-identical encoding (
idna.encode(host, uts46=True).decode(\"ascii\")) and the same exception triplet. No asymmetric-canonicalization path insidesrc/adcp/signing/. _maybe_check_key_originfires after_check_key_purpose(verifier.py:263) and before crypto verify — matches the spec checklist step 7 placement.- Duck-typed
jwks_source != \"brand_json\"discriminant correctly treats\"publisher_pin\"and absence as skip. - Eszett regression test pins all three required directions —
straße.de ↔ xn--strae-oqa.dematches both ways,straße.de ↔ strasse.deraisesrequest_signature_key_origin_mismatch. Without the negative case the migration would be silent under a future refactor. expected_key_origins: Mapping[str, str]matches the canonical wire shape atschemas/cache/3.0/protocol/get-adcp-capabilities-response.json:982-1008— one origin per purpose, not a list.tests/conformance/signing/test_verifier_key_origins.py(359 lines) covers the five behavior cases — brand-json match / mismatch / missing, publisher-pin skip, legacy-resolver skip — andtest_key_unknown_still_surfaces_before_origin_checkpins the rejection ordering against future refactor.VerifyOptionsfield additions are all backward-compat defaults —feat(signing):semver signal is correct.
Follow-ups (non-blocking — file as issues)
- Pass
transitional_processing=Falseexplicitly at all fouridna.encodesites. The current default isFalse(Eszett-preserving), but>=3.0admits future minors that could flip it. Self-documents intent and locks the canonicalization regardless of upstream default drift. - Warn when
expected_key_origins is NoneANDjwks_source == \"brand_json\". ABrandJsonJwksResolverconfigured without origins is observable misconfig — make it aUserWarning, not a silent skip. - Warn when
expected_key_originsis supplied AND the resolver carries nojwks_source. Legacy adopter resolvers upgrading the SDK silently lose the check; a one-shotDeprecationWarninggives the upgrade signal. - Cold-cache
jwks_uri=Nonepath routes through mismatch with synthetic empty-string actual origin (verifier.py:547,key_origins.py:155). Fail-closed posture is right; the error detail is ambiguous on dashboards. ConsiderREQUEST_SIGNATURE_JWKS_UNAVAILABLEfor the resolver-misconfig branch. - Protocol-fidelity confirms: `ad-tech-protocol-expert` flagged two spec-text questions worth confirming against ADCP #3690 — (1) does step 7 mandate IDNA-2008 explicitly, and (2) for publisher-pinned tuples is the spec "no check" or "alternative check (publisher eTLD+1)"? The PR's reading is internally consistent with
errors.py:73-79andkey_origins.py:26-29but the spec PR itself is the source of truth.
Minor nits (non-blocking)
jwks_sourceannotation widens to a value the class never produces.src/adcp/signing/brand_jwks.py:350types it asClassVar[Literal[\"brand_json\", \"publisher_pin\"]].ClassVar[Literal[\"brand_json\"]]would let mypy catch a future reassignment typo. Style only.- Redundant
UnicodeEncodeErrorin the exception triplet at all four IDNA callsites.UnicodeEncodeErroris a subclass ofUnicodeError. Harmless; trim or leave. jwks.py:201comment says "transitional_processing=False" but the call passes onlyuts46=True. Either pass the kwarg or trim the parenthetical — currently a reader has to know the package default.- No regression test for the documented
jwks_uri=Nonefail-closed path atverifier.py:542-546. Behavior is correct via empty-string-routes-to-mismatch; the path lacks a pin. - Test plan: six boxes, none checked. Prose states
5028 passed,mypy clean across 811 files,ruff clean— the actual values are stated, the checkboxes aren't. Habit-of-ticking-them-as-you-go, not a hold.
Request changes — idna>=3.7,<4 is non-negotiable; the verify_from_agent_url plumbing is either in this PR or explicitly deferred in the PR body.
…oses #777) The four IDNA callsites in adcp.signing — JWKS host pinning, IP-pinned transport, governance revocation issuer normalization, and the identity.key_origins consistency check — used stdlib ``host.encode("idna")`` (IDNA-2003). IDNA-2003 maps Eszett (``ß``) to ``ss`` and collapses final-sigma, both of which IDNA-2008 (UTS#46) preserves as Punycode. A counterparty hosting ``straße.de`` would canonicalize under IDNA-2003 to ``strasse.de`` — a different registrable domain than the spec mandates, with two paths a capability/brand.json-write attacker could exploit (silently matching a non-owned origin, or denying a legitimate one). Migrate all four sites to ``idna.encode(host, uts46=True)`` and add ``idna>=3.0`` to the project's runtime dependencies (it's already a transitive httpx dep, but stating it directly closes the supply-chain contract). Pin to >=3 because earlier majors had different IDNA semantics for several boundary cases. Regression test in ``tests/test_key_origins.py`` pins the behavior change explicitly: ``straße.de`` ↔ ``xn--strae-oqa.de`` matches, while ``straße.de`` ↔ ``strasse.de`` does NOT — without the negative case the migration is silent if a future refactor reverts to IDNA-2003. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#776) PR #775 landed ``check_key_origin_consistency`` as a standalone helper but didn't wire it into ``verify_request_signature``. Per ADCP #3690 §step 7 the check is mandatory only when the JWKS source for the (agent, purpose, role) tuple was the operator brand.json; publisher- pinned tuples skip it (the JWKS origin is the publisher's domain by design, not the operator's). The verifier needs both a way to learn the JWKS source AND a place to receive the declared ``identity.key_origins`` map. **JWKS source discriminant.** Introduce a duck-typed ``jwks_source`` class attribute on JWKS resolvers, valued ``"brand_json"`` or ``"publisher_pin"``. ``BrandJsonJwksResolver`` declares ``jwks_source = "brand_json"``; the publisher-pin path (``CachingJwksResolver``, ``StaticJwksResolver``) doesn't declare the attribute, and the verifier treats absence as publisher-pin-equivalent (= skip the check). This preserves the existing ``JwksResolver`` Protocol — adopter-supplied resolvers that predate this attribute keep working without behavior change. **VerifyOptions wiring.** Three new fields: * ``expected_key_origins: Mapping[str, str] | None`` — the signing peer's declared ``identity.key_origins`` map; ``None`` skips the check unconditionally (adopters who haven't yet plumbed capabilities through see no behavior change). * ``signing_purpose: str`` — the purpose key to look up (default ``"request_signing"``). * ``posture: str | None`` — diagnostic context attached to ``_key_origin_missing`` rejections. The check runs after JWK resolution + key-purpose validation but before crypto verify — a misconfigured tuple is rejected before paying the Ed25519/ECDSA cost. Errors surface as the existing ``request_signature_key_origin_{mismatch,missing}`` codes from ``errors.py``; no new error codes. Test coverage in ``tests/conformance/signing/test_verifier_key_origins.py``: brand-json-source mismatch raises the spec code with structured ``detail`` (purpose/expected_origin/actual_origin); brand-json-source missing declaration raises the missing code with posture context; publisher-pin-source mismatch does NOT raise (check skipped); legacy resolver without the attribute does NOT raise (Protocol compatibility); ``key_unknown`` and other earlier-step codes still short-circuit before the origin check; ``expected_key_origins=None`` default skips the check entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…iring Argus review on PR #789 flagged two MUST-FIX items, both fail-open where the PR claimed to fail-closed. **1. CVE-2024-3651 — bump idna floor to 3.7** ``idna>=3.0`` admits idna 3.0–3.6 which carry a quadratic-complexity DoS in ``idna.encode()``. The four signing-side callsites (jwks.py:201, ip_pinned_transport.py:110, revocation_fetcher.py:380, key_origins.py) all hand the function attacker-influenceable hostnames (JWKS URI authority, redirect targets, revocation issuer canonicalization, operator-declared key_origins). Bump the floor to 3.7 and cap at <4 because IDNA semantics across a major are not contracted. **2. verify_from_agent_url engages the new key_origins check** The SDK's recommended buyer-side helper built ``StaticJwksResolver(resolution.jwks)`` without an ``expected_key_origins`` map, so the verifier's ``_maybe_check_key_origin`` step skipped on both counts: ``jwks_source`` was absent on the bare resolver AND ``expected_key_origins`` was None. The closes-#776 wiring engaged only when an adopter hand-built ``VerifyOptions`` — the production helper shipped a defense its own recommended path didn't use. Two changes to fix: - ``AgentResolution`` carries a new ``key_origins: dict[str, str] | None`` field, populated from ``identity.key_origins`` on the capabilities fetch via a new ``_extract_key_origins`` helper (forward-compat: reads the raw dict, filters non-string entries, returns None on absence so legacy 3.0 operators don't trip the missing-declaration rejection). - A new private ``_BrandJsonStaticJwksResolver`` subclass of ``StaticJwksResolver`` carries ``jwks_source = "brand_json"`` so the verifier engages the consistency check. ``verify_from_agent_url`` uses this wrapper and threads ``resolution.key_origins`` into ``VerifyOptions.expected_key_origins``. Two new kwargs (``signing_purpose``, ``posture``) propagate to the verifier so webhook callers can route through ``webhook_signing`` instead of the request-side default. Four new tests in ``test_verify_from_agent_url.py`` pin the wiring: key_origins threading, signing_purpose propagation, the brand_json source-marker on the resolver class, and the None-origins back-compat path for legacy 3.0 operators who haven't published key_origins yet. Full impacted test surface: 607 passed, 2 skipped. ruff + mypy clean. Refs #776 (Argus review feedback), CVE-2024-3651 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
707c009 to
66b17de
Compare
|
Pushed 66b17de3 addressing both MUST-FIX items. #1 — CVE-2024-3651 pin
#2 —
|
Re-review: both MUST-FIX items look good#1 — CVE-2024-3651 pin: #2 —
Positioning of The four deferred items are correctly categorized as non-blocking:
Four Aligned on shipping the two MUST-FIX items now. Happy to review the follow-up issues when they're filed. Reviewed by Claude Code. Session: https://claude.ai/code/session_01Y9cTwcXUwzZFYtWmAufJhV Generated by Claude Code |
There was a problem hiding this comment.
Blocker — the verify_from_agent_url re-wiring engages the spec's consistency check but fails closed on the legitimate path. The marker class threads through; the host it should compare against does not.
MUST FIX
_BrandJsonStaticJwksResolver never carries jwks_uri.
src/adcp/signing/agent_resolver.py:677-694 adds the jwks_source = "brand_json" discriminant but no __init__ — it inherits StaticJwksResolver.__init__(self, jwks) (src/adcp/signing/jwks.py:366-367), which stores only the JWK set. The construction at src/adcp/signing/agent_resolver.py:665 is _BrandJsonStaticJwksResolver(resolution.jwks) — resolution.jwks_uri is dropped.
src/adcp/signing/verifier.py:540 reads jwks_uri = getattr(resolver, "jwks_uri", None) → None → check_key_origin_consistency(jwks_uri="", ...). _origin_host("") returns None (src/adcp/signing/key_origins.py:196-205); the mismatch branch (key_origins.py:141) fires with detail={"actual_origin": ""}.
Trace: operator publishes identity.key_origins = {"request_signing": "https://keys.brand.example"} with jwks_uri = "https://keys.brand.example/.well-known/jwks.json". Buyer calls verify_from_agent_url(...). Resolution succeeds. Signature verifies. Step 7 raises request_signature_key_origin_mismatch with expected_origin=\"keys.brand.example\", actual_origin=\"\".
Net: every operator that publishes identity.key_origins fails verification through the SDK's recommended buyer helper. The shared-tenancy-spoof defense the PR closes #776 to add rejects legitimate signers instead of just adversarial ones. This is the same MUST-FIX surface the prior review flagged — the marker class went in, the URI plumbing did not.
Fix shape (one __init__, one kwarg at the construction site):
class _BrandJsonStaticJwksResolver(StaticJwksResolver):
jwks_source: ClassVar[Literal[\"brand_json\", \"publisher_pin\"]] = \"brand_json\"
def __init__(self, jwks: dict[str, Any], *, jwks_uri: str) -> None:
super().__init__(jwks)
self.jwks_uri = jwks_uriAnd jwks_resolver=_BrandJsonStaticJwksResolver(resolution.jwks, jwks_uri=resolution.jwks_uri) at agent_resolver.py:665.
Test coverage gap that masked it.
tests/test_verify_from_agent_url.py monkeypatches verify_starlette_request in every test (lines 92-93, 215-216, 278-279, 310-311, 344-345, 378-379), so _maybe_check_key_origin is never reached. The one resolver-shape assertion at line 326 (test_factory_resolver_carries_brand_json_source_marker) checks type(resolver).jwks_source but never resolver.jwks_uri. The conformance suite in tests/conformance/signing/test_verifier_key_origins.py:47-64 uses a separate test double that does set self.jwks_uri = jwks_uri, so it passes — but it never exercises the production class. The two surfaces never meet.
Add an integration test in tests/test_verify_from_agent_url.py that does NOT mock verify_starlette_request and runs a real signed request through the production _BrandJsonStaticJwksResolver with key_origins matching the resolved host. That test must be in the same commit as the fix, otherwise the next attempt regresses the same way.
code-reviewer, security-reviewer, and ad-tech-protocol-expert all independently traced the same path. security-reviewer graded it High; code-reviewer Blocker; ad-tech-protocol-expert flagged the diagnostic shape (actual_origin=\"\" leaks empty when actual_host is None) but the underlying defect is the missing jwks_uri.
Things I checked
- IDNA-2008 migration at all four callsites (
jwks.py:210,ip_pinned_transport.py:117,revocation_fetcher.py:387,key_origins.py:203) is consistent and usesuts46=True(transitional off) — Eszett regression test attests/test_key_origins.py:286-309includes both positive AND negative cases, so a future revert to IDNA-2003 doesn't slip silently - CVE-2024-3651 floor
idna>=3.7,<4atpyproject.toml:114is correct per GHSA-jjg7-2v4v-x38h VerifyOptionsfield additions (expected_key_origins,signing_purpose,posture) are dataclass defaults — backwards compatible;feat:(no!) is the right semver signal_extract_key_origins(agent_resolver.py:332-357) filters non-string entries and treats empty asNone— safe against malformed capabilities- Verifier step ordering:
_maybe_check_key_originruns after_check_key_purposeand before revocation (verifier.py:274-279) — matches the spec's checklist - Duck-typed
jwks_sourcepreserves theJwksResolverProtocol contract — adopter resolvers without the attribute default to publisher-pin semantics (skip) - Public-API audit: new field on
AgentResolution, new kwargs onverify_from_agent_url, three new fields onVerifyOptions— all additive with defaults, no breaking change
Follow-ups (non-blocking — file as issues after the MUST FIX lands)
- IDNA-2008 + IP literals.
idna.encode(\"192.0.2.1\", uts46=True)raises (IDNA-2008 rejects purely-numeric labels); stdlibhost.encode(\"idna\")returned the ASCII as-is in some paths. Adopters withallow_private=Truedev setups using IP-literal JWKS URIs may now seeSSRFValidationError: URI host '...' is not IDNA-valid. Gate the four callsites with anipaddress.ip_address(host)short-circuit so IP literals skip IDNA entirely _origin_hosttrailing-dot stripping.key_origins.py:199useshost.rstrip(\".\")(all trailing dots); docstring at lines 186-190 says "single trailing dot." Match the code to the docstring (host[:-1] if host.endswith(\".\")elsehost) or update the docstring — the asymmetry argument the docstring makes holds for one dot, not for arbitrary chains- Duck-typed discriminant — silent skip for adopter resolvers. An adopter on the new SDK whose custom resolver IS brand-json-sourced but doesn't advertise
jwks_sourcesilently bypasses the check. Document this loudly in the next release note; consider aruntime_checkableBrandSourcedJwksResolverProtocol so the discriminant surfaces as a type contract _BrandJsonStaticJwksResolverdefined below its caller.agent_resolver.py:665references the class defined at line 677. Functionally safe (call-time name resolution) but brittle to refactor. Move the class aboveverify_from_agent_url
Minor nits (non-blocking)
_extract_key_originslacks a per-entry length cap.agent_resolver.py:332-357is bounded only by the 64KiB capabilities-body cap. A 253-byte (DNS hostname limit) per-value clamp would tighten the surface. Non-exploitable today.expected_originfield on_mismatchfalls back to raw URL when canonicalization fails.key_origins.py:155-156—actual_originbecomes the full URL (not host-only) when_origin_hostreturnsNone. Inconsistent with the field name on the success path. Diagnostic-only.
Ship it once the jwks_uri plumbing lands with an integration test that drives the production resolver class through verify_request_signature without mocking the verifier.
Argus second-pass review caught a real bug: the marker subclass added in 66b17de carried the jwks_source discriminant but inherited StaticJwksResolver.__init__(self, jwks) which stores only the JWK set, not a jwks_uri. The verifier's _maybe_check_key_origin step reads getattr(resolver, 'jwks_uri', None) to compare the resolved host against the declared identity.key_origins[purpose] origin. With jwks_uri=None the canonicalization returned None, the mismatch branch fired with empty actual_origin, and every legitimate signer through verify_from_agent_url rejected. Fix: _BrandJsonStaticJwksResolver.__init__ accepts jwks positional + jwks_uri keyword and stores jwks_uri on self. verify_from_agent_url threads resolution.jwks_uri through. Test coverage gap that masked the bug: every existing test in test_verify_from_agent_url.py monkeypatched verify_starlette_request, so _maybe_check_key_origin was never reached against the production resolver class. Four new integration tests drive the production resolver through the real _maybe_check_key_origin step without mocking the verifier. Refs Argus review on #789 (second pass). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Pushed 7801f612 — Argus was right, real bug, fixed.
Fix:
Four new integration tests in
520 tests pass ( The non-blocking items from the second review (IDNA + IP literals, trailing-dot stripping, runtime_checkable Protocol for the discriminant, class-definition order) noted for follow-up issues after this lands. Same for the minor nits (per-entry length cap on |
|
Thanks for the detailed write-up. The Triaged by Claude Code. Session: https://claude.ai/code/session_01LyiBfX4oZcuGjVrKKZS7X6 Generated by Claude Code |
There was a problem hiding this comment.
Approving on the strength of the IDNA migration covering all four callsites cleanly plus the integration regression tests that pin both prior-pass bugs against future drift. Public-API additions are backward-compatible with safe defaults across the board.
Things I checked
_BrandJsonStaticJwksResolverconstruction is structurally safe.agent_resolver.py:703-707defines__init__(self, jwks: dict[str, Any], *, jwks_uri: str)— keyword-only required. The only in-tree call site atagent_resolver.py:655threadsjwks_uri=resolution.jwks_uri, andAgentResolution.jwks_uriis populated from a successful brand.json walk or raisesAgentResolverErrorbefore reaching that line. The "marker without URI" trap from the second Argus pass cannot reconstitute.- Order of operations.
_maybe_check_key_originfires atverifier.py:274-279— afterjwks_resolver(keyid)and_check_key_purpose, before revocation, replay cap, and crypto verify. Correct: a misconfigured tuple is rejected cheaply before Ed25519/ECDSA. - Fail-closed on cold-cache
jwks_uri. Thejwks_uri or ""path atverifier.py:548routes through_origin_host("")→None→check_key_origin_consistencyraisesrequest_signature_key_origin_mismatch. Comment atverifier.py:543-546matches the actual trace. - IDNA-2008 regression test pins both arms.
tests/test_key_origins.py:286-316asserts the positivestraße.de↔xn--strae-oqa.dematches AND the negativestraße.de↔strasse.deraises. Without the negative arm a silent IDNA-2003 regression would pass. - CVE-2024-3651 floor is justified.
idna>=3.7,<4atpyproject.toml:108. All four callsites operate onurlsplit().hostname, which RFC 3986 bounds via DNS label syntax — no unbounded host length reachesidna.encode. - Public-API surface is backward-compatible. Three new
VerifyOptionsfields (expected_key_origins,signing_purpose,posture) all carry safe defaults;AgentResolution.key_originsisOptionalwithNonedefault.feat(signing):is the right conventional-commit prefix — no!needed. - Duck-typed protocol stays compatible. Legacy
JwksResolverProtocol adopters withoutjwks_sourceskip the check (treated as publisher-pin-equivalent). Confirmed intests/conformance/signing/test_verifier_key_origins.py:774-796. - All four IDNA callsites converted. No stray
host.encode("idna")remains undersrc/adcp/signing/. Exception handlers are uniform:(idna.IDNAError, UnicodeError, UnicodeEncodeError). code-reviewer: sound-with-caveats.security-reviewer: secure (one Low — same unknown-discriminant point).ad-tech-protocol-expert: sound-with-caveats; flags the purpose-enumeration drift question separately.
Follow-ups (non-blocking — file as issues)
- Unknown
jwks_sourcediscriminant fail-opens atverifier.py:540.source != \"brand_json\"silently skips on any non-\"brand_json\"value, including typos (\"brand-json\",\"BRAND_JSON\") and future variants. For internal usage this is unreachable (theClassVar[Literal[...]]constrains it); for adopter-supplied resolvers a fat-finger silently disables the shared-tenancy-spoof defense. Consider tightening toif source not in (\"brand_json\", \"publisher_pin\"): raiseor exporting aJwksSourceenum with an unknown-value warning. Persecurity-reviewer: Low severity — adopters who control the resolver also control the trust anchor, and signature verification still gates the request. - Purpose enumeration drift.
key_origins.pydocuments four purposes (request_signing,webhook_signing,governance_signing,tmp_signing). Confirmsigning_purposevalidation against this list at a future tightening pass — currently any string flows through. Worth pinning against the upstream spec's normative purpose list before 3.1. - 3.1 wire-shape forward-compat.
_extract_key_origins(agent_resolver.py:332-357) readsidentity.key_originsasdict[str, str]underadditionalProperties: true. If 3.1 reshapes the field to a list-of-objects with posture/rotation metadata, theisinstance(origin, str)filter would silently drop entries rather than fail closed. Worth a defensive warning on non-string values, not a quiet drop.
Minor nits (non-blocking)
- Class definition ordering.
_BrandJsonStaticJwksResolveris defined afterverify_from_agent_urlreferences it (agent_resolver.py:677vsagent_resolver.py:655). Python's name resolution at call time saves this, but top-down readability suffers. Move the class above the function. - Test-double duplicates production class.
tests/conformance/signing/test_verifier_key_origins.py:555defines a local_BrandJsonStaticResolverthat re-implements the production_BrandJsonStaticJwksResolver. The new integration tests intests/test_verify_from_agent_url.py:1107-1205cover the production class against the real_maybe_check_key_origin— fine — but the test-double name is a near-miss with the production class. - Test reads
type(resolver).jwks_source, verifier readsresolver.jwks_source.tests/test_verify_from_agent_url.py:1061mirrors a different access path thanverifier.py:539. Both succeed for aClassVar, but the test should match production access.
Notable that this is the third Argus pass on the same PR; the two prior-pass bugs both picked up dedicated regression tests this time (test_factory_construction_threads_jwks_uri_into_resolver, test_brand_json_static_resolver_carries_jwks_uri_attribute). The fixes themselves landed under their own regressions — which is the right shape.
Ship it.
… contract, misconfig warnings, diagnostic hygiene (#798) * refactor(signing): centralize IDNA host canonicalization, add IP literal short-circuit Argus follow-ups on #789 — three IDNA-hygiene items consolidated into one helper used by all four callsites. 1. IP literal short-circuit. idna.encode('192.0.2.1', uts46=True) raises in IDNA-2008 mode (rejects purely-numeric labels). Adopters on allow_private=True dev setups with IP-literal JWKS URIs regressed to SSRFValidationError after #789. Fix: gate with ipaddress.ip_address before IDNA; v4 + v6 (bracketed and naked) pass through. IPv6 compressed-canonical normalization happens via str(ipaddress.IPv6Address(...)). 2. transitional=False explicit at the single helper callsite. Default in idna>=3.x is already False (Eszett-preserving), but pinning documents intent and locks against future upstream default flip. (Note: idna package spells the kwarg 'transitional', not the UTS#46-document 'transitional_processing'.) 3. Single trailing-dot stripping. Previous rstrip('.') stripped arbitrary chains; docstring claimed single dot. Now matches. Plus a bare-IPv6 fix in _extract_host: urlsplit('https://2001:db8::1') interprets the first ':' as port separator. Detect bare IPv6 via ipaddress.ip_address and bracket-wrap before urlsplit. New module: src/adcp/signing/_idna_canonicalize.py (canonicalize_host). Four callsites now delegate: jwks.py, ip_pinned_transport.py, revocation_fetcher.py, key_origins.py. 3 new IP-literal regression tests in test_key_origins.py. 614 tests across impacted surface remain green. ruff + mypy clean. Refs Argus second-pass review on #789. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(signing): public BrandSourcedJwksResolver Protocol + tighten jwks_source annotation Argus follow-ups on #789 grouped by jwks_source contract. 1. BrandSourcedJwksResolver — runtime_checkable Protocol surfacing the duck-typed jwks_source + jwks_uri contract as a typed predicate. Verifier-side isinstance() checks work, and adopters declaring custom brand.json-walking resolvers can opt in by setting the two attributes without inheriting from this Protocol (typing.Protocol's structural conformance). Lives next to JwksResolver / AsyncJwksResolver in adcp.signing.jwks; re-exported from adcp.signing. 2. Tighten ClassVar[Literal[...]] annotation on both concrete classes from Literal['brand_json', 'publisher_pin'] to Literal['brand_json']. Each class produces exactly one value; the wider annotation would let mypy miss a future reassignment typo. Style only, no runtime effect. 3. Move _BrandJsonStaticJwksResolver above its caller in agent_resolver.py. Functionally safe under Python's call-time name resolution but brittle to refactor. Now sits right after the '# ---- verify factory ----' divider where readers expect helpers to live. Two new Protocol-conformance tests in test_verify_from_agent_url.py: - _BrandJsonStaticJwksResolver isinstance-conforms. - A bare StaticJwksResolver does NOT conform (back-compat skip path for adopter resolvers predating the discriminant). 87 tests across impacted surface remain green. Refs Argus second-pass review on #789. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(signing): warn on key_origins misconfiguration Argus first-pass review follow-ups on #789. Two warnings on misconfig paths that previously silently no-op'd: 1. UserWarning when jwks_source='brand_json' AND expected_key_origins is None. The resolver IS brand-json-sourced but the caller didn't surface the operator's declared identity.key_origins map, so the spec-mandated check skips. The adopter sees the warning in operator logs and threads the origins through VerifyOptions. 2. DeprecationWarning when expected_key_origins is supplied AND the resolver has no jwks_source attribute. Adopter upgraded the SDK but their custom resolver predates the discriminant. The SDK silently downgrades to no-check; warning gives the upgrade signal ('set jwks_source="brand_json" on the resolver class or conform to BrandSourcedJwksResolver'). Three new tests in test_verify_from_agent_url.py: - brand_json-source + no-origins emits UserWarning - legacy-resolver + origins emits DeprecationWarning - happy-path (brand_json + origins) emits neither 18 tests across impacted surface remain green. Refs Argus first-pass review on #789. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(signing): cap key_origins entry size, route cold-cache jwks_uri=None to JWKS_UNAVAILABLE, host-only diagnostic fallback Three remaining Argus follow-ups from #789. 1. Per-entry length cap on _extract_key_origins. Each origin value is bounded at 512 bytes — well above any legitimate scheme+host+port shape but tight enough that a pathological multi-kilobyte entry from the 64 KiB capabilities body doesn't propagate through downstream comparisons. Oversized entries are SKIPPED (not truncated — a truncated host would silently match the wrong domain). Constant _MAX_KEY_ORIGIN_VALUE_BYTES = 512 documents the choice. 2. Cold-cache jwks_uri=None routes to REQUEST_SIGNATURE_JWKS_UNAVAILABLE. Previous behavior coerced None to '' and routed through the mismatch path with empty actual_origin — a resolver-side I/O failure misclassified as adversarial origin-mismatch on dashboards. Now raises JWKS_UNAVAILABLE with detail={'purpose': signing_purpose} so the cold-cache shape aggregates with other resolver-fetch failures. 3. Diagnostic host-only fallback on canonicalization failure. When _origin_host can't canonicalize one side, the mismatch detail's expected_origin / actual_origin values must still be HOST-SHAPED — previous fallback leaked the full raw URL into the host-labeled field, inconsistent with the success-path host-only shape. New _diagnostic_host helper falls through to _extract_host (the same URL/bare-host parser the canonicalization step uses) for a best-effort host, empty string at worst. Three new tests in test_verify_from_agent_url.py: - _extract_key_origins skips oversized entries - mismatch detail uses host-only fallback (no URL leakage) - jwks_uri=None routes to JWKS_UNAVAILABLE 622 tests across impacted surface remain green. ruff + mypy clean. Refs Argus second-pass review on #789 (cold-cache routing, detail shape, per-entry length cap). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(signing): docstring on BrandSourcedJwksResolver.__call__ to silence linter github-code-quality flagged def __call__(...) -> ...: ... on the new Protocol method as 'statement has no effect.' The ellipsis IS the function body (standard Python pattern for abstract Protocol methods — same shape as the three other Protocol __call__ methods in this module). A one-line docstring documents intent and silences the false positive without changing semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Two related follow-ups from the #350 v3-identity series that PR #785 (just merged) had no room for; both touch
src/adcp/signing/canonicalization so they ship together to keep the IDNA story coherent.#777 — IDNA-2008 canonicalization
Migrate four IDNA callsites from stdlib
host.encode("idna")(IDNA-2003) toidna.encode(host, uts46=True)(IDNA-2008 / UTS#46) and addidna>=3.0to[project.dependencies]:src/adcp/signing/jwks.py— JWKS URI host pinning (SSRF + TOCTOU close)src/adcp/signing/ip_pinned_transport.py— pin-host normalizationsrc/adcp/signing/revocation_fetcher.py— governance issuer origin normalizationsrc/adcp/signing/key_origins.py—_origin_hostcanonicalizationThe IDNA-2003 path mismaps Eszett (
ß→ss) and final-sigma, both of which IDNA-2008 preserves as Punycode. A counterparty hostingstraße.dewould canonicalize under IDNA-2003 tostrasse.de— a different registrable domain than the spec mandates, with an attacker-controlled write to either side able to either silently match a non-owned origin or deny a legitimate one.Regression test (
tests/test_key_origins.py::test_consistency_eszett_preserves_as_punycode_idna_2008) pins the behavior change explicitly with both positive (straße.de↔xn--strae-oqa.dematches) and negative (straße.de↔strasse.dedoes NOT match) cases — without the negative case the migration is silent under a future refactor.#776 — Verifier wiring for
identity.key_originsPR #775 landed
check_key_origin_consistencyas a standalone helper but did not wire it intoverify_request_signature. Per ADCP #3690 §step 7, the check is mandatory ONLY when the JWKS source for the (agent, purpose, role) tuple was the operator brand.json — and skipped for publisher-pinned tuples (where the JWKS origin is the publisher's domain by design).Design (least invasive):
jwks_source: Literal["brand_json", "publisher_pin"]class attribute.BrandJsonJwksResolverdeclares"brand_json"; the publisher-pin path (CachingJwksResolver,StaticJwksResolver) omits the attribute. The verifier treats absence as"publisher_pin"-equivalent (= skip), so the existingJwksResolverProtocol is preserved and adopter-supplied resolvers keep working unchanged.VerifyOptionsextensions (three new fields, all backward-compatible defaults):expected_key_origins: Mapping[str, str] | None = None— the signing peer's declaredidentity.key_originsmap.Noneskips the check unconditionally.signing_purpose: str = "request_signing"— the purpose key to look up.posture: str | None = None— diagnostic context attached to_key_origin_missingrejections.No new error codes; the helper already raises
request_signature_key_origin_{mismatch,missing}fromerrors.py.Files changed
pyproject.toml—idna>=3.0added to[project.dependencies]src/adcp/signing/jwks.py— IDNA-2008src/adcp/signing/ip_pinned_transport.py— IDNA-2008src/adcp/signing/revocation_fetcher.py— IDNA-2008src/adcp/signing/key_origins.py— IDNA-2008src/adcp/signing/brand_jwks.py— declarejwks_source = "brand_json"src/adcp/signing/verifier.py—_maybe_check_key_originhelper +VerifyOptionsfieldstests/test_key_origins.py— IDNA-2008 regression test (Eszett)tests/conformance/signing/test_verifier_key_origins.py— new file, 9 verifier-integration testsTest plan
pytest tests/test_brand_jwks.py tests/test_etld.py tests/test_key_origins.py tests/test_brand_authz.py tests/test_decisioning_brand_authz_dispatch.py tests/conformance/signing/ --tb=short— 598 passed locallypytest tests/ -q— full suite, 5028 passed locallyruff check src/adcp/signing/— cleanmypy src/adcp/signing/— cleanmypy src/adcp/— clean (811 source files)