Summary
adcp.signing.etld.host_from applies no IDNA conversion, so same_registrable_domain — the eTLD+1 gate the brand-authorization binding runs on — returns False when the two sides spell the same IDN host differently. A brand that publishes its brand.json under a U-label host and lists its agent under the A-label form (or the reverse) fails the binding.
Separately, the same function is asymmetric with itself on the trailing FQDN-root dot, and its docstring describes behaviour that only one of its two branches has.
Cause
src/adcp/signing/etld.py:73-82:
if "://" in value:
parts = urlsplit(value)
host = parts.hostname
if not host:
raise ValueError(f"URL has no host: {value!r}")
return host.lower() # :78 — no dot strip, no IDNA
stripped = value.strip().rstrip(".").lower() # :79 — dot stripped, still no IDNA
Neither branch performs UTS-46 / IDNA-2008 encoding, and tldextract does not do it either — it accepts a U-label and returns a U-label eTLD+1. So the two spellings never reduce to a common form.
The docstring at :63-64 claims the function "trims a single trailing dot (the FQDN root separator) so Example.COM. and example.com compare equal". The bare-host branch does that; the URL branch does not. The bare-host branch also uses rstrip("."), which strips all trailing dots, not one.
Reproduce
from adcp.signing.etld import host_from, registrable_domain, same_registrable_domain
# IDNA gap — the load-bearing one
registrable_domain("straße.de") # 'straße.de'
registrable_domain("xn--strae-oqa.de") # 'xn--strae-oqa.de'
same_registrable_domain("https://shop.straße.de/", "xn--strae-oqa.de")
# False <- same host, two spellings, binding fails
# Trailing-dot asymmetry
host_from("https://Example.COM./") # 'example.com.'
host_from("Example.COM.") # 'example.com'
host_from("https://x.example.com..") # 'x.example.com..'
host_from("x.example.com..") # 'x.example.com'
Impact
The IDNA gap is a real binding failure, fail-closed. src/adcp/signing/brand_authz.py:255 calls same_registrable_domain(agent_url, brand_host) as step 2a of the eTLD+1 binding. For brand_domain="bücher.example" and a brand.json listing https://ads.xn--bcher-kva.example/, the agent is found by _find_listed_agents (byte-equal URL match) but step 2a returns False, the authorized_operators[] fallback does not match either, and the result is BrandAuthorizationResult(False, reason="binding_failed") (brand_authz.py:278-283). A legitimate agent is refused; nothing is wrongly accepted. Scope is limited to brands on IDN hosts, which is a small population — but the failure is silent from the publisher's side and binding_failed points at the wrong thing.
The trailing-dot asymmetry does not currently affect the binding. tldextract strips trailing dots itself, so registrable_domain and same_registrable_domain return identical results for both spellings — verified above (registrable_domain("https://example.com.") and registrable_domain("example.com") both give example.com). The defect is observable only through host_from, which is a public export (src/adcp/signing/__init__.py:195,430), and through the docstring, which is wrong. Worth fixing as correctness and as a precondition for the IDNA change; not worth describing as a security issue.
The workaround already in the tree
src/adcp/adagents.py:378-383 does exactly the conversion host_from is missing, by hand, at its own call site:
def _idna_ascii_host(hostname: str, context: str) -> str:
try:
return idna.encode(hostname.rstrip("."), uts46=True).decode("ascii").lower()
except idna.IDNAError as e:
raise AdagentsValidationError(...) from e
and applies it to both arguments before calling same_registrable_domain (adagents.py:402-404). That is the redirect path working around this gap locally. It is also a hand-retyped copy of _idna_canonicalize.canonicalize_host steps 1/2/4 and diverges from it in three ways: no leading/trailing .strip(), transitional= left to the idna>=3 default rather than pinned the way the helper pins it, and no IP-literal short-circuit (so a public IPv6-literal redirect target raises "invalid IDNA hostname" rather than passing through). None of the three is reachable today — _normalize_domain and _check_safe_host run upstream of both call sites — so this is a maintenance duplicate rather than a live bug. It disappears entirely if host_from does the conversion.
Suggested direction
Route both branches of host_from through _idna_canonicalize.canonicalize_host, which already strips exactly one trailing dot, lowercases, short-circuits IP literals, and encodes with uts46=True, transitional_processing=False. canonical_formats/references.py:38 establishes that a non-signing module may import that helper, so adagents.py can drop _idna_ascii_host in the same change.
Two decisions the change has to make explicitly rather than incidentally:
registrable_domain() return values change for IDN inputs. It is public API; today straße.de returns straße.de, after the change xn--strae-oqa.de. Both sides of same_registrable_domain move together so the predicate stays correct, but adopters logging or persisting the value see it change.
canonicalize_host raises on underscore hosts and labels over 63 bytes, where host_from returns them unchanged today. The existing six callers are split between fail-open (ip_pinned_transport.py:119, key_origins.py) and fail-closed (jwks.py, revocation_fetcher.py), so "match the convention" is not available as an answer. Given etld is a comparison path and registrable_domain already documents a fail-closed None contract, mapping the raise onto None is probably right — but it should be stated in the diff.
Related
Summary
adcp.signing.etld.host_fromapplies no IDNA conversion, sosame_registrable_domain— the eTLD+1 gate the brand-authorization binding runs on — returnsFalsewhen the two sides spell the same IDN host differently. A brand that publishes itsbrand.jsonunder a U-label host and lists its agent under the A-label form (or the reverse) fails the binding.Separately, the same function is asymmetric with itself on the trailing FQDN-root dot, and its docstring describes behaviour that only one of its two branches has.
Cause
src/adcp/signing/etld.py:73-82:Neither branch performs UTS-46 / IDNA-2008 encoding, and
tldextractdoes not do it either — it accepts a U-label and returns a U-label eTLD+1. So the two spellings never reduce to a common form.The docstring at
:63-64claims the function "trims a single trailing dot (the FQDN root separator) soExample.COM.andexample.comcompare equal". The bare-host branch does that; the URL branch does not. The bare-host branch also usesrstrip("."), which strips all trailing dots, not one.Reproduce
Impact
The IDNA gap is a real binding failure, fail-closed.
src/adcp/signing/brand_authz.py:255callssame_registrable_domain(agent_url, brand_host)as step 2a of the eTLD+1 binding. Forbrand_domain="bücher.example"and abrand.jsonlistinghttps://ads.xn--bcher-kva.example/, the agent is found by_find_listed_agents(byte-equal URL match) but step 2a returnsFalse, theauthorized_operators[]fallback does not match either, and the result isBrandAuthorizationResult(False, reason="binding_failed")(brand_authz.py:278-283). A legitimate agent is refused; nothing is wrongly accepted. Scope is limited to brands on IDN hosts, which is a small population — but the failure is silent from the publisher's side andbinding_failedpoints at the wrong thing.The trailing-dot asymmetry does not currently affect the binding.
tldextractstrips trailing dots itself, soregistrable_domainandsame_registrable_domainreturn identical results for both spellings — verified above (registrable_domain("https://example.com.")andregistrable_domain("example.com")both giveexample.com). The defect is observable only throughhost_from, which is a public export (src/adcp/signing/__init__.py:195,430), and through the docstring, which is wrong. Worth fixing as correctness and as a precondition for the IDNA change; not worth describing as a security issue.The workaround already in the tree
src/adcp/adagents.py:378-383does exactly the conversionhost_fromis missing, by hand, at its own call site:and applies it to both arguments before calling
same_registrable_domain(adagents.py:402-404). That is the redirect path working around this gap locally. It is also a hand-retyped copy of_idna_canonicalize.canonicalize_hoststeps 1/2/4 and diverges from it in three ways: no leading/trailing.strip(),transitional=left to theidna>=3default rather than pinned the way the helper pins it, and no IP-literal short-circuit (so a public IPv6-literal redirect target raises "invalid IDNA hostname" rather than passing through). None of the three is reachable today —_normalize_domainand_check_safe_hostrun upstream of both call sites — so this is a maintenance duplicate rather than a live bug. It disappears entirely ifhost_fromdoes the conversion.Suggested direction
Route both branches of
host_fromthrough_idna_canonicalize.canonicalize_host, which already strips exactly one trailing dot, lowercases, short-circuits IP literals, and encodes withuts46=True, transitional_processing=False.canonical_formats/references.py:38establishes that a non-signingmodule may import that helper, soadagents.pycan drop_idna_ascii_hostin the same change.Two decisions the change has to make explicitly rather than incidentally:
registrable_domain()return values change for IDN inputs. It is public API; todaystraße.dereturnsstraße.de, after the changexn--strae-oqa.de. Both sides ofsame_registrable_domainmove together so the predicate stays correct, but adopters logging or persisting the value see it change.canonicalize_hostraises on underscore hosts and labels over 63 bytes, wherehost_fromreturns them unchanged today. The existing six callers are split between fail-open (ip_pinned_transport.py:119,key_origins.py) and fail-closed (jwks.py,revocation_fetcher.py), so "match the convention" is not available as an answer. Givenetldis a comparison path andregistrable_domainalready documents a fail-closedNonecontract, mapping the raise ontoNoneis probably right — but it should be stated in the diff.Related
canonical.py's signature-base path. Different module, different consumer (@authorityvs the brand binding); fixing one does not fix the other.canonical.py. That issue asks upstream to define the rule for the signature base, where the byte value is on the wire.etld's dot handling is internal to eTLD+1 comparison and is not spec-visible, so it does not need the same upstream decision.idna(IDNA 2008) across signing/ #777 (closed) — migrated IDNA canonicalization from the stdlib (IDNA 2003) to the PyPIidnapackage (IDNA 2008) "acrosssigning/".etld.pywas not converted in that pass;host_fromstill does a bare.lower(). This is residue of that migration rather than a new regression — which is also why the fix here is to call the helper Migrate IDNA canonicalization from stdlib (IDNA 2003) to PyPIidna(IDNA 2008) across signing/ #777 established rather than to introduce another one.