Coverage for node / src / stigmem_node / federation / dnssec / recheck.py: 90%

112 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-18 05:34 +0000

1"""Relay-path DNSSEC recency/revocation re-check (Rev 6 I5 / build-phase 3c). 

2 

3Rev 6 I5: a relayed fact's origin key is honored only if a DNSSEC re-check 

4within the effective interval confirms the binding. The cadence (3c.1) is 

5``clamp(record_DNS_TTL, floor, cap)`` — the origin's DNS TTL is its own freshness 

6signal, the admin sets the bounds — and the per-origin dedup (NF-R5C-5) is 

7anchored on the PERSISTENT pin (``pin.last_validated_at`` + ``_within_cadence``), 

8NOT an in-memory cache: within the effective interval of an origin's last 

9validated re-check the binding is HONORED with no DNS egress, and because the 

10anchor is the persisted pin the cadence survives restarts (revocation stays 

11detectable within the interval across process boundaries). The asymmetric failure 

12semantics (3c.2) ride on top. 

13 

14This module owns two pieces: 

15 

16 * ``effective_interval(ttl, floor, cap)`` — the clamp. A ``None`` TTL (a 

17 non-SECURE binding has no TTL to clamp) falls back to the floor. 

18 * ``recheck_relay_binding`` — the ASYMMETRIC recency/revocation engine (3c.2), 

19 whose cadence short-circuit (``_within_cadence``) is the per-origin dedup, 

20 anchored on the persistent ``pin.last_validated_at``. 

21 

22Asymmetric failure semantics (Rev 6 I5, the recency engine): 

23 

24 ============================= ============================ =================== 

25 re-check outcome disposition audit event 

26 ============================= ============================ =================== 

27 within cadence HONOR (no DNS) — 

28 no pin on the recheck path REJECT (contract breach) — 

29 ACTIVE, fpr matches pin HONOR (refresh + mark fresh) — 

30 ACTIVE, rotation (epoch+ / HONOR (advance pin + grace) — 

31 new fpr matches record) 

32 ACTIVE, fpr matches NEITHER REJECT relay_origin_key_changed 

33 ACTIVE, epoch < host floor REJECT (rollback) relay_origin_rolled_back 

34 ACTIVE, aged RRSIG REJECT (operator-confirm is relay_origin_recheck_stale 

35 (FALLTHROUGH/REJECT) first-trust-only) 

36 REVOKED (positive tombstone) REJECT relay_origin_revoked 

37 suppression (BOGUS / HONOR while within 

38 UNVALIDATABLE / INSECURE / min(grace, k*ttl) of the 

39 ABSENT, incl. transport pin's last_validated_at; 

40 SERVFAIL->BOGUS) else REJECT (unreachable) relay_origin_recheck_unreachable 

41 ============================= ============================ =================== 

42 

43The asymmetry: a POSITIVE answer that withdraws the key (REVOKED / rollback) is 

44hard-rejected — an attacker cannot forge a withdrawal record, so a positive 

45answer is always honored as proof. SUPPRESSION (no positive proof of anything) 

46is time-boxed fail-closed and is NEVER treated as a positive revocation (that 

47would hand an attacker a revocation primitive) and NEVER extends a compromised 

48key indefinitely (that would defeat recency). 

49 

50Cadence / TTL persistence (the seam's chosen approach): 

51 

52 The cadence is anchored on the PERSISTENT ``pin.last_validated_at`` so 

53 revocation is detectable within the interval ACROSS RESTARTS. The pin does NOT 

54 persist the binding TTL (no schema change in 3c.2), so the cadence uses 

55 ``effective_interval(ttl=None, ...) == floor`` — the most conservative (most 

56 frequent) cadence: a re-resolution happens at least every ``floor`` seconds 

57 from the pin's last validation, which only ever IMPROVES recency. The 

58 unreachable-grace window's ``k*ttl`` term, which also needs a TTL the pin does 

59 not store, uses the conservative ``recheck_cap_seconds`` as the TTL bound; with 

60 the maintainer-pinned defaults this yields ``min(86400, 4*3600) = 14400s``, 

61 tighter than the absolute 24h cap (favoring recency / fail-closed). At most one 

62 re-resolution per origin per re-check call (the engine resolves once). 

63 

64No DNSSEC / ``dnspython`` import is reachable from this module at load time (Rev 6 

65I11): ``resolve_dnssec_binding`` (and the epoch/freshness/pin DB primitives) are 

66imported function-locally, and the only DNS egress is through the injected 

67``resolver`` (TX-4 SSRF: ``LiveResolver`` / a system stub resolver only — never a 

68peer-supplied address; the engine introduces no new egress). 

69""" 

