Coverage for node / src / stigmem_node / federation / origin_identity.py: 89%
196 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"""Phase 2a — resolve an origin node_id to the verified pubkey(s) it may sign with.
3Chain: node_id → peers.entity_uri (verified at registration) → stored OrgManifest →
4self-verify (+ rotation-window prior key). Fail-closed: any missing/invalid link raises.
5The consumer (origin_sig verification) lands in Phase 2b.
7Phase 2c W3.2 — for a RELAYED fact the origin is NOT a direct peer, so the 2a chain
8(which needs a stored peer manifest + active peer row) cannot resolve it. The receiver
9instead establishes trust in the origin's key by FETCHING the origin's manifest from the
10``entity_uri`` that W3.1 bound into the signed origin block, verifying it, and binding
11``node_id ↔ entity_uri`` under a fail-closed uniqueness rule (``resolve_origin_key_for_relay``).
12"""
14from __future__ import annotations
16import contextlib
17import logging
18from datetime import UTC, datetime, timedelta
19from typing import TYPE_CHECKING, Any
20from urllib.parse import urlparse, urlsplit, urlunsplit
22import httpx
24from ..db import db
25from ..identity.manifest import (
26 ManifestError,
27 OrgManifest,
28 manifest_from_dict,
29 verify_manifest,
30)
31from ..identity.trust_store import get_peer_manifest, store_peer_manifest
32from ..net_util import resolve_pinned_address
33from ..settings import settings
34from .origin_pins import fingerprint_from_pubkey, get_origin_pin
36if TYPE_CHECKING:
37 # Type-only import of the DNSSEC resolver Protocol (Rev 6 I11): never imported
38 # at runtime, so importing this module on a default node loads no DNSSEC code.
39 from .dnssec.resolver import Resolver
41logger = logging.getLogger("stigmem.federation.origin_identity")
44class OriginIdentityError(ValueError):
45 """Origin identity could not be verified (fail-closed)."""
48def _now() -> datetime:
49 """Current UTC time. Indirected through a module function so tests own the clock."""
50 return datetime.now(UTC)
53def _make_dnssec_resolver() -> Resolver:
54 """Construct the DNSSEC validating resolver for the first-trust tier (Rev 6 I11).
56 Indirected through a module function so (a) the dnspython-backed
57 ``LiveResolver`` is imported ONLY here, function-locally, on the flag-on path
58 — importing this module on a default node never loads the ``[federation-dnssec]``
59 extra (I11) — and (b) tests can inject an offline ``FixtureResolver`` by
60 patching this single seam.
61 """
62 from .dnssec.resolver import LiveResolver
64 return LiveResolver()
67def _dnssec_first_trust_keys(
68 conn: Any,
69 *,
70 node_id: str,
71 entity_uri: str,
72 candidate: OrgManifest | None,
73 candidate_fp: str | None,
74 relay_peer: str | None,
75) -> set[str] | None:
76 """Phase-3 DNSSEC first-trust tier at the relay fail-closed terminals (Rev 6).
78 Strictly ADDITIVE at ``relay_origin_unanchored`` (I8): reached only after
79 operator-pin -> stored-binding -> fetch-on-first TOFU have all declined and the
80 origin is unknown + unreachable. Gated on ``federation_dnssec_trust_enabled``;
81 when the flag is OFF this is a no-op (returns None) and the caller raises the
82 unchanged ``relay_origin_unanchored`` — byte-identical to today, with no ladder
83 call and no DNSSEC resolver constructed.
85 When ON, the disposition depends on whether a candidate key exists:
87 * **No candidate key** (``candidate is None`` — the no-candidate terminal):
88 the DNSSEC record binds ``entity_uri -> fingerprint`` but yields NO key
89 BYTES, and a relayed fact cannot be signature-verified without the key.
90 The DNSSEC tier can therefore neither anchor (no bytes to return) nor
91 route-to-confirm (no candidate fingerprint to quarantine) a key that does
92 not exist. The terminal stays fail-closed — but it is now FLAG-AWARE
93 (consulted + short-circuited here, never silently bypassed), satisfying
94 plan TB-2. Returns None -> the caller raises ``relay_origin_unanchored``.
96 * **Candidate exists** (the candidate-exists terminal): run the first-trust
97 ladder against the candidate's fingerprint.
98 - TRUSTED -> the ladder validated + pinned the binding; a relayed
99 DNSSEC key is then honored only after the I5 relay-path
100 recency/revocation re-check (``recheck_relay_binding``, 3c.2) confirms
101 the binding is still current. The re-check HONORS (returns) -> return
102 the verified key set; it raises a typed reject (``RecheckRejected``, an
103 ``OriginIdentityError``) on revoked / rollback / aged / key-changed /
104 unreachable-past-grace -> fail closed (the audit was already emitted by
105 the re-check engine).
106 - PENDING_CONFIRM -> the ladder quarantined the binding; the fact cannot
107 be trusted until an operator confirms the fingerprint out-of-band.
108 Raise (operator-confirm pending).
109 - REJECTED -> raise (revoked / rollback / bogus / unvalidatable / queue
110 full — every reject branch of the I10 outcome lattice).
112 Raises ``OriginIdentityError`` on any non-trust verdict (or a re-check
113 reject). Returns the verified key set only on TRUSTED + a HONOR re-check.
114 """
115 if not settings.federation_dnssec_trust_enabled:
116 return None # flag OFF — no ladder, no resolver; caller fails closed as today
118 from .dnssec.ladder import TrustDecision, resolve_first_trust
119 from .dnssec.recheck import recheck_relay_binding
121 if candidate is None or not candidate_fp:
122 # No-candidate terminal: no key bytes exist to anchor, no candidate fpr to
123 # confirm. DNSSEC cannot help here (it binds a fingerprint, never key
124 # bytes). Flag-aware fail-closed (TB-2): the caller raises unanchored.
125 return None
127 from .dnssec.host import host_from_entity_uri
129 host = host_from_entity_uri(entity_uri)
130 _audit_relay(
131 "relay_origin_dnssec_first_trust_attempt",
132 node_id=node_id,
133 entity_uri=entity_uri,
134 detail_host=host or "",
135 )
137 decision = resolve_first_trust(
138 conn,
139 entity_uri=entity_uri,
140 node_id=node_id,
141 candidate_key_fpr=candidate_fp,
142 resolver=_make_dnssec_resolver(),
143 settings=settings,
144 now=_now(),
145 relay_peer=relay_peer,
146 source="relay",
147 )
149 if decision.outcome is TrustDecision.Outcome.TRUSTED:
150 # I5 / 3c.2: the ladder validated + pinned the binding, but a relayed
151 # DNSSEC key is honored only after the relay-path recency/revocation
152 # re-check confirms the binding is still current (revocation works while
153 # the origin's node is unreachable, because its DNS is independent). The
154 # re-check HONORS (returns) when current, or raises a typed reject
155 # (``RecheckRejected``, an ``OriginIdentityError``) on revoked / rollback /
156 # aged / key-changed / unreachable-past-grace — every reject having
157 # emitted its ``relay_origin_*`` audit. On honor the verified key set is
158 # returned; on a reject the (already-audited) error propagates and the
159 # caller fails closed. The ladder committed the pin via ``conn``; the
160 # re-check writes any HONOR mutations (rotation advance / fresh stamp) on
161 # the same ``conn``, and the caller commits.
162 try:
163 recheck_relay_binding(
164 conn,
165 host=host or "",
166 entity_uri=entity_uri,
167 node_id=node_id,
168 key_fpr=candidate_fp,
169 resolver=_make_dnssec_resolver(),
170 settings=settings,
171 now=_now(),
172 )
173 except OriginIdentityError:
174 # A typed re-check reject (RecheckRejected) or any other identity
175 # error: persist the ladder/re-check side effects (epoch/sticky/pin
176 # markers) before the raise unwinds the caller's transaction, then
177 # re-raise so the relay fails closed. The audit was already emitted by
178 # the re-check engine.
179 if conn is not None: 179 ↛ 181line 179 didn't jump to line 181 because the condition on line 179 was always true
180 conn.commit()
181 raise
182 # Honor: the binding re-validated current within the cadence/grace.
183 keys = _keys_from_manifest(candidate)
184 return keys
186 if decision.outcome is TrustDecision.Outcome.PENDING_CONFIRM:
187 # The ladder quarantined the binding on ``conn`` (the operator-confirm
188 # queue, I9). Commit BEFORE raising so the queue row survives — otherwise
189 # the caller's ``with db()`` block rolls it back when this raise unwinds.
190 if conn is not None: 190 ↛ 192line 190 didn't jump to line 192 because the condition on line 190 was always true
191 conn.commit()
192 raise OriginIdentityError(
193 f"relayed origin {node_id!r} ({entity_uri!r}) pending operator confirmation "
194 f"({decision.reason})"
195 )
197 # REJECTED (revoked / rollback / bogus / unvalidatable / queue full). The ladder
198 # may have stamped epoch/sticky markers on ``conn``; commit them before raising.
199 if conn is not None: 199 ↛ 201line 199 didn't jump to line 201 because the condition on line 199 was always true
200 conn.commit()
201 raise OriginIdentityError(
202 f"relayed origin {node_id!r} ({entity_uri!r}) rejected by dnssec first-trust "
203 f"({decision.reason})"
204 )
207def _prior_key_within_grace(rotated_at: str) -> bool:
208 """True iff a rotation at *rotated_at* is still inside the configured grace window.
210 Fail-closed: a missing/unparseable/future ``rotated_at`` (or any age beyond the
211 grace) returns False, so the prior key is DROPPED rather than trusted indefinitely.
212 """
213 grace = timedelta(hours=settings.federation_key_rotation_grace_hours)
214 try:
215 rotated = datetime.fromisoformat((rotated_at or "").replace("Z", "+00:00"))
216 except (ValueError, TypeError):
217 return False # indeterminate age ⇒ fail closed on the prior key
218 if rotated.tzinfo is None: 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true
219 rotated = rotated.replace(tzinfo=UTC)
220 return _now() - rotated <= grace
223def _keys_from_manifest(manifest: OrgManifest) -> set[str]:
224 """Current key plus the prior key — but ONLY while inside the rotation grace window.
226 The current ``public_key`` is ALWAYS accepted. The prior (retiring) key from the
227 most recent rotation event is accepted as a dual-trust key ONLY while
228 ``now - rotated_at <= federation_key_rotation_grace_hours``; once that window
229 elapses the retired key is dropped, so a stale/compromised prior key can no longer
230 forge origin signatures (direct or relayed). Fail-closed on an unparseable
231 ``rotated_at`` (see ``_prior_key_within_grace``).
232 """
233 keys = {manifest.public_key}
234 if manifest.rotation_events:
235 last = manifest.rotation_events[-1]
236 if last.previous_public_key and _prior_key_within_grace(last.rotated_at):
237 keys.add(last.previous_public_key)
238 return keys
241def resolve_origin_key(node_id: str) -> set[str]:
242 """Return the base64url pubkeys *node_id*'s origin may sign with.
244 Includes the manifest's current key plus the prior key inside the most
245 recent rotation window (dual-trust). Raises OriginIdentityError on any
246 missing or invalid link in the chain.
247 """
248 with db() as conn:
249 row = conn.execute(
250 "SELECT entity_uri FROM peers WHERE node_id = ? AND status = 'active'",
251 (node_id,),
252 ).fetchone()
253 if row is None or not (row["entity_uri"] or "").strip():
254 raise OriginIdentityError(f"no verified entity_uri bound to node_id {node_id!r}")
256 manifest = get_peer_manifest(row["entity_uri"], trust_mode=settings.trust_mode)
257 if manifest is None: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true
258 raise OriginIdentityError(f"no stored manifest for entity_uri {row['entity_uri']!r}")
259 try:
260 verify_manifest(manifest, trust_mode=settings.trust_mode)
261 except ManifestError as exc:
262 raise OriginIdentityError(f"manifest verification failed: {exc}") from exc
264 return _keys_from_manifest(manifest)
267def _existing_entity_uri_for_node(node_id: str) -> str | None:
268 """Return the entity_uri already locally bound to *node_id*, or None.
270 A ``node_id ↔ entity_uri`` binding is UNIQUE. We read both sources of truth:
272 * ``peers.entity_uri`` — the binding established at peer approval (2a), and
273 * ``federation_manifests`` — any stored manifest that LISTS *node_id* in its
274 ``entities`` (a relay first-contact binding stores the origin's manifest, so a
275 later relay claiming the same node_id under a different entity_uri must be caught).
277 The peers binding wins when present (it is the operator-approved authority). If the
278 peers table has no binding, a stored manifest that vouches for *node_id* fixes the
279 entity_uri. Returns None when *node_id* is genuinely unseen (first-contact TOFU).
280 """
281 with db() as conn:
282 peer = conn.execute(
283 "SELECT entity_uri FROM peers WHERE node_id = ? AND status = 'active' "
284 "AND entity_uri IS NOT NULL AND entity_uri != ''",
285 (node_id,),
286 ).fetchone()
287 if peer is not None and (peer["entity_uri"] or "").strip(): 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true
288 return str(peer["entity_uri"])
290 # No approved peer binding — scan stored manifests for one vouching for node_id.
291 rows = conn.execute(
292 "SELECT entity_uri, manifest_json FROM federation_manifests"
293 ).fetchall()
294 import json as _json
296 for row in rows:
297 m = None
298 with contextlib.suppress(Exception): # a malformed stored manifest cannot vouch
299 m = manifest_from_dict(_json.loads(row["manifest_json"]))
300 if m is not None and node_id in m.entities:
301 return str(row["entity_uri"])
302 return None
305def _fetch_relay_manifest(entity_uri: str) -> OrgManifest | None:
306 """Fetch + self-verify the origin's manifest from *entity_uri*, HTTPS-ONLY + pinned.
308 This resolves an attacker-CHOSEN entity_uri carried on the wire, so it is the
309 sharpest SSRF surface: the host could point at an internal/IMDS address or a
310 plaintext endpoint, and could DNS-rebind between validation and connect. We close
311 that with the R-5 / F-SSRF1 anti-rebind pin (``resolve_pinned_address``, https-only
312 — rejecting the whole URL on any private record or non-https scheme) resolved BEFORE
313 the client is opened, connecting to the EXACT pinned IP while preserving the ``Host``
314 header + TLS SNI, ``follow_redirects=False``. This matches the now-pinned trust_store
315 sibling ``_try_fetch_manifest`` (which is also https-only after R-5's F-SSRF2 change).
316 The dev bypass is ``federation_insecure`` alone, matching the recurring-pull path.
317 """
318 if not (entity_uri.startswith("https://") or entity_uri.startswith("http://")): 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true
319 return None # cannot derive a fetch URL from a non-HTTP entity_uri
320 parsed = urlparse(entity_uri)
321 base_url = f"{parsed.scheme}://{parsed.netloc}"
323 try:
324 resp = _pinned_relay_manifest_get(
325 f"{base_url}/.well-known/stigmem-manifest.json",
326 timeout=10.0,
327 skip_pin=settings.federation_insecure,
328 )
329 if resp.status_code != 200:
330 return None
331 manifest = manifest_from_dict(resp.json())
332 verify_manifest(manifest, trust_mode=settings.trust_mode)
333 return manifest
334 except Exception as exc:
335 logger.debug("relay manifest fetch failed for %s: %s", entity_uri, exc)
336 return None
339def _pinned_relay_manifest_get(
340 url: str,
341 *,
342 timeout: float,
343 skip_pin: bool,
344) -> httpx.Response:
345 """GET *url* with the a11 anti-rebind DNS pin (R-5 / F-SSRF1), unless *skip_pin*.
347 Synchronous sibling of ``federation_pull._pinned_get`` /
348 ``trust_store._pinned_manifest_get``. Resolves the host ONCE via
349 ``resolve_pinned_address`` (https-only) BEFORE opening the client, connecting to the
350 EXACT pinned IP literal with ``Host`` header + TLS SNI + cert verification preserved
351 against the original hostname and ``follow_redirects=False``. A blocked/rebind/non-
352 https target fails closed (``ValueError``) with no request issued. Under *skip_pin*
353 (``federation_insecure``) the original-hostname URL is passed through unpinned.
354 """
355 if skip_pin:
356 return httpx.get(url, timeout=timeout, follow_redirects=False)
358 pinned_ip = resolve_pinned_address(url, allow_schemes=frozenset({"https"}))
359 parts = urlsplit(url)
360 hostname = parts.hostname or ""
361 port = parts.port
362 ip_authority = f"[{pinned_ip}]" if ":" in pinned_ip else pinned_ip
363 if port is not None: 363 ↛ 364line 363 didn't jump to line 364 because the condition on line 363 was never true
364 netloc = f"{ip_authority}:{port}"
365 host_header = f"{hostname}:{port}"
366 else:
367 netloc = ip_authority
368 host_header = hostname
369 pinned_url = urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
370 # extensions={"sni_hostname": ...} runs TLS SNI + cert verification against the
371 # original hostname while the socket connects to the pinned IP literal (the webhook
372 # pin shape). httpx.get forwards it to the transient Client; the type stub omits the
373 # kwarg, so the runtime-valid call needs an ignore.
374 return httpx.get( # type: ignore[call-arg]
375 pinned_url,
376 timeout=timeout,
377 follow_redirects=False,
378 headers={"Host": host_header},
379 extensions={"sni_hostname": hostname},
380 )
383def _audit_relay(event_type: str, *, node_id: str, entity_uri: str, **detail: object) -> None:
384 """Best-effort relay-origin audit emit (never blocks resolution)."""
385 from ..observability.audit_event import emit_nofail
387 emit_nofail(
388 event_type,
389 entity_uri=entity_uri,
390 source="federation_relay",
391 detail={"node_id": node_id, "entity_uri": entity_uri, **detail},
392 )
395def _candidate_manifest_from_carried(
396 origin_manifest: dict[str, object] | None,
397) -> OrgManifest | None:
398 """Parse + self-verify a CARRIED origin manifest body, or return None if unusable.
400 The carried manifest is OPTIONAL and is only a manifest BODY — parsing + self-sig
401 verification here is the same W3.2 self-verify gate the fetch path applies; it does
402 NOT confer trust (that requires a first-party anchor match in the tier logic).
403 """
404 if not isinstance(origin_manifest, dict):
405 return None
406 try:
407 m = manifest_from_dict(origin_manifest)
408 verify_manifest(m, trust_mode=settings.trust_mode)
409 return m
410 except Exception as exc: # noqa: BLE001 — a malformed/invalid body is simply unusable
411 logger.debug("carried relay origin manifest unusable: %s", exc)
412 return None
415def resolve_origin_key_for_relay(
416 node_id: str,
417 entity_uri: str,
418 *,
419 cache: dict[tuple[str, str], set[str]],
420 origin_manifest: dict[str, object] | None = None,
421 relay_peer: str | None = None,
422) -> set[str]:
423 """Resolve the signing key set for a RELAYED origin (offline-safe, zero transitive trust).
425 Precedence (fail-closed at every step):
427 1. **Peer path** — if the origin is an active bound peer, ``resolve_origin_key`` resolves
428 it with NO fetch (a first-party verified 2a binding, the highest tier).
429 2. **Candidate manifest** — obtain a candidate from: a fetch-on-first (HTTPS-only,
430 ``fetched`` — may be None if unreachable), the carried ``origin_manifest`` body, or a
431 stored manifest for ``entity_uri``. The candidate MUST pass the W3.2 checks (self-sig,
432 ``node_id ∈ entities``, entity-authority/uniqueness) before ANY acceptance.
433 3. **Anchor + cross-check** (the offline core, by descending anchor strength):
435 * **Tier 1 — operator pin** (W4.1 ``get_origin_pin``): the candidate fingerprint MUST
436 equal the pin, ELSE reject (``relay_origin_pin_mismatch``). If the origin is also
437 REACHABLE (``fetched`` not None) the fetched key must ALSO equal the pin, ELSE reject
438 (``relay_origin_fetch_disagrees_pin`` — a reachable fetch that disagrees with the human
439 anchor is a MITM/compromise signal). On match → accept.
440 * **Tier 2 — stored binding** (``get_peer_manifest``): the candidate fingerprint MUST
441 equal the stored manifest's key, ELSE reject (``relay_origin_key_changed`` — never a
442 silent key update). On match → accept.
443 * **Tier 3 — fetch-on-first TOFU**: reachable, never-seen, unpinned — the EXISTING W3.2
444 behaviour: store the manifest + emit ``relay_origin_first_contact`` + accept.
445 * **Fail-closed**: no pin, no stored binding, not reachable → raise
446 (``relay_origin_unanchored``). The unknown-AND-unreachable case is correctly refused.
448 *cache* is a per-request dict threaded through the page loop, keyed by the
449 ``(entity_uri, node_id)`` PAIR → verified key set, so the fetch + rotation check happen
450 ONCE per (origin, node) rather than once per fact. The key MUST include ``node_id``:
451 every check after the cache short-circuit (entity-authority/uniqueness, ``node_id ∈
452 entities``, the operator-pin lookup) is node_id-scoped, so an ``entity_uri``-only key
453 would let a SECOND node_id carried with the same ``entity_uri`` inherit the first one's
454 key set and bypass those checks (entity-authority + per-node pin bypass). It MUST be a
455 local threaded through calls — a module-level global would persist a stale binding
456 across requests and defeat rotation/revocation.
458 *relay_peer* is the node_id of the IMMEDIATE relaying peer (the authenticated
459 sender), threaded into the DNSSEC first-trust ladder so the per-peer
460 operator-confirm quarantine cap (``quarantine._peer_pending_count``) is keyed
461 per relaying peer rather than collapsing every relay into a shared
462 ``relay_peer IS NULL`` bucket. ``None`` keeps the legacy shared bucket (e.g. a
463 self/non-relay caller).
465 Returns ``{current_key} ∪ rotation-window keys`` (same shape as
466 ``resolve_origin_key``). Raises OriginIdentityError on any failure.
467 """
468 # 1. Peer path: an already-bound active peer resolves without any fetch.
469 try:
470 return resolve_origin_key(node_id)
471 except OriginIdentityError:
472 # Not an already-bound peer — fall through to the relay-resolution path below.
473 pass
475 if not (entity_uri or "").strip(): 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true
476 raise OriginIdentityError(f"relayed origin {node_id!r} carries no entity_uri")
478 # Cache hit: this (entity_uri, node_id) pair was already anchored + verified earlier
479 # this page. Keyed on the PAIR — see the node_id-scoped-checks note in the docstring.
480 cached = cache.get((entity_uri, node_id))
481 if cached is not None:
482 return cached
484 # Entity-authority uniqueness: enforce BEFORE anything else so a hostile manifest
485 # cannot be considered under a node_id owned by a different entity.
486 existing = _existing_entity_uri_for_node(node_id)
487 if existing is not None and existing != entity_uri:
488 raise OriginIdentityError(
489 f"node_id {node_id!r} already bound to entity_uri {existing!r}; "
490 f"a relayed manifest from {entity_uri!r} may not re-claim it"
491 )
493 # 2. Obtain a CANDIDATE manifest. Try a fetch-on-first (https-only; None if unreachable);
494 # the fetched manifest, if any, doubles as the strongest candidate AND the reachable
495 # cross-check key for tier 1. Else fall back to the carried body, else a stored manifest.
496 fetched = _fetch_relay_manifest(entity_uri)
497 candidate = (
498 fetched
499 or _candidate_manifest_from_carried(origin_manifest)
500 or get_peer_manifest(entity_uri, refresh_if_expired=False, trust_mode=settings.trust_mode)
501 )
502 if candidate is None:
503 # No candidate from any source AND no stored binding ⇒ unknown + unreachable.
504 # Phase-3 DNSSEC first-trust (flag-gated, strictly additive at this terminal,
505 # Rev 6 I8 / plan TB-2). With NO candidate key bytes the DNSSEC tier can
506 # neither anchor (it yields a fingerprint, never key bytes) nor route-to-
507 # confirm (no candidate fpr to quarantine), so it short-circuits to None and
508 # this terminal stays fail-closed — but it is now flag-AWARE (consulted here,
509 # never silently bypassed). When the flag is OFF this is a no-op.
510 dnssec_keys = _dnssec_first_trust_keys(
511 None,
512 node_id=node_id,
513 entity_uri=entity_uri,
514 candidate=None,
515 candidate_fp=None,
516 relay_peer=relay_peer,
517 )
518 if dnssec_keys is not None: # 3c only; in 3b this branch is unreachable. 518 ↛ 519line 518 didn't jump to line 519 because the condition on line 518 was never true
519 cache[(entity_uri, node_id)] = dnssec_keys
520 return dnssec_keys
521 _audit_relay("relay_origin_unanchored", node_id=node_id, entity_uri=entity_uri)
522 raise OriginIdentityError(
523 f"relayed origin {node_id!r} ({entity_uri!r}) is unanchored and unreachable"
524 )
526 # 3. W3.2 self-verify gate: the candidate must vouch for node_id. (verify_manifest already
527 # ran on the fetch/carried/stored paths; node_id ∈ entities is the remaining W3.2 check.)
528 if node_id not in candidate.entities:
529 raise OriginIdentityError(
530 f"relay origin manifest {entity_uri!r} does not list node_id {node_id!r}"
531 )
533 candidate_fp = fingerprint_from_pubkey(candidate.public_key)
535 # 4. ANCHOR + CROSS-CHECK, by descending anchor strength.
536 with db() as conn:
537 pin = get_origin_pin(conn, entity_uri=entity_uri, node_id=node_id)
539 if pin is not None:
540 # Tier 1 — operator pin (human anchor). Two checks, both required:
541 # Cross-check FIRST: a REACHABLE fetch that disagrees with the pin is a MITM /
542 # compromise signal (the live endpoint serves a key the operator never confirmed).
543 # This is the strongest attack signal, so it is reported ahead of a stale candidate.
544 if fetched is not None and fingerprint_from_pubkey(fetched.public_key) != pin[
545 "key_fingerprint"
546 ]:
547 _audit_relay(
548 "relay_origin_fetch_disagrees_pin", node_id=node_id, entity_uri=entity_uri
549 )
550 raise OriginIdentityError(
551 f"relayed origin {node_id!r} reachable fetch disagrees with the operator pin"
552 )
553 # The candidate (fetched / carried / stored) MUST itself match the pin.
554 if candidate_fp != pin["key_fingerprint"]:
555 _audit_relay("relay_origin_pin_mismatch", node_id=node_id, entity_uri=entity_uri)
556 raise OriginIdentityError(
557 f"relayed origin {node_id!r} candidate key does not match the operator pin"
558 )
559 keys = _keys_from_manifest(candidate)
560 cache[(entity_uri, node_id)] = keys
561 return keys
563 stored = get_peer_manifest(
564 entity_uri, refresh_if_expired=False, trust_mode=settings.trust_mode
565 )
566 if stored is not None:
567 # Tier 2 — stored first-party binding. The candidate MUST match the stored key.
568 if candidate_fp != fingerprint_from_pubkey(stored.public_key):
569 _audit_relay("relay_origin_key_changed", node_id=node_id, entity_uri=entity_uri)
570 raise OriginIdentityError(
571 f"relayed origin {node_id!r} candidate key differs from the stored binding"
572 )
573 keys = _keys_from_manifest(candidate)
574 cache[(entity_uri, node_id)] = keys
575 return keys
577 if fetched is not None:
578 # Tier 3 — fetch-on-first TOFU (reachable, never-seen, unpinned): the EXISTING W3.2
579 # first-contact behaviour. Persist the manifest + emit the first-contact audit.
580 try:
581 store_peer_manifest(entity_uri, fetched, trust_mode=settings.trust_mode)
582 except ManifestError as exc:
583 logger.debug("relay first-contact manifest store rejected for %s: %s", entity_uri, exc)
584 _audit_relay("relay_origin_first_contact", node_id=node_id, entity_uri=entity_uri)
585 keys = _keys_from_manifest(fetched)
586 cache[(entity_uri, node_id)] = keys
587 return keys
589 # Phase-3 DNSSEC first-trust (flag-gated, strictly additive at this terminal,
590 # Rev 6 I8 / plan TB-2). A candidate self-verified + lists node_id (the W3.2
591 # gate above passed) but there is no pin, no stored binding, and the origin is
592 # unreachable. The DNSSEC tier runs the first-trust ladder against the
593 # candidate's fingerprint. It writes (pins / epoch / quarantine), so it owns a
594 # live transaction here. When the flag is OFF this is a no-op and the unchanged
595 # ``relay_origin_unanchored`` raise below fires (byte-identical to today).
596 with db() as conn:
597 dnssec_keys = _dnssec_first_trust_keys(
598 conn,
599 node_id=node_id,
600 entity_uri=entity_uri,
601 candidate=candidate,
602 candidate_fp=candidate_fp,
603 relay_peer=relay_peer,
604 )
605 if dnssec_keys is not None: # 3c only; in 3b TRUSTED fails closed at recheck.
606 conn.commit()
607 cache[(entity_uri, node_id)] = dnssec_keys
608 return dnssec_keys
609 # The ladder may have pinned/quarantined as a side effect even on a verdict
610 # that does not yield a key here (e.g. PENDING_CONFIRM raises before this
611 # point). On the flag-OFF no-op path there is nothing to commit; commit is
612 # harmless and persists any quarantine row written before a raise is caught
613 # upstream. (Reached only when _dnssec_first_trust_keys returned None.)
614 conn.commit()
616 # Fail-closed: a candidate existed (carried/stored) but there is no pin, no stored binding,
617 # and the origin is unreachable ⇒ no first-party anchor to accept it against.
618 _audit_relay("relay_origin_unanchored", node_id=node_id, entity_uri=entity_uri)
619 raise OriginIdentityError(
620 f"relayed origin {node_id!r} ({entity_uri!r}) is unanchored and unreachable"
621 )