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

1"""Phase 2a — resolve an origin node_id to the verified pubkey(s) it may sign with. 

2 

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. 

6 

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

13 

14from __future__ import annotations 

15 

16import contextlib 

17import logging 

18from datetime import UTC, datetime, timedelta 

19from typing import TYPE_CHECKING, Any 

20from urllib.parse import urlparse, urlsplit, urlunsplit 

21 

22import httpx 

23 

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 

35 

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 

40 

41logger = logging.getLogger("stigmem.federation.origin_identity") 

42 

43 

44class OriginIdentityError(ValueError): 

45 """Origin identity could not be verified (fail-closed).""" 

46 

47 

48def _now() -> datetime: 

49 """Current UTC time. Indirected through a module function so tests own the clock.""" 

50 return datetime.now(UTC) 

51 

52 

53def _make_dnssec_resolver() -> Resolver: 

54 """Construct the DNSSEC validating resolver for the first-trust tier (Rev 6 I11). 

55 

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 

63 

64 return LiveResolver() 

65 

66 

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

77 

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. 

84 

85 When ON, the disposition depends on whether a candidate key exists: 

86 

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

95 

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

111 

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 

117 

118 from .dnssec.ladder import TrustDecision, resolve_first_trust 

119 from .dnssec.recheck import recheck_relay_binding 

120 

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 

126 

127 from .dnssec.host import host_from_entity_uri 

128 

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 ) 

136 

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 ) 

148 

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 

185 

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 ) 

196 

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 ) 

205 

206 

207def _prior_key_within_grace(rotated_at: str) -> bool: 

208 """True iff a rotation at *rotated_at* is still inside the configured grace window. 

209 

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 

221 

222 

223def _keys_from_manifest(manifest: OrgManifest) -> set[str]: 

224 """Current key plus the prior key — but ONLY while inside the rotation grace window. 

225 

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 

239 

240 

241def resolve_origin_key(node_id: str) -> set[str]: 

242 """Return the base64url pubkeys *node_id*'s origin may sign with. 

243 

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

255 

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 

263 

264 return _keys_from_manifest(manifest) 

265 

266 

267def _existing_entity_uri_for_node(node_id: str) -> str | None: 

268 """Return the entity_uri already locally bound to *node_id*, or None. 

269 

270 A ``node_id ↔ entity_uri`` binding is UNIQUE. We read both sources of truth: 

271 

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

276 

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

289 

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 

295 

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 

303 

304 

305def _fetch_relay_manifest(entity_uri: str) -> OrgManifest | None: 

306 """Fetch + self-verify the origin's manifest from *entity_uri*, HTTPS-ONLY + pinned. 

307 

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

322 

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 

337 

338 

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

346 

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) 

357 

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 ) 

381 

382 

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 

386 

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 ) 

393 

394 

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. 

399 

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 

413 

414 

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

424 

425 Precedence (fail-closed at every step): 

426 

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

434 

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. 

447 

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. 

457 

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

464 

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 

474 

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

477 

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 

483 

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 ) 

492 

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 ) 

525 

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 ) 

532 

533 candidate_fp = fingerprint_from_pubkey(candidate.public_key) 

534 

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) 

538 

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 

562 

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 

576 

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 

588 

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

615 

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 )