Coverage for node / src / stigmem_node / routes / _federation_impl.py: 80%
330 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"""Federation route implementations extracted from routes/federation.py.
3These functions are the original route handler bodies; they are imported back
4into ``routes.federation`` and invoked from thin ``@router``-decorated wrappers.
5No behavioural changes — code was moved verbatim from federation.py.
7Tests monkey-patch attributes on the ``routes.federation`` module
8(``settings``, ``write_audit_log``). To honour those patches, this module
9looks those names up via ``routes.federation`` lazily inside the function
10bodies rather than binding them at import time.
11"""
13from __future__ import annotations
15import contextlib
16import hashlib
17import json
18import logging
19import uuid
20from datetime import UTC, datetime
21from typing import Any
22from urllib.parse import urlsplit, urlunsplit
24import httpx
25from fastapi import BackgroundTasks, HTTPException, Request, status
27from ..auth import Identity
28from ..db import db
29from ..federation.peer_token import verify_declaration_sig
30from ..federation.tls import check_peer_san
31from ..identity.capability import CapabilityTokenError, verify_token
32from ..identity.manifest import ManifestError, manifest_from_dict, verify_manifest
33from ..identity.transparency_log import LogEntry, TransparencyLogUnavailable, make_transparency_log
34from ..identity.trust_store import get_peer_manifest, store_peer_manifest
35from ..models.federation import (
36 PeerApprovalResponse,
37 PeerRegisterRequest,
38 PeerRegisterResponse,
39)
40from ..models.tombstones import (
41 TombstoneRecord,
42 TombstoneRevocationRecord,
43)
44from ..net_util import node_url_is_loopback, resolve_pinned_address
45from ..plugins import Deny, TenantContext, get_registry
47logger = logging.getLogger("stigmem.federation")
50def peer_pubkey_fingerprint(pubkey: str) -> str:
51 """Return the operator-verifiable fingerprint for a pinned peer public key."""
52 return f"sha256:{hashlib.sha256(pubkey.encode()).hexdigest()}"
55def _build_pinned_request(url: str, pinned_ip: str) -> tuple[str, str]:
56 """Return ``(pinned_url, host_header)`` for connecting to *pinned_ip*.
58 Mirrors ``federation_pull._build_pinned_request`` (the a11 webhook pin shape):
59 swap the original hostname for the validated *pinned_ip* literal (IPv6 bracketed)
60 while preserving scheme/port/path/query, so the socket connects to the pinned IP
61 and cannot be re-resolved by a rebinder. ``host_header`` carries the ORIGINAL
62 hostname (+ explicit port). The caller passes ``extensions={"sni_hostname": host}``
63 so TLS SNI + cert verification run against the original hostname, NOT the IP.
64 """
65 parts = urlsplit(url)
66 hostname = parts.hostname or ""
67 port = parts.port
68 ip_authority = f"[{pinned_ip}]" if ":" in pinned_ip else pinned_ip
69 if port is not None:
70 netloc = f"{ip_authority}:{port}"
71 host_header = f"{hostname}:{port}"
72 else:
73 netloc = ip_authority
74 host_header = hostname
75 pinned_url = urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
76 return pinned_url, host_header
79async def _pinned_well_known_get(
80 client: httpx.AsyncClient,
81 url: str,
82 *,
83 allow_schemes: frozenset[str],
84 skip_pin: bool,
85 **kwargs: Any,
86) -> httpx.Response:
87 """GET *url* with the a11 anti-rebind DNS pin (F-SSRF-3), unless *skip_pin*.
89 The one-shot registration/approval well-known fetches were SSRF-guarded with
90 ``assert_safe_url`` but NOT DNS-pinned, leaving the rebind TOCTOU window the
91 recurring pull fetches already close via ``federation_pull._pinned_get``. We
92 resolve the host ONCE (``resolve_pinned_address`` — rejects the whole URL if
93 ANY resolved record is private/loopback/IMDS), connect to that EXACT pinned IP
94 literal, and preserve the ``Host`` header + TLS SNI + cert verification against
95 the original hostname.
97 ``skip_pin`` is supplied by the caller so each site keeps its EXISTING dev
98 bypass exactly: registration skips under ``federation_insecure`` alone (NF-2);
99 the approval-time manifest fetch skips under ``federation_insecure AND loopback``.
100 Under the skip the original-hostname URL is passed straight through with no pin
101 extensions (preserving today's call shape, including the test fake clients).
103 NOTE: the pin is resolved (and a blocked target raises) BEFORE this is called —
104 see ``_resolve_well_known_pin`` — so callers fail closed without ever opening the
105 HTTP client for a private/IMDS URL. This wrapper recomputes the same pin for the
106 actual GET.
107 """
108 if skip_pin:
109 return await client.get(url, **kwargs)
111 pinned_ip = resolve_pinned_address(url, allow_schemes=allow_schemes)
112 pinned_url, host_header = _build_pinned_request(url, pinned_ip)
113 hostname = urlsplit(url).hostname or ""
114 headers = dict(kwargs.pop("headers", None) or {})
115 headers["Host"] = host_header
116 return await client.get(
117 pinned_url,
118 headers=headers,
119 extensions={"sni_hostname": hostname},
120 **kwargs,
121 )
124def _resolve_well_known_pin(
125 url: str,
126 *,
127 allow_schemes: frozenset[str],
128 skip_pin: bool,
129) -> None:
130 """Resolve+validate the F-SSRF-3 pin for *url* BEFORE the HTTP client is opened.
132 Raises ``ValueError`` (via ``resolve_pinned_address``) if the target resolves to a
133 private/loopback/IMDS address, so a blocked target fails closed without ever
134 constructing the client. A no-op when *skip_pin* is set (the dev bypass).
135 """
136 if skip_pin:
137 return
138 resolve_pinned_address(url, allow_schemes=allow_schemes)
141def _make_federation_client() -> httpx.AsyncClient:
142 from . import federation as _fed_mod
144 if _fed_mod.settings.mtls_enabled: 144 ↛ 145line 144 didn't jump to line 145 because the condition on line 144 was never true
145 from ..federation.tls import build_client_ssl_context
147 ssl_ctx = build_client_ssl_context(
148 _fed_mod.settings.tls_cert_path,
149 _fed_mod.settings.tls_key_path,
150 _fed_mod.settings.tls_ca_bundle,
151 )
152 return httpx.AsyncClient(verify=ssl_ctx, trust_env=False)
153 return httpx.AsyncClient(trust_env=False)
156async def register_peer_impl(
157 req: PeerRegisterRequest,
158 background_tasks: BackgroundTasks,
159 identity: Identity,
160) -> PeerRegisterResponse:
161 """Register a peer. Fetches its well-known doc and verifies declaration_sig (§5.6)."""
162 if not identity.can_federate(): 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true
163 raise HTTPException(status_code=403, detail="federate permission required")
164 decision = get_registry().fire_voting(
165 "federation_peer_authenticate",
166 req=req,
167 identity=identity,
168 tenant=TenantContext(
169 tenant_id=identity.tenant_id,
170 metadata={"tenant_context_source": "hook"},
171 ),
172 )
173 if isinstance(decision, Deny): 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true
174 raise HTTPException(status_code=403, detail=decision.reason)
176 peer_id = str(uuid.uuid4())
177 allowed_scopes_json = json.dumps(sorted(req.allowed_scopes))
179 with db() as conn:
180 existing = conn.execute(
181 "SELECT id, status FROM peers WHERE node_id = ?", (req.node_id,)
182 ).fetchone()
183 if existing: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true
184 raise HTTPException(
185 status_code=409,
186 detail=f"peer already registered (status={existing['status']})",
187 )
188 conn.execute(
189 """INSERT INTO peers
190 (id, node_id, node_url, federation_pubkey, allowed_scopes,
191 status, established_at, declaration_sig, signed_at)
192 VALUES (?,?,?,?,?,?,?,?,?)""",
193 (
194 peer_id,
195 req.node_id,
196 req.node_url,
197 req.federation_pubkey,
198 allowed_scopes_json,
199 "pending_verification",
200 None,
201 req.declaration_sig,
202 req.signed_at,
203 ),
204 )
206 # Fetch peer's /.well-known/stigmem to retrieve their published pubkey (§5.6 step 1–3)
207 # SSRF guard (NF-2): assert_safe_url runs before the GET so the connection is never
208 # opened for private/internal addresses. Skipped only when federation_insecure=True
209 # (dev/test mode where the operator has explicitly opted out of URL safety checks).
210 # In production (federation_insecure=False) only https:// peer URLs are accepted.
211 from . import federation as _fed_mod
213 fetched_pubkey: str | None = None
214 try:
215 # F-SSRF-3: pin the host ONCE (resolve + reject private/rebind targets, then
216 # connect to the pinned IP). Skipped under federation_insecure ALONE — the
217 # existing NF-2 dev bypass for this registration fetch (https-only otherwise).
218 # The pin is resolved BEFORE the client opens so a blocked target fails closed.
219 skip_pin = _fed_mod.settings.federation_insecure
220 wk_url = f"{req.node_url}/.well-known/stigmem"
221 _resolve_well_known_pin(
222 wk_url, allow_schemes=frozenset({"https"}), skip_pin=skip_pin
223 )
224 async with _make_federation_client() as client:
225 wk_resp = await _pinned_well_known_get(
226 client,
227 wk_url,
228 allow_schemes=frozenset({"https"}),
229 skip_pin=skip_pin,
230 )
231 if wk_resp.status_code == 200: 231 ↛ 236line 231 didn't jump to line 236 because the condition on line 231 was always true
232 fetched_pubkey = wk_resp.json().get("federation_pubkey")
233 except Exception as exc: # nosec B110 — fetched_pubkey stays None → rejected below
234 logger.debug("peer .well-known fetch failed: %s", exc)
236 final_status = "rejected"
237 verified_at: str | None = None
239 if fetched_pubkey and fetched_pubkey == req.federation_pubkey:
240 # Signed fields = everything except declaration_sig (spec §6.1 struct "above fields")
241 signed_fields: dict[str, Any] = {
242 "allowed_scopes": req.allowed_scopes,
243 "federation_pubkey": req.federation_pubkey,
244 "node_id": req.node_id,
245 "node_url": req.node_url,
246 "signed_at": req.signed_at,
247 }
248 if verify_declaration_sig(signed_fields, req.declaration_sig, fetched_pubkey):
249 final_status = "pending_approval"
251 # Phase 2a — entity_uri is NOT bound here. A fresh peer's manifest is not stored at
252 # registration time; the only flow that fetches+stores it is _check_tl_inclusion_for_peer,
253 # which runs at approval. The binding lives there (where the manifest exists and where the
254 # peer is 'active', matching resolve_origin_key's status filter). See Task 8.
255 with db() as conn:
256 conn.execute(
257 "UPDATE peers SET status = ?, established_at = ? WHERE id = ?",
258 (final_status, verified_at, peer_id),
259 )
261 return PeerRegisterResponse(peer_id=peer_id, status=final_status, verified_at=verified_at)
264def approve_peer_impl(
265 peer_id: str,
266 pubkey_fingerprint: str,
267 background_tasks: BackgroundTasks,
268 identity: Identity,
269) -> PeerApprovalResponse:
270 """Approve a verified peer after operator out-of-band key confirmation."""
271 if not identity.can_admin_federation(): 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 raise HTTPException(status_code=403, detail="admin:federation required")
274 now = datetime.now(UTC).isoformat()
275 fingerprint_mismatch_peer: dict[str, Any] | None = None
276 with db() as conn:
277 peer = conn.execute("SELECT * FROM peers WHERE id = ?", (peer_id,)).fetchone()
278 if peer is None: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 raise HTTPException(status_code=404, detail="peer not found")
280 if peer["status"] != "pending_approval": 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true
281 raise HTTPException(
282 status_code=409,
283 detail=f"peer is not pending approval (status={peer['status']})",
284 )
286 expected = peer_pubkey_fingerprint(peer["federation_pubkey"])
287 if pubkey_fingerprint != expected:
288 fingerprint_mismatch_peer = dict(peer)
289 else:
290 fingerprint_mismatch_peer = None
292 conn.execute(
293 "UPDATE peers SET status = 'active', established_at = ? WHERE id = ?",
294 (now, peer["id"]),
295 )
297 if fingerprint_mismatch_peer is not None:
298 from . import federation as _fed_mod
300 _fed_mod.write_audit_log(
301 fingerprint_mismatch_peer["id"],
302 "peer_approval_failed",
303 {"node_id": fingerprint_mismatch_peer["node_id"], "reason": "fingerprint_mismatch"},
304 )
305 raise HTTPException(status_code=400, detail="fingerprint mismatch")
307 from . import federation as _fed_mod
309 _fed_mod.write_audit_log(
310 peer_id,
311 "peer_approved",
312 {"node_id": peer["node_id"], "approved_by": identity.entity_uri},
313 )
314 background_tasks.add_task(
315 _check_tl_inclusion_for_peer,
316 peer["node_id"],
317 peer["node_url"],
318 peer_id,
319 )
320 return PeerApprovalResponse(
321 peer_id=peer_id,
322 node_id=peer["node_id"],
323 status="active",
324 approved_at=now,
325 )
328async def _check_tl_inclusion_for_peer(node_id: str, node_url: str, peer_id: str) -> None:
329 """Check TL inclusion proof for a newly registered peer (§19.2.3).
331 trust_mode=strict (enforce): no proof → downgrade peer to pending_tl_proof
332 trust_mode=relaxed (warn): no proof → accept + audit warning
333 trust_mode=off: skip entirely
334 """
335 # Lazy lookup: tests monkey-patch ``federation.write_audit_log`` —
336 # accessing via the module preserves those patches.
337 from typing import cast as _cast
339 from . import federation as _fed_mod
341 _fed = _cast(Any, _fed_mod)
343 trust_mode = _fed.settings.trust_mode
344 if trust_mode == "off": 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true
345 return
347 # Try to fetch the peer's manifest from their well-known endpoint
348 manifest_obj = None
349 try:
350 # The SSRF guard normally blocks loopback/private addresses. We skip
351 # assert_safe_url for this approval-time manifest fetch ONLY under the
352 # conjunction (federation_insecure dev mode AND a literal loopback host),
353 # so a loopback dev cluster can bind the peer's entity_uri (Phase 2a) —
354 # without the skip, assert_safe_url rejects the loopback URL, the binding
355 # never fires, resolve_origin_key fails, and no v2 fact federates between
356 # loopback nodes. In production (federation_insecure off) the guard is
357 # always enforced. Note: the registration-time well-known fetch
358 # (register_peer_impl, NF-2) is guarded by assert_safe_url under
359 # federation_insecure ALONE (https-only) — NOT flag+loopback-gated like
360 # this fetch, so do not treat the two as the same mechanism.
361 _loopback_dev = _fed.settings.federation_insecure and node_url_is_loopback(node_url)
362 # F-SSRF-3: pin the host ONCE rather than only assert_safe_url (which leaves
363 # the rebind TOCTOU window). Skipped under the SAME federation_insecure AND
364 # loopback conjunction this fetch already used for its dev bypass. The pin is
365 # resolved BEFORE the client opens so a private/IMDS target fails closed without
366 # ever opening an HTTP connection.
367 manifest_url = f"{node_url}/.well-known/stigmem-manifest.json"
368 _resolve_well_known_pin(
369 manifest_url, allow_schemes=frozenset({"https", "http"}), skip_pin=_loopback_dev
370 )
371 async with httpx.AsyncClient(timeout=10.0) as client:
372 resp = await _pinned_well_known_get(
373 client,
374 manifest_url,
375 allow_schemes=frozenset({"https", "http"}),
376 skip_pin=_loopback_dev,
377 follow_redirects=False,
378 )
379 if resp.status_code == 200: 379 ↛ 393line 379 didn't jump to line 393 because the condition on line 379 was always true
380 try:
381 manifest_obj = manifest_from_dict(resp.json())
382 verify_manifest(manifest_obj, trust_mode=trust_mode)
383 except ManifestError as exc:
384 logger.warning("peer manifest from %s failed verification: %s", node_url, exc)
385 manifest_obj = None
386 except ValueError as exc:
387 logger.warning("peer manifest from %s was not valid JSON: %s", node_url, exc)
388 manifest_obj = None
389 except Exception as exc:
390 logger.warning("failed to fetch peer manifest from %s: %s", node_url, exc)
391 manifest_obj = None
393 has_tl_proof = False
394 if manifest_obj is not None:
395 # Check whether the manifest has a TL entry recorded
396 existing = get_peer_manifest(manifest_obj.entity_uri, refresh_if_expired=False)
397 if existing is None: 397 ↛ 406line 397 didn't jump to line 406 because the condition on line 397 was always true
398 with contextlib.suppress(ManifestError):
399 store_peer_manifest(manifest_obj.entity_uri, manifest_obj, trust_mode=trust_mode)
401 # Phase 2a — bind the verified entity_uri now that the manifest is fetched + stored
402 # (same key must control node_id AND entity_uri). The peer's manifest must publish
403 # public_key == the peer's registered federation_pubkey AND list node_id in its
404 # entities. Fail-OPEN: any mismatch/exception leaves entity_uri NULL and approval
405 # still completes. The peer is already 'active' here, matching resolve_origin_key.
406 try:
407 from ..db import db as _bind_db
409 with _bind_db() as conn:
410 peer_row = conn.execute(
411 "SELECT federation_pubkey FROM peers WHERE id = ?", (peer_id,)
412 ).fetchone()
413 if (
414 peer_row is not None
415 and manifest_obj.public_key == peer_row["federation_pubkey"]
416 and node_id in manifest_obj.entities
417 ):
418 conn.execute(
419 "UPDATE peers SET entity_uri = ? WHERE id = ?",
420 (manifest_obj.entity_uri, peer_id),
421 )
422 except Exception as exc: # nosec B110 — binding failure → entity_uri stays NULL
423 logger.debug("peer entity_uri binding at approval failed: %s", exc)
425 # Try to verify TL inclusion
426 try:
427 tl = make_transparency_log()
428 from ..db import db as _db
430 with _db() as conn:
431 row = conn.execute(
432 "SELECT log_entry_json FROM federation_manifests WHERE entity_uri = ?",
433 (manifest_obj.entity_uri,),
434 ).fetchone()
435 if row and row["log_entry_json"]: 435 ↛ 436line 435 didn't jump to line 436 because the condition on line 435 was never true
436 import json as _json
438 le_data = _json.loads(row["log_entry_json"])
439 le = LogEntry(
440 log_id=le_data.get("log_id", ""),
441 leaf_hash=le_data.get("leaf_hash", ""),
442 log_index=le_data.get("log_index", -1),
443 integrated_time=le_data.get("integrated_time", 0),
444 inclusion_proof=le_data.get("inclusion_proof", {}),
445 )
446 tl.verify_inclusion(le)
447 has_tl_proof = True
448 except TransparencyLogUnavailable as exc:
449 logger.debug("transparency log unavailable for TL inclusion check: %s", exc)
450 except Exception as exc: # nosec B110 — TL inclusion check is best-effort
451 logger.debug("TL inclusion check failed: %s", exc)
453 if not has_tl_proof: 453 ↛ exitline 453 didn't return from function '_check_tl_inclusion_for_peer' because the condition on line 453 was always true
454 if trust_mode == "strict": 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true
455 _fed.write_audit_log(
456 peer_id,
457 "tl_proof_missing",
458 {"node_id": node_id, "action": "downgraded_to_pending_tl_proof"},
459 )
460 from ..db import db as _db
462 with _db() as conn:
463 conn.execute(
464 "UPDATE peers SET status = 'pending_tl_proof' WHERE id = ?",
465 (peer_id,),
466 )
467 else:
468 _fed.write_audit_log(
469 peer_id,
470 "tl_proof_missing",
471 {"node_id": node_id, "action": "accepted_with_warning", "trust_mode": trust_mode},
472 )
475def _authenticate_tombstone_caller(
476 request: Request,
477 authorization: str | None,
478 x_stigmem_capability: str | None,
479 try_peer_token_auth: Any,
480 get_mtls_peer_cert: Any,
481 fed_settings: Any,
482) -> dict[str, Any] | None:
483 """F-1 fix: caller must present a valid peer-JWT OR a tombstone-write capability token.
485 Raises HTTPException on any auth failure. On success returns the authenticated peer
486 row (when authed by peer-JWT) or None (capability-token caller). W6.8: the peer is
487 RETURNED rather than re-resolved by the v2 path — peer tokens carry a single-use nonce,
488 so calling ``try_peer_token_auth`` twice on the same token fails the second nonce check.
489 """
490 peer_auth = try_peer_token_auth(authorization)
491 if peer_auth is not None:
492 if fed_settings.mtls_enabled and request is not None: 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true
493 peer_cert = get_mtls_peer_cert(request)
494 if not check_peer_san(peer_cert, peer_auth[0]["node_id"]):
495 raise HTTPException(
496 status_code=401,
497 detail="peer certificate URI SAN does not match node_id",
498 )
499 peer_row: dict[str, Any] = peer_auth[0]
500 return peer_row
502 if x_stigmem_capability is None:
503 raise HTTPException(status_code=401, detail="peer token or capability token required")
505 try:
506 verify_token(
507 x_stigmem_capability,
508 lambda uri: get_peer_manifest(
509 uri, refresh_if_expired=True, trust_mode=fed_settings.trust_mode
510 ),
511 trust_mode=fed_settings.trust_mode,
512 )
513 except CapabilityTokenError as exc:
514 raise HTTPException(status_code=401, detail=f"capability token invalid: {exc}") from exc
515 try:
516 cap_token = json.loads(x_stigmem_capability)
517 except json.JSONDecodeError as exc:
518 raise HTTPException(
519 status_code=400, detail=f"malformed capability token JSON: {exc}"
520 ) from exc
521 if cap_token.get("verb", "") not in ("tombstone:write", "write"): 521 ↛ 523line 521 didn't jump to line 523 because the condition on line 521 was always true
522 raise HTTPException(status_code=403, detail="capability token missing tombstone:write verb")
523 return None
526def _verify_signed_artifact_or_400(
527 *,
528 record: Any,
529 key_id: str,
530 artifact_label: str, # "tombstone" or "revocation"
531 missing_manifest_detail: str, # exact wire-error string for the unknown-signer 401
532 signer_uri: str,
533 verifier: Any, # verify_tombstone_signature or verify_revocation_signature
534 on_failure: Any | None = None, # callable(record, reason) emitted on bad signature
535) -> None:
536 """Look up the signer manifest, resolve the signing key, and verify the signature.
538 Raises HTTPException on any verification failure (no-key-id / unknown-signer /
539 key-id-not-in-manifest / signature-mismatch). ``missing_manifest_detail`` is
540 parameterised because the existing wire contract uses different wording for
541 tombstones vs revocations.
543 W6.5: the manifest-lookup + key-id-resolve + verify core is the shared
544 ``resolve_and_verify_tombstone_issuer`` (also used by the pull client, closing the
545 W6.1 gap). This wrapper preserves the EXACT push-route HTTP status codes / wire-error
546 strings / audit events by mapping the helper's ``reason`` codes back to them.
547 """
548 from ..lifecycle.tombstone_signing import (
549 IssuerVerificationError,
550 resolve_and_verify_tombstone_issuer,
551 )
553 try:
554 resolve_and_verify_tombstone_issuer(
555 record, key_id=key_id, signer_uri=signer_uri, verifier=verifier
556 )
557 except IssuerVerificationError as exc:
558 reason = exc.reason
559 if reason == "missing_key_id":
560 _audit_tombstone_ingest_rejected(
561 record, artifact_label, f"{artifact_label}_missing_key_id"
562 )
563 raise HTTPException(
564 status_code=status.HTTP_400_BAD_REQUEST,
565 detail=f"{artifact_label} missing key_id",
566 ) from exc
567 if reason == "signer_manifest_missing":
568 _audit_tombstone_ingest_rejected(record, artifact_label, "signer_manifest_missing")
569 raise HTTPException(status_code=401, detail=missing_manifest_detail) from exc
570 if reason == "key_id_not_in_signer_manifest": 570 ↛ 571line 570 didn't jump to line 571 because the condition on line 570 was never true
571 _audit_tombstone_ingest_rejected(
572 record, artifact_label, "key_id_not_in_signer_manifest"
573 )
574 raise HTTPException(status_code=401, detail="key_id not in signer manifest") from exc
575 # Signature mismatch (reason is the verifier's ValueError string).
576 if on_failure is not None:
577 on_failure(record, reason)
578 _audit_tombstone_ingest_rejected(record, artifact_label, reason)
579 raise HTTPException(
580 status_code=status.HTTP_400_BAD_REQUEST,
581 detail=f"{artifact_label}_verification_failed: {reason}",
582 ) from exc
585def _ingest_revocation(payload: dict[str, Any], fed_settings: Any) -> dict[str, Any]:
586 """Parse + verify + apply an inbound revocation. Returns the success response dict."""
587 from ..lifecycle.tombstone_signing import verify_revocation_signature
588 from ..lifecycle.tombstones import RevocationAuthorityMismatch, apply_inbound_revocation
590 try:
591 rev = TombstoneRevocationRecord(**payload)
592 except Exception as exc:
593 _audit_tombstone_payload_rejected(payload, "revocation", str(exc))
594 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
596 _verify_signed_artifact_or_400(
597 record=rev,
598 key_id=rev.key_id or "",
599 artifact_label="revocation",
600 missing_manifest_detail="no manifest for revocation signer",
601 signer_uri=rev.signed_by,
602 verifier=verify_revocation_signature,
603 )
605 # Same-issuer binding (RTBF integrity): even the bare/back-compat push path must NOT let an
606 # authenticated peer revoke ANOTHER org's tombstone. apply_inbound_revocation is the shared
607 # chokepoint; a signer ≠ tombstone-issuer mismatch fails closed → 403.
608 try:
609 apply_inbound_revocation(rev)
610 except RevocationAuthorityMismatch as exc:
611 _audit_tombstone_payload_rejected(payload, "revocation", RevocationAuthorityMismatch.reason)
612 raise HTTPException(
613 status_code=status.HTTP_403_FORBIDDEN,
614 detail=f"revocation_rejected: {RevocationAuthorityMismatch.reason}",
615 ) from exc
616 return {"status": "ok", "type": "revocation"}
619def _ingest_tombstone(
620 payload: dict[str, Any], peer: dict[str, Any] | None, fed_settings: Any
621) -> dict[str, Any]:
622 """Parse + verify + apply a bare (pre-v2) inbound tombstone. Returns the success response.
624 A bare body carries no origin block, so it is treated as a DIRECT, issuer-verified
625 tombstone (received_from None, origin == self semantics). Its LOCAL tenant is the
626 posting peer's pinned ``ingest_tenant`` — resolved fail-closed by the SAME resolver the
627 v2 + pull DIRECT paths use (:func:`resolve_ingest_tenant_for_peer`). Landing every bare
628 tombstone in ``default`` would let a peer pinned to a non-default tenant RTBF-no-op on its
629 own tenant while over-suppressing ``default`` (F-SBOLA3 on a federation WRITE path).
630 """
631 from ..lifecycle.tombstone_signing import verify_tombstone_signature
632 from ..lifecycle.tombstones import apply_inbound_tombstone
634 try:
635 record = TombstoneRecord(**payload)
636 except Exception as exc:
637 _audit_tombstone_payload_rejected(payload, "tombstone", str(exc))
638 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
640 _verify_signed_artifact_or_400(
641 record=record,
642 key_id=record.key_id or "",
643 artifact_label="tombstone",
644 missing_manifest_detail="no manifest for signer",
645 signer_uri=record.signed_by,
646 verifier=verify_tombstone_signature,
647 on_failure=_emit_tombstone_verification_failed,
648 )
650 if peer is None: 650 ↛ 654line 650 didn't jump to line 654 because the condition on line 650 was never true
651 # A capability-token-only caller carries no per-peer tenant policy. Without a peer row
652 # there is nothing to pin the tenant against, so a non-default landing cannot be made
653 # safe; the back-compat single-node contract is the default partition.
654 direct_tenant_id = "default"
655 else:
656 # Resolve the (direct) ingest tenant fail-closed — the SAME resolver the v2 path uses.
657 # A mis-pinned / ambiguous peer ⇒ 403 rather than silently mis-landing in "default".
658 from ..db import db as _db
659 from ..federation.peer_policy import PeerPolicyError, resolve_ingest_tenant_for_peer
661 try:
662 with _db() as conn:
663 direct_tenant_id = resolve_ingest_tenant_for_peer(peer, conn)
664 except PeerPolicyError as exc:
665 _audit_tombstone_payload_rejected(payload, "tombstone", f"tenant_policy_unsafe: {exc}")
666 raise HTTPException(
667 status_code=status.HTTP_403_FORBIDDEN, detail=f"tenant policy unsafe: {exc}"
668 ) from exc
670 written = apply_inbound_tombstone(record, tenant_id=direct_tenant_id)
671 return {"status": "ok", "written": written}
674# W6.8: reason codes emitted by the SHARED ``ingest_tombstone_entry`` (the v2 secure chain)
675# mapped to the PUSH route's HTTP contract. The pull loop logs+continues on these reasons;
676# the push route surfaces them as 4xx so a posting peer learns its envelope was rejected.
677_V2_INGEST_REASON_HTTP: dict[str, int] = {
678 "malformed_entry": status.HTTP_400_BAD_REQUEST,
679 "missing_tombstone_origin_or_sig": status.HTTP_400_BAD_REQUEST,
680 "malformed_tombstone": status.HTTP_400_BAD_REQUEST,
681 # Rev-3: revocation envelope parse reasons (mirror the tombstone shapes).
682 "missing_revocation_origin_or_sig": status.HTTP_400_BAD_REQUEST,
683 "malformed_revocation": status.HTTP_400_BAD_REQUEST,
684 # origin != sender with relay OFF — the push route is not a weaker path than pull.
685 "origin_not_sender": status.HTTP_403_FORBIDDEN,
686 "relay_sender_not_trusted": status.HTTP_403_FORBIDDEN,
687 "origin_unresolvable": status.HTTP_401_UNAUTHORIZED,
688 "origin_sig_invalid": status.HTTP_400_BAD_REQUEST,
689 "issuer_sig_invalid": status.HTTP_400_BAD_REQUEST,
690 "scope_not_in_origin_grant": status.HTTP_403_FORBIDDEN,
691 # F-2c-MED-2: fact.scope ∉ VALID_SCOPES (non-enum/wildcard scope rejected on ingest).
692 "invalid_scope": status.HTTP_400_BAD_REQUEST,
693 # F-2c-MED-1: origin.tenant ∉ origin.allowed_tenants (ingest/egress symmetry).
694 "tenant_not_in_origin_grant": status.HTTP_403_FORBIDDEN,
695 "tenant_policy_unsafe": status.HTTP_403_FORBIDDEN,
696 # Same-issuer binding: revocation.signed_by != held tombstone's issuer (RTBF integrity).
697 "revocation_authority_mismatch": status.HTTP_403_FORBIDDEN,
698}
701def _ingest_revocation_v2(
702 entry: dict[str, Any], peer: dict[str, Any] | None, fed_settings: Any
703) -> dict[str, Any]:
704 """Push-ingest ONE v2 revocation envelope entry through the SHARED secure chain (Rev-3).
706 Routes the posted envelope through ``ingest_revocation_entry`` — the EXACT verify+apply
707 code path the pull loop uses — so the push surface can never be weaker than pull. A relayed
708 (origin != sender) revocation requires relay ON + the SENDER peer relay_trusted (same
709 fail-closed gate). On a skip/reject the helper's ``reason`` is mapped to the push route's
710 HTTP contract; on success returns ``{"status": "ok", "type": "revocation"}``.
711 """
712 from ..federation.federation_pull import ingest_revocation_entry
714 if peer is None: 714 ↛ 716line 714 didn't jump to line 716 because the condition on line 714 was never true
715 # A v2 envelope needs the authenticated peer (relay_trusted + node_id). Fail closed.
716 _audit_tombstone_payload_rejected(
717 entry.get("revocation", entry), "revocation", "v2_envelope_requires_peer_identity"
718 )
719 raise HTTPException(
720 status_code=status.HTTP_401_UNAUTHORIZED,
721 detail="v2 revocation envelope requires an authenticated peer identity",
722 )
724 sender_node_id = str(peer["node_id"])
725 try:
726 relay_trusted = bool(peer["relay_trusted"])
727 except (KeyError, IndexError, TypeError):
728 relay_trusted = bool(dict(peer).get("relay_trusted"))
730 result = ingest_revocation_entry(
731 entry=entry,
732 sender_node_id=sender_node_id,
733 peer=peer,
734 relay_enabled=fed_settings.federation_relay_enabled,
735 relay_trusted=relay_trusted,
736 relay_cache={},
737 )
738 if result.applied:
739 return {"status": "ok", "type": "revocation"}
741 reason = result.reason or "revocation_verification_failed"
742 _audit_tombstone_payload_rejected(entry.get("revocation", entry), "revocation", reason)
743 raise HTTPException(
744 status_code=_V2_INGEST_REASON_HTTP.get(reason, status.HTTP_400_BAD_REQUEST),
745 detail=f"revocation_rejected: {reason}",
746 )
749def _ingest_tombstone_v2(
750 entry: dict[str, Any], peer: dict[str, Any] | None, fed_settings: Any
751) -> dict[str, Any]:
752 """Push-ingest ONE v2 tombstone envelope entry through the SHARED secure chain.
754 Routes the posted envelope through ``ingest_tombstone_entry`` — the EXACT verify+apply
755 code path the pull loop uses (W6.8) — so the push surface can never be weaker than pull.
756 A relayed (origin != sender) tombstone requires relay ON + the SENDER peer relay_trusted
757 (same fail-closed gate). On a skip/reject the helper's ``reason`` is mapped to the push
758 route's HTTP contract; on success returns the existing ``{"status": "ok", "written": ...}``.
759 """
760 from ..federation.federation_pull import ingest_tombstone_entry
762 if peer is None: 762 ↛ 766line 762 didn't jump to line 766 because the condition on line 762 was never true
763 # The shared chain needs the authenticated peer (relay_trusted + tenant policy +
764 # node_id). A capability-token-only caller cannot post a v2 envelope: there is no peer
765 # identity to gate relay/tenant against. Fail closed.
766 _audit_tombstone_payload_rejected(
767 entry.get("tombstone", entry), "tombstone", "v2_envelope_requires_peer_identity"
768 )
769 raise HTTPException(
770 status_code=status.HTTP_401_UNAUTHORIZED,
771 detail="v2 tombstone envelope requires an authenticated peer identity",
772 )
774 sender_node_id = str(peer["node_id"])
775 try:
776 relay_trusted = bool(peer["relay_trusted"])
777 except (KeyError, IndexError, TypeError):
778 relay_trusted = bool(dict(peer).get("relay_trusted"))
780 # Resolve the page-level (direct) ingest tenant fail-closed — same resolver the pull loop
781 # uses for a DIRECT tombstone. A mis-pinned peer ⇒ 403 rather than mis-landing the tombstone.
782 from ..db import db as _db
783 from ..federation.peer_policy import PeerPolicyError, resolve_ingest_tenant_for_peer
785 try:
786 with _db() as conn:
787 direct_tenant_id = resolve_ingest_tenant_for_peer(peer, conn)
788 except PeerPolicyError as exc:
789 _audit_tombstone_payload_rejected(
790 entry.get("tombstone", entry), "tombstone", f"tenant_policy_unsafe: {exc}"
791 )
792 raise HTTPException(
793 status_code=status.HTTP_403_FORBIDDEN, detail=f"tenant policy unsafe: {exc}"
794 ) from exc
796 result = ingest_tombstone_entry(
797 entry=entry,
798 sender_node_id=sender_node_id,
799 peer=peer,
800 relay_enabled=fed_settings.federation_relay_enabled,
801 relay_trusted=relay_trusted,
802 direct_tenant_id=direct_tenant_id,
803 relay_cache={},
804 )
805 if result.applied:
806 return {"status": "ok", "written": True}
808 reason = result.reason or "tombstone_verification_failed"
809 _audit_tombstone_payload_rejected(entry.get("tombstone", entry), "tombstone", reason)
810 raise HTTPException(
811 status_code=_V2_INGEST_REASON_HTTP.get(reason, status.HTTP_400_BAD_REQUEST),
812 detail=f"tombstone_rejected: {reason}",
813 )
816def federation_ingest_tombstone_impl(
817 request: Request,
818 payload: dict[str, Any],
819 authorization: str | None,
820 x_stigmem_capability: str | None,
821 try_peer_token_auth: Any,
822 get_mtls_peer_cert: Any,
823) -> dict[str, Any]:
824 """Inbound tombstone push from a federation peer (§23.4.2).
826 Auth: peer JWT or capability token with tombstone:write verb (mirrors push_facts).
828 Body shapes (W6.8):
829 * ``{"tombstone_id": ...}`` → revocation ingest (unchanged; later task).
830 * v2 envelope — a single ``{"tombstone", "origin", "origin_sig"}`` entry OR a
831 ``{"v": 2, "tombstones": [...]}`` page → routed through the SHARED secure chain
832 (``ingest_tombstone_entry``) so push and pull verify identically. A relayed
833 (origin != sender) tombstone requires relay ON + sender relay_trusted.
834 * bare ``TombstoneRecord`` (pre-v2) → accepted as a DIRECT, issuer-verified tombstone
835 (received_from=None, origin==self semantics) for back-compat with single-node callers.
836 A relayed tombstone MUST use the v2 envelope (a bare body carries no origin block).
837 """
838 # Lazy lookup: tests monkey-patch ``federation.settings``.
839 from typing import cast as _cast
841 from . import federation as _fed_mod
843 fed_settings = _cast(Any, _fed_mod).settings
845 peer = _authenticate_tombstone_caller(
846 request,
847 authorization,
848 x_stigmem_capability,
849 try_peer_token_auth,
850 get_mtls_peer_cert,
851 fed_settings,
852 )
854 # v2 enveloped revocation — a single ``{"revocation", "origin", "origin_sig"}`` entry routed
855 # through the SHARED secure chain (Rev-3) so push and pull verify identically. A relayed
856 # (origin != sender) revocation requires relay ON + sender relay_trusted. Checked BEFORE the
857 # bare-revocation path: a v2 entry carries ``tombstone_id`` nested under ``revocation``.
858 if "revocation" in payload and "origin" in payload:
859 return _ingest_revocation_v2(payload, peer, fed_settings)
861 if "tombstone_id" in payload:
862 return _ingest_revocation(payload, fed_settings)
864 # v2 enveloped tombstone — a single entry, or a full v2 page of entries. ``peer`` is the
865 # peer already resolved by the auth step (the peer-JWT nonce is single-use, so it is not
866 # re-verified here).
867 if "tombstone" in payload and "origin" in payload:
868 return _ingest_tombstone_v2(payload, peer, fed_settings)
869 if payload.get("v") == 2 and isinstance(payload.get("tombstones"), list): 869 ↛ 870line 869 didn't jump to line 870 because the condition on line 869 was never true
870 written_any = False
871 for sub_entry in payload["tombstones"]:
872 res = _ingest_tombstone_v2(sub_entry, peer, fed_settings)
873 written_any = written_any or bool(res.get("written"))
874 return {"status": "ok", "written": written_any}
876 # Bare (pre-v2) tombstone — back-compat DIRECT issuer-verified path. ``peer`` is the
877 # authenticated peer (or None for a capability-token caller); the helper resolves its
878 # pinned ingest tenant fail-closed so the tombstone lands in the peer's tenant, not "default".
879 return _ingest_tombstone(payload, peer, fed_settings)
882def _emit_tombstone_verification_failed(record: TombstoneRecord, reason: str) -> None:
883 import logging as _logging
885 _logging.getLogger("stigmem.tombstones.ingest").error(
886 "tombstone_verification_failed: tombstone_id=%s entity=%s reason=%s",
887 record.id,
888 record.entity_uri,
889 reason,
890 )
893def _audit_tombstone_ingest_rejected(record: Any, artifact_label: str, reason: str) -> None:
894 from ..observability.audit_event import emit_nofail
896 artifact_id = str(getattr(record, "id", "") or getattr(record, "tombstone_id", ""))
897 signer_uri = str(getattr(record, "signed_by", "") or "system:federation")
898 target_entity_uri = str(
899 getattr(record, "entity_uri", "") or getattr(record, "tombstone_id", "") or artifact_id
900 )
901 emit_nofail(
902 "tombstone_federation_rejected",
903 entity_uri=signer_uri,
904 fact_id=artifact_id or None,
905 source="federation",
906 detail={
907 "artifact": artifact_label,
908 "artifact_id": artifact_id,
909 "target_entity_uri": target_entity_uri,
910 "key_id": str(getattr(record, "key_id", "") or ""),
911 "reason": reason,
912 },
913 )
916def _audit_tombstone_payload_rejected(
917 payload: dict[str, Any],
918 artifact_label: str,
919 reason: str,
920) -> None:
921 from ..observability.audit_event import emit_nofail
923 artifact_id = str(payload.get("id") or payload.get("tombstone_id") or "")
924 signer_uri = str(payload.get("signed_by") or "system:federation")
925 target_entity_uri = str(payload.get("entity_uri") or payload.get("tombstone_id") or artifact_id)
926 emit_nofail(
927 "tombstone_federation_rejected",
928 entity_uri=signer_uri,
929 fact_id=artifact_id or None,
930 source="federation",
931 detail={
932 "artifact": artifact_label,
933 "artifact_id": artifact_id,
934 "target_entity_uri": target_entity_uri,
935 "key_id": str(payload.get("key_id") or ""),
936 "reason": reason,
937 },
938 )