Skip to content

Commit c0ed690

Browse files
bokelleyclaude
andcommitted
fix(signing): expert-review fixes for key_origins consistency check
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>
1 parent 62d2379 commit c0ed690

3 files changed

Lines changed: 230 additions & 15 deletions

File tree

src/adcp/signing/errors.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,34 @@
77

88
from __future__ import annotations
99

10+
from collections.abc import Mapping
11+
1012

1113
class SignatureVerificationError(Exception):
12-
"""Raised when a request signature fails any step of the verifier checklist."""
14+
"""Raised when a request signature fails any step of the verifier checklist.
15+
16+
``detail`` carries the spec-mandated structured fields for codes that
17+
require them — e.g. ``request_signature_key_origin_mismatch`` carries
18+
``{purpose, expected_origin, actual_origin}`` per ADCP #3690
19+
security.mdx step 7, and ``request_signature_brand_json_url_missing``
20+
carries ``{agent_url}`` per the same section's rejection-code table.
21+
Middleware adapters surface these as structured fields on the 401
22+
response or in a DLQ payload; ``str(exc)`` continues to render the
23+
free-form message for unstructured logs.
24+
"""
1325

1426
def __init__(
1527
self,
1628
code: str,
1729
*,
1830
step: int | str | None = None,
1931
message: str | None = None,
32+
detail: Mapping[str, str] | None = None,
2033
) -> None:
2134
super().__init__(message or code)
2235
self.code = code
2336
self.step = step
37+
self.detail = dict(detail) if detail is not None else None
2438

2539

2640
REQUEST_SIGNATURE_REQUIRED = "request_signature_required"

src/adcp/signing/key_origins.py

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ def check_key_origin_consistency(
7474
"""Verify that the resolved ``jwks_uri`` host matches the declared
7575
``identity.key_origins.{purpose}``.
7676
77+
**Caller contract: skip this call for publisher-pinned JWKS sources.**
78+
Per ADCP #3690 the consistency check is mandatory only when the JWKS
79+
source for the (agent, purpose, role) tuple was the operator
80+
brand.json. For tuples sourced from a publisher
81+
``adagents.json signing_keys`` pin, the JWKS origin is the
82+
publisher's domain by design — invoking this check on a
83+
publisher-pinned tuple would incorrectly reject a legitimate key.
84+
Callers are responsible for that branching; the helper takes no
85+
``source`` parameter and will always raise on host disagreement.
86+
7787
Parameters
7888
----------
7989
jwks_uri:
@@ -91,7 +101,7 @@ def check_key_origin_consistency(
91101
posture:
92102
Optional context attached to ``key_origin_missing`` rejection
93103
for adopter diagnostics (e.g. ``"required"``, ``"supported"``).
94-
Not consulted by the check itself.
104+
Surfaced as ``detail['posture']`` and in the message.
95105
code_family:
96106
``"request"`` (default) or ``"webhook"``. Picks the
97107
corresponding spec error code family.
@@ -100,19 +110,28 @@ def check_key_origin_consistency(
100110
------
101111
SignatureVerificationError
102112
With ``code = *_key_origin_missing`` when ``purpose`` is absent
103-
from ``key_origins``; ``code = *_key_origin_mismatch`` when the
104-
purpose's declared origin differs from the resolved
105-
``jwks_uri`` host (after IDNA-A-label canonicalization).
113+
from ``key_origins`` — ``detail`` carries ``{purpose, posture}``.
114+
115+
With ``code = *_key_origin_mismatch`` when the purpose's declared
116+
origin differs from the resolved ``jwks_uri`` host (after IDNA
117+
A-label canonicalization). ``detail`` carries
118+
``{purpose, expected_origin, actual_origin}`` per the spec's
119+
rejection-code shape — middleware adapters surface these as
120+
structured fields on the 401 / in a DLQ.
106121
"""
107122
declared = (key_origins or {}).get(purpose)
108123
if declared is None:
124+
missing_detail: dict[str, str] = {"purpose": purpose}
125+
if posture:
126+
missing_detail["posture"] = posture
109127
raise SignatureVerificationError(
110128
_MISSING_CODE[code_family],
111129
step=7,
112130
message=(
113131
f"identity.key_origins.{purpose} declaration missing"
114132
+ (f" (posture={posture})" if posture else "")
115133
),
134+
detail=missing_detail,
116135
)
117136

118137
actual_host = _origin_host(jwks_uri)
@@ -125,6 +144,15 @@ def check_key_origin_consistency(
125144
f"identity.key_origins.{purpose} declares {declared_host!r} "
126145
f"but resolved jwks_uri host is {actual_host!r}"
127146
),
147+
detail={
148+
"purpose": purpose,
149+
# Use the canonicalized values when available; fall back
150+
# to the raw inputs for diagnostic accuracy when one
151+
# side failed to canonicalize. Spec wording is
152+
# ``expected_origin`` / ``actual_origin`` verbatim.
153+
"expected_origin": declared_host if declared_host is not None else declared,
154+
"actual_origin": actual_host if actual_host is not None else jwks_uri,
155+
},
128156
)
129157