70 

71from __future__ import annotations 

72 

73from datetime import UTC, datetime, timedelta 

74from typing import Any 

75 

76from ..origin_identity import OriginIdentityError, _audit_relay 

77 

78 

79class RecheckRejected(OriginIdentityError): 

80 """The relay-path DNSSEC recency/revocation re-check rejected the binding. 

81 

82 A typed subclass of :class:`OriginIdentityError` so the relay call site maps 

83 a reject cleanly to its fail-closed verdict. Raised on every asymmetric 

84 reject branch (revoked / rollback / aged / key-changed / unreachable-past- 

85 grace / missing-pin contract breach). The accompanying ``relay_origin_*`` 

86 audit event is emitted BEFORE the raise. 

87 """ 

88 

89 

90def effective_interval(ttl: int | None, *, floor: int, cap: int) -> int: 

91 """The relay-path re-check cadence ``clamp(ttl, floor, cap)`` (Rev 6 §7/I5). 

92 

93 The origin's DNS TTL drives the cadence; the admin-set ``floor`` (anti-storm) 

94 and ``cap`` (DNS-load bound) clamp it. A ``None`` TTL (a binding that did not 

95 resolve SECURE, so it carries no TTL) has no freshness signal to honor and 

96 falls back to the ``floor`` — the most conservative cadence. ``floor`` is 

97 applied after ``cap`` so a (mis)configured ``floor > cap`` still yields the 

98 floor (never a value below it), keeping the anti-storm guarantee. 

99 """ 

100 if ttl is None: 

101 return floor 

102 return max(floor, min(ttl, cap)) 

103 

104 

105def _as_utc(dt: datetime) -> datetime: 

106 """Normalize a naive datetime to UTC (the engine's wall-clock convention).""" 

107 return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC) 

108 

109 

110def _within_cadence(pin: Any, *, now: datetime, settings: Any) -> bool: 

111 """Whether ``now`` is still within the re-check cadence of the pin. 

112 

113 Anchored on the PERSISTENT ``pin.last_validated_at`` (recency is detectable 

114 within the interval across restarts). The TTL is not persisted, so the 

115 cadence uses ``effective_interval(None, ...) == floor`` — the most 

116 conservative (most frequent) cadence. An unparseable ``last_validated_at`` 

117 fails closed to "past cadence" so a re-resolution happens (never stale). 

118 """ 

119 interval = effective_interval( 

120 None, 

121 floor=settings.federation_dnssec_recheck_floor_seconds, 

122 cap=settings.federation_dnssec_recheck_cap_seconds, 

123 ) 

124 try: 

125 validated_at = _as_utc(datetime.fromisoformat(pin.last_validated_at)) 

126 except (ValueError, TypeError): 

127 return False # indeterminate -> re-resolve (fail toward recency) 

128 return (_as_utc(now) - validated_at).total_seconds() < interval 

129 

130 

131def _unreachable_grace_seconds(settings: Any) -> float: 

132 """The suppression / unreachable grace = ``min(grace, k * ttl)`` (Rev 6 I5). 

133 

134 The pin does not persist the binding TTL, so the ``k * ttl`` term uses the 

135 conservative ``recheck_cap_seconds`` as the TTL bound (documented in the 

136 module docstring): with the maintainer-pinned defaults this is 

137 ``min(86400, 4 * 3600) = 14400s``, tighter than the 24h absolute cap. 

138 """ 

