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
« 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).
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.
14This module owns two pieces:
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``.
22Asymmetric failure semantics (Rev 6 I5, the recency engine):
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 ============================= ============================ ===================
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).
50Cadence / TTL persistence (the seam's chosen approach):
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).
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"""
71from __future__ import annotations
73from datetime import UTC, datetime, timedelta
74from typing import Any
76from ..origin_identity import OriginIdentityError, _audit_relay
79class RecheckRejected(OriginIdentityError):
80 """The relay-path DNSSEC recency/revocation re-check rejected the binding.
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 """
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).
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))
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)
110def _within_cadence(pin: Any, *, now: datetime, settings: Any) -> bool:
111 """Whether ``now`` is still within the re-check cadence of the pin.
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
131def _unreachable_grace_seconds(settings: Any) -> float:
132 """The suppression / unreachable grace = ``min(grace, k * ttl)`` (Rev 6 I5).
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))
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.
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)
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
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 )
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).
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.
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.
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
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 )
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
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
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
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 )
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 )
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 )
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 )
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 )
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 )
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
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 )
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 )
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).
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
378def _policy_ceiling(*, observed_at: datetime, settings: Any) -> datetime:
379 """The I6 policy ceiling on a rotation-grace window: ``observed_at + grace``.
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)
390def _grace_deadline(record: Any, *, observed_at: datetime, settings: Any) -> datetime:
391 """The clamped rotation-grace deadline for ``record.prev_fpr`` (Rev 6 I6).
393 The honored ``prev_fpr`` window is::
395 min(parse(record.prev_until) if parseable, observed_at + grace_hours)
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)
416def _records_same_rotation(pin: Any, *, retiring_fpr: str) -> bool:
417 """Whether the pin already records the rotation retiring ``retiring_fpr``.
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)
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.
444 Rotation-grace clamping (I6 — the record may SHORTEN the window, never EXTEND
445 it past policy):
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
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())
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).
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 )