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

1"""Canonical entity_uri -> DNS host derivation for the DNSSEC first-trust tier. 

2 

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. 

6 

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. 

11 

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""" 

18 

19from __future__ import annotations 

20 

21import ipaddress 

22from urllib.parse import urlparse 

23 

24_HTTP_SCHEMES = ("https://", "http://") 

25 

26 

27def host_from_entity_uri(entity_uri: str) -> str | None: 

28 """Return the canonical DNS host for ``entity_uri``, or ``None``. 

29 

30 ``None`` means the DNSSEC tier is not applicable for this origin. 

31 """ 

32 if not entity_uri: 

33 return None 

34 

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 

43 

44 parsed = urlparse(normalized) 

45 

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 

49 

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 

57 

58 host = parsed.hostname # NEVER parsed.netloc 

59 if not host: 

60 return None 

61 

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 

66 

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 

74 

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