139 k = settings.federation_dnssec_unreachable_ttl_multiple 

140 ttl_bound = settings.federation_dnssec_recheck_cap_seconds 

141 return float(min(settings.federation_dnssec_unreachable_grace_seconds, k * ttl_bound)) 

142 

143 

144def _suppression_within_grace(pin: Any, *, now: datetime, settings: Any) -> bool: 

145 """Whether a no-positive-proof re-check is still inside the unreachable grace. 

146 

147 Measured from the pin's PERSISTENT ``last_validated_at`` (the last time the 

148 binding was positively proven). An unparseable timestamp fails closed (past 

149 grace) so suppression never extends a key indefinitely. 

150 """ 

151 try: 

152 validated_at = _as_utc(datetime.fromisoformat(pin.last_validated_at)) 

153 except (ValueError, TypeError): 

154 return False # indeterminate -> past grace (fail closed, recency wins) 

155 elapsed = (_as_utc(now) - validated_at).total_seconds() 

156 return elapsed <= _unreachable_grace_seconds(settings) 

157 

158 

159# Validator outcomes carrying NO positive proof for a PINNED binding: a transport 

160# failure (SERVFAIL/timeout) maps to BOGUS (total/fail-closed); UNVALIDATABLE / 

161# INSECURE / ABSENT_AUTHENTICATED are likewise "no positive proof" on the relay 

162# path. None is honored as revocation (Rev 6 I5 / NF-R5D-2). 

163def _is_suppression(outcome: Any) -> bool: 

164 from .resolve import DnssecResult 

165 

166 return outcome in ( 

167 DnssecResult.Outcome.BOGUS, 

168 DnssecResult.Outcome.UNVALIDATABLE, 

169 DnssecResult.Outcome.INSECURE, 

170 DnssecResult.Outcome.ABSENT_AUTHENTICATED, 

171 DnssecResult.Outcome.NOT_APPLICABLE, 

172 ) 

173 

174 

175def recheck_relay_binding( 

176 conn: Any, 

177 *, 

178 host: str | None = None, 

179 entity_uri: str, 

180 node_id: str, 

181 key_fpr: str, 

182 resolver: Any, 

183 settings: Any, 

184 now: datetime, 

185) -> None: 

186 """Re-check a pinned DNSSEC binding's recency/revocation (Rev 6 I5). 

187 

188 Returns ``None`` on HONOR (the relayed key is still current); raises 

189 :class:`RecheckRejected` (a fail-closed ``OriginIdentityError``) on every 

190 asymmetric reject branch, after emitting the matching ``relay_origin_*`` 

191 audit event. See the module docstring for the full outcome table. 

192 

193 The rollback/freshness defenses (``accept_epoch`` / ``mark_signed_delegation`` 

194 / ``mark_fresh``) are keyed on the canonical host RE-DERIVED here from the 

195 signed ``entity_uri`` (``host_from_entity_uri`` — the single canonical I3 

196 derivation, the same one ``resolve_dnssec_binding`` uses internally). The 

197 caller-supplied ``host`` param is ignored (kept only for source/back-compat): 

198 keying the epoch floor on a caller-passed host risks an empty or mismatched 

199 key that silences the monotonic-epoch rollback defense. When the entity_uri is 

200 not DNSSEC-capable the re-derivation is ``None``; the pinned host (the 

201 canonical host the pin was created with) is the fallback. 

202 

203 The caller (``origin_identity._dnssec_first_trust_keys`` TRUSTED branch) owns 

204 the transaction; HONOR mutations (pin refresh / rotation advance / fresh 

205 stamp) are written on ``conn`` and the caller commits. 

206 """ 

207 from . import epoch as ep 

208 from . import freshness as fr 

209 from . import pin as pinstore 

210 from .host import host_from_entity_uri 

211 from .resolve import DnssecResult, resolve_dnssec_binding 

212 

213 # --- step 1: the pin MUST exist (contract breach otherwise) --------------- 

214 pin = pinstore.get_pin(conn, entity_uri, node_id) if conn is not None else None 

