Coverage for node / src / stigmem_node / federation / dnssec / ladder.py: 95%
76 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"""First-trust ladder: operator-pin -> DNSSEC -> operator-confirm -> fail-closed.
3Rev 6 §2/§5 precedence, invariants I1 (first-trust rooted, never silent
4first-seen-wins), I2 (sticky-signedness: an authenticated absence on a host that
5has served a signed delegation is an attack), I4 (monotonic epoch + RRSIG-age
6clamp -> operator-confirm, with the previously-fresh hard-reject), I9
7(operator-confirm queue-bounded), I10 (outcome lattice: every branch exit is a
8verified-accept or a raise/reject — no permissive intermediate return).
10``resolve_first_trust`` is a PURE resolver. It composes the already-built
11primitives (``pin``, ``resolve_dnssec_binding``, ``epoch``, ``freshness``,
12``quarantine``) and returns a ``TrustDecision``. It deliberately:
14 * does NOT read ``federation_dnssec_trust_enabled`` — that flag gates the CALL
15 SITE (a later batch), not the ladder logic itself;
16 * does NOT touch the relay path or perform any network egress of its own
17 (DNS resolution happens through the injected ``resolver``);
18 * is not wired anywhere yet.
20Self-certification (Rev 6 I3, plan TB-1): there is NO self-signed shortcut. An
21origin block naming this node's own identity goes through the same ladder; trust
22is rooted in the pin store or the DNSSEC chain, never in the origin asserting its
23own key. The wire ``entity_uri`` is self-certifying — a forged one only selects a
24zone the forger controls — and the downstream ``origin_sig`` check (not this
25module) closes the loop.
27RRSIG-age seam (Rev 6 I4): the binding RRSIG inception is threaded out of the
28validator (``ValidationResult.rrsig_inception`` -> ``DnssecResult.rrsig_inception``)
29and the ladder derives the signature age internally from it and ``now``
30(``age = now - inception``). The age clamp (I4) therefore fires on every ACTIVE
31binding by default — an aged-but-valid signature routes to operator-confirm, and a
32previously-fresh-now-aged signature is rejected.
34``rrsig_age_seconds`` remains an OPTIONAL OVERRIDE for the 3c relay/recheck layer
35(a caller that extracts the age from a live re-resolved DNS message itself). When
36it is ``None`` (the default) the age is derived from ``result.rrsig_inception``.
37An ACTIVE outcome whose ``rrsig_inception`` is ``None`` is a contract breach (the
38validator must surface it on every SECURE path): the age cannot be derived, so the
39binding fails closed (REJECTED) rather than being treated as fresh.
41dnspython stays out of this module's import graph (Rev 6 I11): the only DNSSEC
42work is delegated to ``resolve_dnssec_binding``, which imports dnspython lazily.
43"""
45from __future__ import annotations
47import enum
48from dataclasses import dataclass
49from datetime import UTC, datetime
50from typing import TYPE_CHECKING, Any
52from . import epoch as ep
53from . import freshness as fr
54from . import pin as pinstore
55from . import quarantine as q
56from .host import host_from_entity_uri
57from .resolve import DnssecResult, resolve_dnssec_binding
59if TYPE_CHECKING: # type-checkers only; never imported at runtime (I11).
60 from .resolver import Resolver
63@dataclass(frozen=True)
64class TrustDecision:
65 """The ladder's verdict for a candidate origin key.
67 ``outcome`` is always populated; ``reason`` is a short functional label for
68 audit/logging (never operator-education prose).
69 """
71 class Outcome(enum.Enum):
72 TRUSTED = "trusted"
73 PENDING_CONFIRM = "pending_confirm"
74 REJECTED = "rejected"
76 outcome: TrustDecision.Outcome
77 reason: str
80def _trusted(reason: str) -> TrustDecision:
81 return TrustDecision(TrustDecision.Outcome.TRUSTED, reason)
84def _rejected(reason: str) -> TrustDecision:
85 return TrustDecision(TrustDecision.Outcome.REJECTED, reason)
88def _pending(reason: str) -> TrustDecision:
89 return TrustDecision(TrustDecision.Outcome.PENDING_CONFIRM, reason)
92def _quarantine_or_fail_closed(
93 conn: Any,
94 *,
95 entity_uri: str,
96 node_id: str,
97 candidate_key_fpr: str,
98 source: str,
99 relay_peer: str | None,
100 now: datetime,
101 settings: Any,
102 confirm_source: str,
103) -> TrustDecision:
104 """Step 3: park the candidate for operator-confirm, or fail closed (I9/I10).
106 ``confirm_source`` is the queue-row ``source`` label distinguishing the
107 fallthrough kind ("unsigned" / "insecure-delegation" / "absent" /
108 "not-applicable"); a full queue (per-peer cap) fails closed -> REJECTED so an
109 untrusted relay cannot trade a flood for silent trust.
110 """
111 parked = q.quarantine(
112 conn,
113 entity_uri=entity_uri,
114 node_id=node_id,
115 candidate_key_fpr=candidate_key_fpr,
116 source=confirm_source,
117 relay_peer=relay_peer,
118 now=now,
119 cap=settings.federation_dnssec_pending_confirm_cap,
120 )
121 if parked:
122 return _pending(f"operator-confirm queued ({confirm_source})")
123 return _rejected("operator-confirm queue full (fail-closed)")
126def resolve_first_trust(
127 conn: Any,
128 *,
129 entity_uri: str,
130 node_id: str,
131 candidate_key_fpr: str,
132 resolver: Resolver,
133 settings: Any,
134 now: datetime,
135 relay_peer: str | None = None,
136 source: str = "relay",
137 rrsig_age_seconds: float | None = None,
138) -> TrustDecision:
139 """Resolve first-trust for a candidate origin key (Rev 6 ladder, I1/I10).
141 Precedence: operator-pin / existing pin -> DNSSEC -> operator-confirm ->
142 fail-closed. Returns a ``TrustDecision``. Pure resolver: no flag read, no
143 relay-path side effects, no network egress beyond the injected ``resolver``.
144 """
145 # --- step 1: operator-pin / existing pin (I1) ----------------------------
146 existing = pinstore.get_pin(conn, entity_uri, node_id)
147 if existing is not None:
148 if pinstore.pin_matches(existing, candidate_key_fpr, now=now):
149 # The candidate matches the established anchor. This is a PURE pin
150 # match — no DNS chain was resolved here, so there is nothing new to
151 # persist and ``last_validated_at`` must NOT be advanced.
152 #
153 # ``last_validated_at`` means "last genuine DNSSEC chain validation"
154 # (I5): only a real re-resolution (the SECURE/ACTIVE first-trust
155 # branch below, or the relay-path ``recheck_relay_binding`` re-resolve)
156 # may stamp it. The relay path runs THIS ladder BEFORE the I5 recency/
157 # revocation re-check, whose cadence — and whose unreachable/suppression
158 # grace — are both anchored on ``pin.last_validated_at``. If this
159 # match branch refreshed it to ``now``, the re-check would see
160 # ``now - last_validated_at == 0`` (< the floor cadence) and HONOR
161 # without re-resolving, so a ``status=revoked`` / rolled-back record
162 # would never be consulted, and relay activity (not DNS) would extend
163 # the suppression grace indefinitely. A pure match persists nothing.
164 return _trusted("matches established pin")
165 # An established pin exists and the candidate does NOT match it. This is
166 # disagreement with a stored anchor (I8) — an attack, NOT a fresh
167 # first-trust. A genuine key change must go through DNSSEC
168 # rotation/revocation (higher epoch / prev_fpr grace / revoked record),
169 # which updates the pin; it never re-enters first-trust. Do NOT fall
170 # through to the DNSSEC tier here.
171 return _rejected("candidate disagrees with established pin")
173 # --- step 2: DNSSEC (I2/I3/I4) -------------------------------------------
174 host = host_from_entity_uri(entity_uri)
175 if host is None:
176 # Non-DNSSEC-capable entity_uri (non-HTTP scheme, IP-literal, userinfo,
177 # port) -> the DNSSEC tier is not applicable; route to operator-confirm
178 # (Rev 6 I3 — an expected ladder path, not an error).
179 return _quarantine_or_fail_closed(
180 conn,
181 entity_uri=entity_uri,
182 node_id=node_id,
183 candidate_key_fpr=candidate_key_fpr,
184 source=source,
185 relay_peer=relay_peer,
186 now=now,
187 settings=settings,
188 confirm_source="not-applicable",
189 )
191 result = resolve_dnssec_binding(entity_uri, resolver=resolver)
192 outcome = result.outcome
194 if outcome is DnssecResult.Outcome.REVOKED:
195 # A DNSSEC-validated revocation tombstone: all keys for the host are dead.
196 return _rejected("dnssec revoked record")
198 if outcome is DnssecResult.Outcome.BOGUS:
199 # Forged / broken chain / transport failure -> fail closed.
200 return _rejected("dnssec bogus chain")
202 if outcome is DnssecResult.Outcome.UNVALIDATABLE:
203 # An absence (or answer) with no validatable proof either way -> reject;
204 # never fall through to operator-confirm on an unvalidatable result (I2).
205 return _rejected("dnssec unvalidatable")
207 if outcome in (
208 DnssecResult.Outcome.INSECURE,
209 DnssecResult.Outcome.ABSENT_AUTHENTICATED,
210 ):
211 # Authenticated unsigned delegation, or authenticated absence. Both are
212 # genuine fall-through-to-operator-confirm signals — EXCEPT when the host
213 # has previously served a SIGNED delegation: sticky-signedness (I2) makes
214 # a later authenticated "absent"/"insecure" an attack -> reject.
215 if ep.signed_delegation_seen(conn, host):
216 return _rejected("authenticated-absent on sticky-signed host")
217 kind = (
218 "insecure-delegation"
219 if outcome is DnssecResult.Outcome.INSECURE
220 else "absent"
221 )
222 return _quarantine_or_fail_closed(
223 conn,
224 entity_uri=entity_uri,
225 node_id=node_id,
226 candidate_key_fpr=candidate_key_fpr,
227 source=source,
228 relay_peer=relay_peer,
229 now=now,
230 settings=settings,
231 confirm_source=kind,
232 )
234 # The only remaining outcome is ACTIVE (NOT_APPLICABLE cannot occur — host
235 # is non-None here; resolve_dnssec_binding only returns NOT_APPLICABLE when
236 # host derivation yields None, which we already handled). Treat any other
237 # value defensively as fail-closed (I10: no permissive default).
238 if outcome is not DnssecResult.Outcome.ACTIVE: 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true
239 return _rejected(f"dnssec unexpected outcome: {outcome.value}")
241 record = result.record
242 if record is None or not record.fpr: 242 ↛ 244line 242 didn't jump to line 244 because the condition on line 242 was never true
243 # ACTIVE must carry a non-empty fingerprint; absence is a contract breach.
244 return _rejected("dnssec active without fingerprint")
246 # The candidate key MUST be the one the validated record binds. A SECURE
247 # record binding a DIFFERENT fingerprint than the relayed candidate is a
248 # mismatch -> reject (carried bytes are never the key source, I6/I7).
249 if record.fpr != candidate_key_fpr:
250 return _rejected("dnssec record binds a different fingerprint")
252 # Monotonic epoch (I4): a record epoch below the host's floor is a rollback.
253 if not ep.accept_epoch(conn, host, record.epoch):
254 return _rejected("dnssec epoch rollback")
256 # RRSIG-age clamp (I4). The age is derived from the validated binding RRSIG
257 # inception threaded out of the resolver, measured against `now` (the same
258 # wall-clock reference the validator checks RRSIG validity against). A 3c
259 # caller may override the derived value via `rrsig_age_seconds`.
260 age = rrsig_age_seconds
261 if age is None: 261 ↛ 272line 261 didn't jump to line 272 because the condition on line 261 was always true
262 if result.rrsig_inception is None:
263 # Contract breach: an ACTIVE binding MUST carry an RRSIG inception
264 # (the validator surfaces it on every SECURE path). With no inception
265 # the age cannot be derived -> fail closed; never treat as fresh (I4).
266 return _rejected("dnssec active without rrsig inception")
267 # `now` is the ladder's wall-clock reference; treat a naive datetime as
268 # UTC to match how the epoch/freshness batches handle `now`.
269 now_ref = now if now.tzinfo is not None else now.replace(tzinfo=UTC)
270 age = now_ref.timestamp() - result.rrsig_inception
272 age_class = fr.classify_rrsig_age(
273 rrsig_age_seconds=age,
274 max_age=settings.federation_dnssec_max_rrsig_age,
275 previously_fresh=fr.was_previously_fresh(conn, host),
276 )
277 if age_class is fr.AgeClass.REJECT:
278 # Previously-fresh host now serving only aged signatures -> attack (I4).
279 return _rejected("aged rrsig on previously-fresh host")
280 if age_class is fr.AgeClass.FALLTHROUGH_CONFIRM:
281 # Aged RRSIG on a never-fresh host -> slow-resigning zone behind a
282 # human gate (operator-confirm), not a hard reject (I4).
283 return _quarantine_or_fail_closed(
284 conn,
285 entity_uri=entity_uri,
286 node_id=node_id,
287 candidate_key_fpr=candidate_key_fpr,
288 source=source,
289 relay_peer=relay_peer,
290 now=now,
291 settings=settings,
292 confirm_source="stale-dnssec",
293 )
295 # SECURE + binds-candidate + epoch-OK + fresh -> TRUSTED. Stamp the host's
296 # sticky-signed + fresh markers and pin the identity (I1).
297 ep.mark_signed_delegation(conn, host)
298 fr.mark_fresh(conn, host, now=now.isoformat())
299 pinstore.upsert_pin(
300 conn,
301 entity_uri=entity_uri,
302 node_id=node_id,
303 key_fpr=record.fpr,
304 epoch=record.epoch,
305 host=host,
306 prev_fpr=record.prev_fpr or None,
307 prev_until=record.prev_until or None,
308 now=now,
309 )
310 return _trusted("dnssec-validated binding")