Coverage for node / src / stigmem_node / federation / dnssec / host.py: 87%
34 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
1"""Canonical entity_uri -> DNS host derivation for the DNSSEC first-trust tier.
3Rev 6 I3 (single canonical algorithm, used for BOTH the DNS qname and the pin
4key). The host MUST be derived from the signed wire ``entity_uri`` by exactly
5this algorithm; the relay-carried manifest is never consulted.
7Returns ``None`` when the ``entity_uri`` is not DNSSEC-capable — a non-HTTP
8scheme, an IP-literal host, embedded userinfo (``@``), or a non-default port.
9A ``None`` result is an expected ladder path (the DNSSEC tier is not
10applicable -> the caller routes to operator-confirm), not an error.
12Self-certification note (Rev 6 I3 — keep so a future reader does not "fix" the
13ordering): the wire ``entity_uri`` is not signature-verified at query time. A
14forged ``entity_uri`` can only select a zone the forger controls, yielding
15trust in the forger's *own* identity, never a victim's; the ``origin_sig``
16check closes the loop after resolution.
17"""
19from __future__ import annotations
21import ipaddress
22from urllib.parse import urlparse
24_HTTP_SCHEMES = ("https://", "http://")
27def host_from_entity_uri(entity_uri: str) -> str | None:
28 """Return the canonical DNS host for ``entity_uri``, or ``None``.
30 ``None`` means the DNSSEC tier is not applicable for this origin.
31 """
32 if not entity_uri:
33 return None
35 # RFC 3986 §3.1: the scheme is case-insensitive. Lowercase only the scheme
36 # component (everything up to and including the first ``://``) so an
37 # uppercase scheme like ``HTTPS://`` is recognized; the authority/path are
38 # left untouched (urlparse + IDNA handle host case-folding downstream).
39 scheme, sep, rest = entity_uri.partition("://")
40 normalized = scheme.lower() + sep + rest if sep else entity_uri
41 if not normalized.startswith(_HTTP_SCHEMES):
42 return None
44 parsed = urlparse(normalized)
46 # Userinfo steer (e.g. https://victim.com@attacker.com/) -> reject (NF-R5D-1).
47 if parsed.username is not None or parsed.password is not None:
48 return None
50 # A non-default port is not part of the DNS name -> reject rather than guess.
51 try:
52 if parsed.port is not None:
53 return None
54 except ValueError:
55 # Malformed port -> not DNSSEC-capable.
56 return None
58 host = parsed.hostname # NEVER parsed.netloc
59 if not host:
60 return None
62 # case-fold + strip a single trailing dot.
63 host = host.rstrip(".").lower()
64 if not host: 64 ↛ 65line 64 didn't jump to line 65 because the condition on line 64 was never true
65 return None
67 # IP-literal host (IPv4 or IPv6; urlparse already strips IPv6 brackets) ->
68 # DNSSEC tier not applicable.
69 try:
70 ipaddress.ip_address(host)
71 return None
72 except ValueError:
73 pass
75 # IDNA-normalize to A-labels (punycode). An already-encoded xn-- label
76 # round-trips unchanged.
77 try:
78 return host.encode("idna").decode("ascii")
79 except (UnicodeError, ValueError):
80 return None