215 if pin is None: 

216 # The TRUSTED branch always pins before re-checking; a missing pin on the 

217 # recheck path is a contract breach -> fail closed (resolver untouched). 

218 raise RecheckRejected( 

219 f"relayed origin {node_id!r} ({entity_uri!r}) recheck has no pin (contract breach)" 

220 ) 

221 

222 # Key the rollback/freshness defenses on the CANONICAL host re-derived from 

223 # the signed entity_uri (R1-F2), never the caller-passed param. A non-DNSSEC- 

224 # capable entity_uri re-derives None; the pin's own (canonical) host is the 

225 # fallback so the epoch floor is never keyed on an empty host. 

226 host = host_from_entity_uri(entity_uri) or pin.host 

227 

228 # --- step 2: cadence — within interval -> HONOR with no DNS ---------------- 

229 if _within_cadence(pin, now=now, settings=settings): 

230 return # the pinned key is current as of its last successful re-check 

231 

232 # --- step 3: re-resolve (at most once per origin per call) ---------------- 

233 result = resolve_dnssec_binding(entity_uri, resolver=resolver) 

234 outcome = result.outcome 

235 

236 # --- REVOKED: positive withdrawal -> hard reject -------------------------- 

237 if outcome is DnssecResult.Outcome.REVOKED: 

238 _audit_relay( 

239 "relay_origin_revoked", 

240 node_id=node_id, 

241 entity_uri=entity_uri, 

242 detail_epoch=result.record.epoch if result.record else None, 

243 ) 

244 raise RecheckRejected( 

245 f"relayed origin {node_id!r} ({entity_uri!r}) revoked by dnssec record" 

246 ) 

247 

248 # --- ACTIVE: rotation / rollback / aged / match --------------------------- 

249 if outcome is DnssecResult.Outcome.ACTIVE: 

250 record = result.record 

251 if record is None or not record.fpr: 251 ↛ 254line 251 didn't jump to line 254 because the condition on line 251 was never true

252 # ACTIVE must carry a fingerprint; absence is a contract breach -> 

253 # treat as suppression (no positive proof), never trust it. 

254 return _suppression_disposition( 

255 pin, node_id=node_id, entity_uri=entity_uri, now=now, settings=settings 

256 ) 

257 

258 # Monotonic epoch (I4): a record epoch below the host floor is a rollback. 

259 if not ep.accept_epoch(conn, host, record.epoch): 

260 _audit_relay( 

261 "relay_origin_rolled_back", 

262 node_id=node_id, 

263 entity_uri=entity_uri, 

264 detail_epoch=record.epoch, 

265 ) 

266 raise RecheckRejected( 

267 f"relayed origin {node_id!r} ({entity_uri!r}) epoch rollback " 

268 f"(record epoch {record.epoch} below host floor)" 

269 ) 

270 

271 # Aged-RRSIG clamp (I4). On the RELAY path an aged binding is a HARD 

272 # REJECT — operator-confirm is first-trust-only and cannot run mid-relay, 

273 # so both FALLTHROUGH_CONFIRM and the previously-fresh REJECT -> reject. 

274 if _rrsig_is_aged(result, now=now, settings=settings, fr=fr): 

275 _audit_relay( 

276 "relay_origin_recheck_stale", 

277 node_id=node_id, 

278 entity_uri=entity_uri, 

279 ) 

280 raise RecheckRejected( 

281 f"relayed origin {node_id!r} ({entity_uri!r}) aged dnssec signature on relay path" 

282 ) 

283 

284 # The live record's fingerprint must reconcile with the stored anchor 

285 # (Rev 6 I4/I6): it HONORS when it matches the pin's current-or-prev- 

286 # within-grace fpr (steady state), OR when it is a genuine rotation 

287 # (a NEW fpr at a STRICTLY HIGHER epoch — monotonic-epoch-protected). A 

288 # record.fpr matching NEITHER (a different key WITHOUT an epoch bump) is 

289 # an unsanctioned key change -> reject. 

290 is_steady_or_grace = pinstore.pin_matches(pin, record.fpr, now=now) 

