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

1"""First-trust ladder: operator-pin -> DNSSEC -> operator-confirm -> fail-closed. 

2 

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). 

9 

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: 

13 

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. 

19 

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. 

26 

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. 

33 

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. 

40 

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

44 

45from __future__ import annotations 

46 

47import enum 

48from dataclasses import dataclass 

49from datetime import UTC, datetime 

50from typing import TYPE_CHECKING, Any 

51 

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 

58 

59if TYPE_CHECKING: # type-checkers only; never imported at runtime (I11). 

60 from .resolver import Resolver 

61 

62 

63@dataclass(frozen=True) 

64class TrustDecision: 

65 """The ladder's verdict for a candidate origin key. 

66 

67 ``outcome`` is always populated; ``reason`` is a short functional label for 

68 audit/logging (never operator-education prose). 

69 """ 

70 

71 class Outcome(enum.Enum): 

72 TRUSTED = "trusted" 

73 PENDING_CONFIRM = "pending_confirm" 

74 REJECTED = "rejected" 

75 

76 outcome: TrustDecision.Outcome 

77 reason: str 

78 

79 

80def _trusted(reason: str) -> TrustDecision: 

81 return TrustDecision(TrustDecision.Outcome.TRUSTED, reason) 

82 

83 

84def _rejected(reason: str) -> TrustDecision: 

85 return TrustDecision(TrustDecision.Outcome.REJECTED, reason) 

86 

87 

88def _pending(reason: str) -> TrustDecision: 

89 return TrustDecision(TrustDecision.Outcome.PENDING_CONFIRM, reason) 

90 

91 

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). 

105 

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

124 

125 

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). 

140 

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

172 

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 ) 

190 

191 result = resolve_dnssec_binding(entity_uri, resolver=resolver) 

192 outcome = result.outcome 

193 

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

197 

198 if outcome is DnssecResult.Outcome.BOGUS: 

199 # Forged / broken chain / transport failure -> fail closed. 

200 return _rejected("dnssec bogus chain") 

201 

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

206 

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 ) 

233 

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

240 

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

245 

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

251 

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

255 

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 

271 

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 ) 

294 

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