130158

@@ -142,24 +170,64 @@ def _origin_host(value: str) -> str | None:
142170
keeps the canonicalization story coherent. A future IDNA-2008
143171
migration would update all four callsites together.
144172
145-
Returns ``None`` when the input is structurally invalid (no scheme
146-
or no host); callers treat ``None`` as a binding failure.
173+
**Bare-host and URL forms are normalized symmetrically.** A bare
174+
host like ``"keys.brand.com"`` is processed through the same
175+
``urlsplit`` path as a full URL (with a synthetic scheme prepended)
176+
so port, userinfo, query, and fragment all strip consistently.
177+
Without that synthesis, a declarant supplying
178+
``"keys.brand.com:8443"`` as a bare host would canonicalize to
179+
``"keys.brand.com:8443"`` while the matching URL form would
180+
canonicalize to ``"keys.brand.com"`` — a fail-closed asymmetry an
181+
attacker who controls capabilities could exploit to deny
182+
verification against the operator's brand.json origin.
183+
184+
**Trailing-dot equality.** ``host.example.`` and ``host.example``
185+
are the same FQDN at the protocol layer (the dot denotes the root
186+
zone). A counterparty serving the dot form while the capability
187+
declares the no-dot form (or vice versa) must not mismatch. We
188+
strip a single trailing dot before IDNA encoding.
189+
190+
Returns ``None`` when the input is structurally invalid (no
191+
resolvable host, or it parses but contains characters that don't
192+
survive IDNA); callers treat ``None`` as a binding failure.
147193
"""
148-
parts = urlsplit(value)
149-
host = parts.hostname
194+
host = _extract_host(value)
195+
if host is None:
196+
return None
197+
host = host.rstrip(".").lower()
150198
if not host:
151-
# Permit bare-host inputs like ``"keys.brand.com"`` —
152-
# capabilities ``identity.key_origins`` values are not
153-
# spec-constrained to be full URLs, only to identify an origin.
154-
host = value.strip().lower()
155-
if not host or "/" in host or " " in host:
156-
return None
199+
return None
157200
try:
158201
return host.encode("idna").decode("ascii").lower()
159202
except (UnicodeError, UnicodeEncodeError):
160203
return None
161204

162205

206+
def _extract_host(value: str) -> str | None:
207+
"""Pull the host portion out of ``value``, accepting both URL form
208+
(``https://keys.brand.com/...``) and bare-host form
209+
(``keys.brand.com``).
210+
211+
For URL inputs the host comes from ``urlsplit().hostname``. For
212+
bare-host inputs we prepend a synthetic ``https://`` scheme and
213+
re-parse so port / userinfo / query / fragment all strip the same
214+
way they would for an explicit URL — closing the bare-host vs URL
215+
asymmetry that the bare-host fallback used to have.
216+
"""
217+
parts = urlsplit(value)
218+
if parts.hostname:
219+
return parts.hostname
220+
221+
# Schemeless input. Prepend ``https://`` and re-parse.
222+
# Strip whitespace first so leading-space inputs don't produce
223+
# ``https:// foo.com`` which then fails to parse a host.
224+
stripped = value.strip()
225+
if not stripped:
226+
return None
227+
parts = urlsplit(f"https://{stripped}")
228+
return parts.hostname or None
229+
230+
163231
__all__ = [
164232
"CodeFamily",
165233
"check_key_origin_consistency",

tests/test_key_origins.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,136 @@ def test_consistency_webhook_family_missing_uses_webhook_code() -> None:
162162
code_family="webhook",
163163
)
164164
assert exc_info.value.code == WEBHOOK_SIGNATURE_KEY_ORIGIN_MISSING
165+
166+
167+
# ----- spec-mandated structured detail fields -----
168+
169+
170+
def test_consistency_mismatch_carries_structured_detail() -> None:
171+
# Spec #3690 step 7: ``request_signature_key_origin_mismatch`` MUST
172+
# carry ``{purpose, expected_origin, actual_origin}`` as structured
173+
# fields, not just an opaque message string. Middleware adapters
174+
# surface them on the 401 / in a DLQ.
175+
with pytest.raises(SignatureVerificationError) as exc_info:
176+
check_key_origin_consistency(
177+
jwks_uri="https://attacker.example.org/.well-known/jwks.json",
178+
key_origins={"request_signing": "https://keys.brand.com"},
179+
purpose="request_signing",
180+
)
181+
detail = exc_info.value.detail
182+
assert detail is not None
183+
assert detail["purpose"] == "request_signing"
184+
assert detail["expected_origin"] == "keys.brand.com"
185+
assert detail["actual_origin"] == "attacker.example.org"
186+
187+
188+
def test_consistency_missing_carries_structured_detail() -> None:
189+
# Spec #3690 step 7: ``_key_origin_missing`` MUST carry
190+
# ``{purpose, posture}``.
191+
with pytest.raises(SignatureVerificationError) as exc_info:
192+
check_key_origin_consistency(
193+
jwks_uri="https://keys.brand.com/.well-known/jwks.json",
194+
key_origins={},
195+
purpose="request_signing",
196+
posture="required",
197+
)
198+
detail = exc_info.value.detail
199+
assert detail is not None
200+
assert detail["purpose"] == "request_signing"
201+
assert detail["posture"] == "required"
202+
203+
204+
def test_consistency_missing_detail_omits_posture_when_unsupplied() -> None:
205+
# ``posture`` is optional; when the caller doesn't pass one, the
206+
# detail dict carries only ``purpose``. Adapters reading
207+
# ``detail.get("posture")`` see ``None`` rather than an empty string.
208+
with pytest.raises(SignatureVerificationError) as exc_info:
209+
check_key_origin_consistency(
210+
jwks_uri="https://keys.brand.com/.well-known/jwks.json",
211+
key_origins={},
212+
purpose="request_signing",
213+
)
214+
detail = exc_info.value.detail
215+
assert detail is not None
216+
assert detail == {"purpose": "request_signing"}
217+
218+
219+
# ----- canonicalization edge cases (regressions for reviewer findings) -----
220+
221+
222+
def test_consistency_trailing_fqdn_dot_compares_equal_either_side() -> None:
223+
# ``host.example.`` and ``host.example`` are the same FQDN at the
224+
# protocol layer (the dot denotes the root zone). An attacker who
225+
# controls capabilities could otherwise declare the dot form while
226+
# the brand.json serves the no-dot form (or vice versa) and weaponize
227+
# the check to deny verification against the legitimate counterparty.
228+
# Both directions must compare equal.
229+
check_key_origin_consistency(
230+
jwks_uri="https://keys.brand.com./jwks.json", # trailing dot
231+
key_origins={"request_signing": "https://keys.brand.com"},
232+
purpose="request_signing",
233+
)
234+
check_key_origin_consistency(
235+
jwks_uri="https://keys.brand.com/jwks.json",
236+
key_origins={"request_signing": "https://keys.brand.com."}, # trailing dot
237+
purpose="request_signing",
238+
)
239+
240+
241+
def test_consistency_bare_host_with_port_normalizes_symmetrically() -> None:
242+
# Capability declaring ``keys.brand.com:8443`` (bare host with port)
243+
# must normalize the same way the URL form would — stripping the
244+
# port — so it compares equal to a resolved jwks_uri of
245+
# ``https://keys.brand.com/...``. Without symmetric normalization,
246+
# an attacker with capability-write access could supply a
247+
# bare-host-with-port declaration to force a mismatch against the
248+
# operator's brand.json origin.
249+
check_key_origin_consistency(
250+
jwks_uri="https://keys.brand.com/.well-known/jwks.json",
251+
key_origins={"request_signing": "keys.brand.com:8443"},
252+
purpose="request_signing",
253+
)
254+
255+
256+
def test_consistency_bare_host_with_userinfo_rejects_or_normalizes() -> None:
257+
# Declarations with userinfo (``user@host``) are spec-suspicious;
258+
# the helper must NOT accidentally accept the host portion while
259+
# ignoring the user. Symmetric urlsplit-based normalization strips
260+
# userinfo the same way it does for URL inputs, so the comparison
261+
# collapses to ``host == host``.
262+
check_key_origin_consistency(
263+
jwks_uri="https://keys.brand.com/.well-known/jwks.json",
264+
key_origins={"request_signing": "user@keys.brand.com"},
265+
purpose="request_signing",
266+
)
267+
268+
269+
def test_consistency_idn_a_label_equals_u_label() -> None:
270+
# IDN U-label (``münchen.example``) and A-label (Punycode
271+
# ``xn--mnchen-3ya.example``) refer to the same host. Canonicalization
272+
# to A-label via ``host.encode("idna")`` must make them compare equal
273+
# regardless of which form each side uses.
274+
check_key_origin_consistency(
275+
jwks_uri="https://xn--mnchen-3ya.example/.well-known/jwks.json",
276+
key_origins={"request_signing": "münchen.example"},
277+
purpose="request_signing",
278+
)
279+
check_key_origin_consistency(
280+
jwks_uri="https://münchen.example/.well-known/jwks.json",
281+
key_origins={"request_signing": "xn--mnchen-3ya.example"},
282+
purpose="request_signing",
283+
)
284+
285+
286+
def test_consistency_unparseable_declared_origin_fails_closed() -> None:
287+
# Symmetric to ``test_consistency_raises_mismatch_on_invalid_jwks_uri``
288+
# but with the unparseable string on the *declared* side. A future
289+
# refactor must not silently invert the fail direction — both sides
290+
# must fail closed.
291+
with pytest.raises(SignatureVerificationError) as exc_info:
292+
check_key_origin_consistency(
293+
jwks_uri="https://keys.brand.com/.well-known/jwks.json",
294+
key_origins={"request_signing": "not a host"},
295+
purpose="request_signing",
296+
)
297+
assert exc_info.value.code == REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH

0 commit comments

Comments
 (0)