291 is_rotation = record.fpr != pin.key_fpr and record.epoch > pin.epoch 

292 if not (is_steady_or_grace or is_rotation): 

293 _audit_relay( 

294 "relay_origin_key_changed", 

295 node_id=node_id, 

296 entity_uri=entity_uri, 

297 ) 

298 raise RecheckRejected( 

299 f"relayed origin {node_id!r} ({entity_uri!r}) live record binds a key matching " 

300 f"neither the pinned current nor grace-window prior key (no rotation epoch bump)" 

301 ) 

302 

303 # HONOR: advance the pin to the live record. On a rotation (a new fpr) the 

304 # OLD pinned key becomes prev_fpr with a live grace window (I6); on steady 

305 # state the pin is refreshed in place. 

306 _honor_active( 

307 conn, 

308 pin=pin, 

309 record=record, 

310 host=host, 

311 entity_uri=entity_uri, 

312 node_id=node_id, 

313 now=now, 

314 is_rotation=is_rotation, 

315 settings=settings, 

316 ep=ep, 

317 fr=fr, 

318 pinstore=pinstore, 

319 ) 

320 

321 # Rotation grace via prev_fpr (Rev 6 I6, 3c.3): the relayed fact's own 

322 # signing key must be one the (now-refreshed) pin honors — the CURRENT 

323 # key always, or the committed PRIOR key while inside its grace window. 

324 # A fact still signed by the retiring key verifies within 

325 # ``federation_key_rotation_grace_hours`` of the rotation, NOT past it 

326 # (the shared ``pin_matches`` predicate). Re-read the pin so a rotation 

327 # this re-check just committed (old key -> prev_fpr) is reflected. 

328 refreshed = pinstore.get_pin(conn, entity_uri, node_id) 

329 if refreshed is None or not pinstore.pin_matches(refreshed, key_fpr, now=now): 

330 _audit_relay( 

331 "relay_origin_key_changed", 

332 node_id=node_id, 

333 entity_uri=entity_uri, 

334 ) 

335 raise RecheckRejected( 

336 f"relayed origin {node_id!r} ({entity_uri!r}) signing key is neither the " 

337 f"current pinned key nor a prior key within rotation grace (I6)" 

338 ) 

339 return 

340 

341 # --- suppression (no positive proof) -> time-boxed fail-closed ------------ 

342 if _is_suppression(outcome): 342 ↛ 348line 342 didn't jump to line 348 because the condition on line 342 was always true

343 return _suppression_disposition( 

344 pin, node_id=node_id, entity_uri=entity_uri, now=now, settings=settings 

345 ) 

346 

347 # --- defensive: any unmodeled outcome is fail-closed (I10) ---------------- 

348 raise RecheckRejected( 

349 f"relayed origin {node_id!r} ({entity_uri!r}) recheck unexpected outcome: {outcome.value}" 

350 ) 

351 

352 

353def _rrsig_is_aged(result: Any, *, now: datetime, settings: Any, fr: Any) -> bool: 

354 """Whether the ACTIVE binding's RRSIG is aged (relay-path reject signal, I4). 

355 

356 Reuses ``classify_rrsig_age``: an aged RRSIG that on first-trust would route to 

357 operator-confirm (FALLTHROUGH_CONFIRM) OR hard-reject (previously-fresh REJECT) 

358 is, on the RELAY path, a reject either way (operator-confirm cannot run 

359 mid-relay). A missing inception (contract breach) is treated as aged (fail 

360 closed; never treat as fresh). 

361 """ 

362 if result.rrsig_inception is None: 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true

363 return True 

364 age = _as_utc(now).timestamp() - result.rrsig_inception 

365 # A PINNED binding is by definition a host that previously served a fresh, 

366 # validated signature (the first-trust ladder stamped ``mark_fresh`` when it 

367 # pinned). On the relay path the previously-fresh hard-reject therefore 

368 # always applies — and FALLTHROUGH_CONFIRM is likewise a relay reject 

