Coverage for node / src / stigmem_node / federation / dnssec / resolve.py: 88%
43 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"""Off-path composition entry point for the DNSSEC first-trust tier (Rev 6 §7/I2/I3).
3``resolve_dnssec_binding`` is the single seam the 3b first-trust ladder consumes.
4It composes the three self-contained 3a pieces:
6 1. ``host_from_entity_uri`` (3a.2 / I3) — derive the canonical query host from
7 the *signed wire* ``entity_uri``. A non-DNSSEC-capable URI (non-HTTP
8 scheme, IP-literal, userinfo, explicit port) yields ``None`` and the binding
9 is ``NOT_APPLICABLE`` — the caller routes to operator-confirm (I3).
10 2. ``validate_binding`` (3a.4/5/6 / I2) — walk the chain to the IANA root,
11 re-deriving trust from signatures (never the AD bit), and validate the
12 binding TXT, its authenticated absence, or its insecure delegation.
13 3. The record parse already happened inside the validator (3a.3); its parsed
14 ``BindingRecord`` rides on ``SECURE``.
16This module is OFF-PATH (Rev 6, build-phase 3a): no resolver is wired into the
17relay terminal yet — that is build-phase 3c. Its only contract is total: every
18input, including a malformed ``entity_uri`` or any validator outcome, maps to
19exactly one ``DnssecResult``. **No exception escapes.**
21The ``Validation`` -> ``DnssecResult.Outcome`` mapping is fail-closed (I10):
23 ====================== ============================ ==========================
24 validator outcome record DnssecResult.Outcome
25 ====================== ============================ ==========================
26 SECURE active ACTIVE (+ record)
27 SECURE revoked (``status=revoked``) REVOKED (+ record)
28 INSECURE — INSECURE
29 ABSENT_AUTHENTICATED — ABSENT_AUTHENTICATED
30 UNVALIDATABLE — UNVALIDATABLE
31 BOGUS — BOGUS
32 ====================== ============================ ==========================
34dnspython stays function-local (it is only reached through ``validate_binding``,
35which imports it lazily), so importing this module never loads the
36``[federation-dnssec]`` extra (Rev 6 I11).
37"""
39from __future__ import annotations
41import enum
42from dataclasses import dataclass
43from typing import TYPE_CHECKING
45from .host import host_from_entity_uri
46from .record import BindingRecord
47from .validator import Validation, validate_binding
49if TYPE_CHECKING: # type-checkers only; never imported at runtime (I11).
50 from .resolver import Resolver
53@dataclass(frozen=True)
54class DnssecResult:
55 """The outcome of composing host derivation + chain validation for an origin.
57 ``outcome`` is always populated. ``record`` is the parsed, chain-validated
58 ``BindingRecord`` for the ``ACTIVE`` and ``REVOKED`` outcomes and ``None``
59 for every other outcome (the binding either does not exist, is unsigned, or
60 failed validation, so there is no trustworthy record to surface).
61 """
63 class Outcome(enum.Enum):
64 """The seven terminal verdicts the first-trust ladder dispatches on.
66 Caller disposition (Rev 6 I3/I10):
67 * ``ACTIVE`` -> trust the record's key (subject to epoch
68 pin + age clamp in 3b).
69 * ``REVOKED`` -> hard-reject the origin's key.
70 * ``INSECURE`` -> genuinely-unsigned delegation; fall to
71 operator-confirm.
72 * ``ABSENT_AUTHENTICATED`` -> proven-absent binding; fall to
73 operator-confirm.
74 * ``UNVALIDATABLE`` -> reject (no validatable proof either way).
75 * ``NOT_APPLICABLE`` -> entity_uri is not DNSSEC-capable; route
76 to operator-confirm.
77 * ``BOGUS`` -> reject (forged / broken chain).
78 """
80 ACTIVE = "active"
81 REVOKED = "revoked"
82 INSECURE = "insecure"
83 ABSENT_AUTHENTICATED = "absent_authenticated"
84 UNVALIDATABLE = "unvalidatable"
85 NOT_APPLICABLE = "not_applicable"
86 BOGUS = "bogus"
88 outcome: DnssecResult.Outcome
89 record: BindingRecord | None = None
90 host: str | None = None
91 detail: str = ""
92 # Epoch-seconds inception of the newest binding RRSIG, threaded from the
93 # validator (Rev 6 I4). Only meaningful for SECURE-derived outcomes
94 # (``ACTIVE``/``REVOKED``); ``None`` everywhere else. The first-trust ladder
95 # derives the RRSIG age from this for its age clamp.
96 rrsig_inception: float | None = None
97 # DNS TTL (seconds) of the binding TXT RRset, threaded from the validator
98 # (Rev 6 §7 / I5). Only meaningful for SECURE-derived outcomes
99 # (``ACTIVE``/``REVOKED``); ``None`` everywhere else. The relay-path re-check
100 # clamps its cadence to this (``clamp(ttl, floor, cap)``).
101 ttl: int | None = None
104# The SECURE-with-record outcomes are decided by the record's revoked flag; every
105# other validator status maps 1:1. A status missing from this table is treated
106# fail-closed as BOGUS.
107_NON_SECURE_MAP: dict[Validation, DnssecResult.Outcome] = {
108 Validation.INSECURE: DnssecResult.Outcome.INSECURE,
109 Validation.ABSENT_AUTHENTICATED: DnssecResult.Outcome.ABSENT_AUTHENTICATED,
110 Validation.UNVALIDATABLE: DnssecResult.Outcome.UNVALIDATABLE,
111 Validation.BOGUS: DnssecResult.Outcome.BOGUS,
112}
115def resolve_dnssec_binding(entity_uri: str, *, resolver: Resolver) -> DnssecResult:
116 """Resolve the DNSSEC binding for ``entity_uri`` into a ``DnssecResult``.
118 Off-path composition (3a.7): derive host (I3) -> validate chain (I2) -> map.
119 Total + fail-closed: any unexpected error maps to ``BOGUS``; no exception
120 escapes (Rev 6 I10).
121 """
122 try:
123 host = host_from_entity_uri(entity_uri)
124 except Exception: # noqa: BLE001 — host derivation is fail-closed (I10).
125 return DnssecResult(DnssecResult.Outcome.BOGUS, detail="host derivation error")
127 if host is None:
128 # Not DNSSEC-capable (non-HTTP scheme, IP-literal, userinfo, port). The
129 # resolver is never consulted; the caller routes to operator-confirm (I3).
130 return DnssecResult(DnssecResult.Outcome.NOT_APPLICABLE)
132 try:
133 verdict = validate_binding(host, resolver=resolver)
134 except Exception: # noqa: BLE001 — the validator is designed not to raise,
135 # but the composition contract is total: any escape is BOGUS (I10).
136 return DnssecResult(DnssecResult.Outcome.BOGUS, host=host, detail="validation error")
138 if verdict.status is Validation.SECURE:
139 record = verdict.record
140 if record is None: 140 ↛ 143line 140 didn't jump to line 143 because the condition on line 140 was never true
141 # SECURE must carry a parsed record; absence is a contract breach we
142 # treat fail-closed rather than trust.
143 return DnssecResult(
144 DnssecResult.Outcome.BOGUS, host=host, detail="secure without record"
145 )
146 outcome = (
147 DnssecResult.Outcome.REVOKED if record.revoked else DnssecResult.Outcome.ACTIVE
148 )
149 return DnssecResult(
150 outcome,
151 record=record,
152 host=host,
153 detail=verdict.detail,
154 rrsig_inception=verdict.rrsig_inception,
155 ttl=verdict.ttl,
156 )
158 mapped = _NON_SECURE_MAP.get(verdict.status, DnssecResult.Outcome.BOGUS)
159 return DnssecResult(mapped, host=host, detail=verdict.detail)