369 # (operator-confirm cannot run mid-relay) — so any non-OK age is aged. 

370 age_class = fr.classify_rrsig_age( 

371 rrsig_age_seconds=age, 

372 max_age=settings.federation_dnssec_max_rrsig_age, 

373 previously_fresh=True, 

374 ) 

375 return age_class is not fr.AgeClass.OK 

376 

377 

378def _policy_ceiling(*, observed_at: datetime, settings: Any) -> datetime: 

379 """The I6 policy ceiling on a rotation-grace window: ``observed_at + grace``. 

380 

381 ``observed_at`` is WHEN THE ROTATION WAS FIRST OBSERVED — NOT a fresh ``now`` 

382 recomputed on every re-check. Anchoring on the first-observation time is what 

383 makes the window lapse on schedule: a steady-state record that keeps re- 

384 advertising ``prev_fpr`` cannot push the deadline forward, because it always 

385 measures from the same fixed observation instant. 

386 """ 

387 return _as_utc(observed_at) + timedelta(hours=settings.federation_key_rotation_grace_hours) 

388 

389 

390def _grace_deadline(record: Any, *, observed_at: datetime, settings: Any) -> datetime: 

391 """The clamped rotation-grace deadline for ``record.prev_fpr`` (Rev 6 I6). 

392 

393 The honored ``prev_fpr`` window is:: 

394 

395 min(parse(record.prev_until) if parseable, observed_at + grace_hours) 

396 

397 The record may SHORTEN the grace (a ``prev_until`` sooner than the policy 

398 ceiling is honored as the earlier deadline) but NEVER EXTEND it past policy 

399 (a far-future or unparseable ``prev_until`` is clamped to the ceiling). An 

400 empty/unparseable ``prev_until`` is NOT "no expiry" — it falls back to the 

401 policy ceiling. This defeats a record that sets ``prev_until=2999-…`` (which 

402 would otherwise honor a retired key indefinitely, breaking I6). 

403 """ 

404 ceiling = _policy_ceiling(observed_at=observed_at, settings=settings) 

405 if not record.prev_until: 

406 return ceiling 

407 try: 

408 record_deadline = _as_utc(datetime.fromisoformat(record.prev_until)) 

409 except (ValueError, TypeError): 

410 # Unparseable record deadline: never treat as "no expiry" -> clamp to 

411 # the policy ceiling (the record may only shorten, never extend, I6). 

412 return ceiling 

413 return min(record_deadline, ceiling) 

414 

415 

416def _records_same_rotation(pin: Any, *, retiring_fpr: str) -> bool: 

417 """Whether the pin already records the rotation retiring ``retiring_fpr``. 

418 

419 True when ``pin.prev_fpr`` is exactly the retiring key and ``pin.prev_until`` 

420 is already set (a committed grace window). Used to distinguish FIRST 

421 observation of a rotation (set the clamped deadline once) from a STEADY-STATE 

422 re-check (preserve the pinned deadline so the window lapses on schedule). 

423 """ 

424 return bool(retiring_fpr) and pin.prev_fpr == retiring_fpr and bool(pin.prev_until) 

425 

426 

427def _honor_active( 

428 conn: Any, 

429 *, 

430 pin: Any, 

431 record: Any, 

432 host: str, 

433 entity_uri: str, 

434 node_id: str, 

435 now: datetime, 

436 is_rotation: bool, 

437 settings: Any, 

438 ep: Any, 

439 fr: Any, 

440 pinstore: Any, 

441) -> None: 

442 """HONOR an ACTIVE re-check: advance the pin, set rotation grace (I6), mark fresh. 

443 

444 Rotation-grace clamping (I6 — the record may SHORTEN the window, never EXTEND 

445 it past policy): 

446 

447 * On a rotation (``is_rotation`` — a new fpr at a strictly higher epoch) the 

448 OLD pinned key becomes ``prev_fpr`` with ``prev_until`` = 

449 ``min(record.prev_until, now + grace_hours)`` — the rotation is first 

450 observed NOW, so the policy ceiling is ``now + grace_hours``. 

451 * On a STEADY-STATE re-check that re-advertises the SAME rotation already 

452 pinned (``pin.prev_fpr`` == the record's ``prev_fpr`` and a pinned 

453 ``prev_until`` exists), the pinned ``prev_until`` is PRESERVED — never 

454 recomputed from a fresh ``now`` — so the window lapses on its original 

455 schedule and a re-advertising record cannot refresh it forever. 

456 * On a steady-state re-check that newly advertises a rotation grace the pin 

457 has not yet recorded, the clamped deadline ``min(record.prev_until, 

458 now + grace_hours)`` is committed once (first observation is now). 

459 * On a steady-state match with no record-carried grace, the existing pinned 

460 ``prev_fpr``/``prev_until`` is preserved (a prior rotation's window lapses 

461 via ``pin_matches``' ``prev_until`` check). 

462 """ 

463 if is_rotation: 

464 # First observation of a new rotation: the retiring key is the old pin, 

465 # observed NOW. Clamp the record's prev_until to now + grace_hours. 

466 prev_fpr = pin.key_fpr 

467 prev_until = _grace_deadline(record, observed_at=now, settings=settings).isoformat() 

468 elif record.prev_fpr: 

469 # Steady state, the live record re-advertises a rotation grace for 

470 # record.prev_fpr. 

471 if _records_same_rotation(pin, retiring_fpr=record.prev_fpr): 471 ↛ 480line 471 didn't jump to line 480 because the condition on line 471 was always true

472 # The pin already records THIS rotation: PRESERVE the committed 

473 # deadline. Do NOT recompute from a fresh now (that would refresh the 

474 # retiring key's grace forever and defeat I6). 

475 prev_fpr = pin.prev_fpr 

476 prev_until = pin.prev_until 

477 else: 

478 # First observation of this advertised rotation: clamp once, observed 

479 # now (the record may shorten, never extend past now + grace). 

480 prev_fpr = record.prev_fpr 

481 prev_until = _grace_deadline(record, observed_at=now, settings=settings).isoformat() 

482 else: 

483 # Steady state with no record-carried grace: PRESERVE the existing pin's 

484 # rotation-grace window (a prior rotation's prev_fpr/prev_until). A 

485 # steady-state record stops re-advertising prev_fpr once the rotation 

486 # settles, but the pinned grace window must persist until prev_until so a 

487 # fact still signed by the retiring key verifies within grace (I6). The 

488 # window naturally lapses via pin_matches' prev_until check. 

489 prev_fpr = pin.prev_fpr 

490 prev_until = pin.prev_until 

491 

492 pinstore.upsert_pin( 

493 conn, 

494 entity_uri=entity_uri, 

495 node_id=node_id, 

496 key_fpr=record.fpr, 

497 epoch=record.epoch, 

498 host=host, 

499 prev_fpr=prev_fpr, 

500 prev_until=prev_until, 

501 now=now, 

502 ) 

503 ep.mark_signed_delegation(conn, host) 

504 fr.mark_fresh(conn, host, now=_as_utc(now).isoformat()) 

505 

506 

507def _suppression_disposition( 

508 pin: Any, *, node_id: str, entity_uri: str, now: datetime, settings: Any 

509) -> None: 

510 """A no-positive-proof re-check: HONOR within grace, else fail-closed (I5). 

511 

512 NEVER emits ``relay_origin_revoked`` (suppression is not a positive 

513 revocation primitive); past the unreachable grace it emits 

514 ``relay_origin_recheck_unreachable`` and raises. 

515 """ 

516 if _suppression_within_grace(pin, now=now, settings=settings): 

517 return # honor the pinned key up to the bounded grace 

518 _audit_relay( 

519 "relay_origin_recheck_unreachable", 

520 node_id=node_id, 

521 entity_uri=entity_uri, 

522 ) 

523 raise RecheckRejected( 

524 f"relayed origin {node_id!r} ({entity_uri!r}) recheck unreachable past grace " 

525 f"(no positive dnssec proof; fail-closed, NOT revoked)" 

526 )