"""Infonet — append-only signed event ledger for decentralized consensus. The Infonet is ShadowBroker's consensus protocol. Every action on the mesh (message, vote, gate creation, oracle prediction) becomes a chain event. Each event references the previous event's hash, creating an immutable ordered sequence. No mining, no proof-of-work — just cryptographic linking and signature verification. This is the consensus layer. The reputation, gates, and oracle systems are the application layer that sits on top. Event types: - message: Public broadcast message - vote: Reputation vote (+1/-1 on a node) - gate_create: New gate/community creation - prediction: Oracle market prediction - stake: Oracle truth stake - key_rotate: Link old and new public keys - key_revoke: Revoke a compromised key (with grace window) Private DM registration, mailbox access, and transport routing metadata are intentionally kept off-ledger. Each event contains: - event_id: SHA-256 hash of (prev_hash + type + payload + timestamp + node_id) - prev_hash: Hash of the previous event (chain link) - type: Event type string - node_id: Author's node ID - payload: Event-specific data - timestamp: Unix timestamp - sequence: Per-node monotonic sequence number (replay protection) - signature: Node's cryptographic signature Persistence: JSON file at backend/data/infonet.json Encrypted gate chat events are intentionally kept off the public chain and persisted separately via GateMessageStore. """ import json import os import time import hmac import hashlib import logging import threading import atexit import tempfile import base64 import zlib from pathlib import Path from collections import deque from typing import Any from services.mesh.mesh_secure_storage import read_domain_json, write_domain_json from services.mesh.mesh_crypto import ( build_signature_payload, parse_public_key_algo, verify_node_binding, verify_signature, ) from services.mesh.mesh_protocol import NETWORK_ID, PROTOCOL_VERSION, normalize_payload from services.mesh.mesh_schema import ( ACTIVE_PUBLIC_LEDGER_EVENT_TYPES, PUBLIC_LEDGER_EVENT_TYPES, validate_event_payload, validate_protocol_fields, validate_public_ledger_payload, ) logger = logging.getLogger("services.mesh_hashchain") _PRIVACY_LOGS = os.environ.get("MESH_PRIVACY_LOGS", "").strip().lower() in ("1", "true", "yes") _MESH_ONLY = os.environ.get("MESH_ONLY", "").strip().lower() in ("1", "true", "yes") def _safe_int(val, default=0): try: return int(val) except (TypeError, ValueError): return default def _redact_node(node_id: str) -> str: if not node_id: return "" if _PRIVACY_LOGS or _MESH_ONLY: return f"{node_id[:6]}…" return node_id def _atomic_write_text(target: Path, content: str, encoding: str = "utf-8") -> None: """Write content atomically via temp file + os.replace().""" parent = target.parent parent.mkdir(parents=True, exist_ok=True) fd, tmp_path = tempfile.mkstemp(dir=str(parent), suffix=".tmp") try: with os.fdopen(fd, "w", encoding=encoding) as handle: handle.write(content) handle.flush() os.fsync(handle.fileno()) os.replace(tmp_path, str(target)) except BaseException: try: os.unlink(tmp_path) except OSError: pass raise DATA_DIR = Path(__file__).resolve().parents[2] / "data" CHAIN_FILE = DATA_DIR / "infonet.json" WAL_FILE = DATA_DIR / "infonet.wal" GATE_STORE_DIR = DATA_DIR / "gate_messages" GATE_STORAGE_DOMAIN = "gates" # ─── Constants ──────────────────────────────────────────────────────────── GENESIS_HASH = "0" * 64 # The "previous hash" for the first event MAX_CHAIN_MEMORY = 50000 # Max events to keep in memory (older ones on disk only) EPHEMERAL_TTL = 86400 # 24 hours — ephemeral messages auto-purge MESSAGE_RETENTION_DAYS = 90 # Non-ephemeral messages kept for 90 days CHAIN_LOCK_DEPTH = 6 GATE_REPLAY_WINDOW_S = 86400 * 30 GATE_REPLAY_PRUNE_INTERVAL = 256 GATE_SEGMENT_EVENT_TARGET = max(1, int(os.environ.get("MESH_GATE_SEGMENT_EVENT_TARGET", "1000") or "1000")) GATE_SEGMENT_MAX_COMPRESSED_BYTES = max( 16 * 1024, int(os.environ.get("MESH_GATE_SEGMENT_MAX_COMPRESSED_BYTES", str(2 * 1024 * 1024)) or str(2 * 1024 * 1024)), ) GATE_SEGMENT_STORAGE_VERSION = 1 _PUBLIC_EVENT_APPEND_HOOKS: list[Any] = [] _PUBLIC_EVENT_APPEND_HOOKS_LOCK = threading.Lock() def register_public_event_append_hook(callback: Any) -> None: if callback is None: return with _PUBLIC_EVENT_APPEND_HOOKS_LOCK: if callback not in _PUBLIC_EVENT_APPEND_HOOKS: _PUBLIC_EVENT_APPEND_HOOKS.append(callback) def unregister_public_event_append_hook(callback: Any) -> None: with _PUBLIC_EVENT_APPEND_HOOKS_LOCK: if callback in _PUBLIC_EVENT_APPEND_HOOKS: _PUBLIC_EVENT_APPEND_HOOKS.remove(callback) def _notify_public_event_append_hooks(event_dict: dict[str, Any]) -> None: with _PUBLIC_EVENT_APPEND_HOOKS_LOCK: hooks = list(_PUBLIC_EVENT_APPEND_HOOKS) for hook in hooks: try: hook(dict(event_dict)) except Exception: logger.exception("public event append hook failed") # ─── Network Identity ──────────────────────────────────────────────────── # NETWORK_ID is defined in services.mesh_protocol to avoid circular imports. # ─── Protocol Constraints ──────────────────────────────────────────────── ACTIVE_APPEND_EVENT_TYPES = set(ACTIVE_PUBLIC_LEDGER_EVENT_TYPES) """Event types allowed for new append() calls — gate_message excluded since S3A/S4B.""" ALLOWED_EVENT_TYPES = set(PUBLIC_LEDGER_EVENT_TYPES) """Full set including legacy types — used by ingest_events() and apply_fork().""" MAX_PAYLOAD_BYTES = 4096 REPLAY_FILTER_BITS = 1_000_000 REPLAY_FILTER_HASHES = 3 REPLAY_FILTER_ROTATE_S = 3600 CRITICAL_EVENT_TYPES = {"key_rotate", "key_revoke"} MIN_CONFIRMATIONS_CRITICAL = 3 def _gate_wire_event_material(event: dict[str, Any]) -> str: payload = event.get("payload") if isinstance(event.get("payload"), dict) else {} material = { "event_type": str(event.get("event_type", "gate_message") or "gate_message"), "timestamp": float(event.get("timestamp", 0) or 0), "ciphertext": str(payload.get("ciphertext", "") or ""), "format": str(payload.get("format", "") or ""), } return json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=False) def build_gate_replay_fingerprint(gate_id: str, event: dict[str, Any]) -> str: payload = event.get("payload") if isinstance(event.get("payload"), dict) else {} material = { "gate": str(gate_id or "").strip().lower(), "event_type": "gate_message", "timestamp": float(event.get("timestamp", 0) or 0), "ciphertext": str(payload.get("ciphertext", "") or ""), "nonce": str(payload.get("nonce", "") or ""), "format": str(payload.get("format", "") or ""), } return hashlib.sha256( json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") ).hexdigest() def _peer_pair_ref_key(peer_url: str) -> bytes: """Derive a per-hop HMAC key for gate wire refs. Sprint 3 / Rec #4: the wire ref used to be HMAC-keyed by the global ``MESH_PEER_PUSH_SECRET``, which let any authenticated peer enumerate gate memberships by HMACing every gate_id they knew. The new key is bound to the authenticated *hop* (the receiving peer's URL) via the same HKDF chain as the peer-push HMAC, with a fresh domain separator. A peer who intercepts push traffic addressed to *another* receiver cannot derive the matching key — they only learn gate_ids on pushes where they are the intended receiver, which they would learn anyway via MLS membership. Returns an empty key on misconfiguration so callers fail closed. """ try: from services.mesh.mesh_crypto import ( normalize_peer_url, resolve_peer_key_for_url, ) except Exception: return b"" normalized = normalize_peer_url(peer_url or "") if not normalized: return b"" # Issue #256: resolve_peer_key_for_url() prefers per-peer secrets # from MESH_PEER_SECRETS and falls back to the global # MESH_PEER_PUSH_SECRET only when the URL has no per-peer entry. peer_key = resolve_peer_key_for_url(normalized) if not peer_key: return b"" # Domain-separate from the transport HMAC key so the two # derivations can't cross-contaminate in analysis. return hmac.new(peer_key, b"sb-gate-ref-v2", hashlib.sha256).digest() def build_gate_wire_ref( gate_id: str, event: dict[str, Any], *, peer_url: str = "", ) -> str: gate_key = str(gate_id or "").strip().lower() if not gate_key: return "" key = _peer_pair_ref_key(peer_url) if not key: return "" material = f"{gate_key}|{_gate_wire_event_material(event)}".encode("utf-8") return hmac.new(key, material, hashlib.sha256).hexdigest() def resolve_gate_wire_ref( gate_ref: str, event: dict[str, Any], *, peer_url: str = "", ) -> str: ref = str(gate_ref or "").strip().lower() if not ref: return "" if not peer_url: # Sprint 3 / Rec #4: pair-binding is mandatory. Refuse to # resolve refs that don't identify the hop — fail-closed # stops stale callers from enumerating via a one-sided key. return "" candidates: set[str] = set() try: candidates.update(gate_store.known_gate_ids()) except Exception: pass try: from services.mesh.mesh_reputation import gate_manager for gate in gate_manager.list_gates(): gate_id = str((gate or {}).get("gate_id", "") or "").strip().lower() if gate_id: candidates.add(gate_id) except Exception: pass for gate_id in sorted(candidates): candidate_ref = build_gate_wire_ref( gate_id, event, peer_url=peer_url, ) if candidate_ref and hmac.compare_digest(candidate_ref, ref): return gate_id return "" def _private_gate_signature_payload( gate_id: str, event: dict[str, Any], *, include_reply_to: bool = True, ) -> dict[str, Any]: payload = event.get("payload") if isinstance(event.get("payload"), dict) else {} normalized = { "gate": str(gate_id or "").strip().lower(), "ciphertext": str(payload.get("ciphertext", "") or ""), "nonce": str(payload.get("nonce", "") or ""), "sender_ref": str(payload.get("sender_ref", "") or ""), "format": str(payload.get("format", "mls1") or "mls1"), } epoch = _safe_int(payload.get("epoch", 0) or 0, 0) if epoch > 0: normalized["epoch"] = epoch envelope_hash = str(payload.get("envelope_hash", "") or "").strip() if envelope_hash: normalized["envelope_hash"] = envelope_hash transport_lock = str(payload.get("transport_lock", "") or "").strip().lower() if transport_lock: normalized["transport_lock"] = transport_lock reply_to = str(payload.get("reply_to", "") or "").strip() if include_reply_to and reply_to: normalized["reply_to"] = reply_to return normalize_payload("gate_message", normalized) def _private_gate_event_id( gate_id: str, node_id: str, sequence: int, event: dict[str, Any], *, include_reply_to: bool = True, ) -> str: payload_json = json.dumps( _private_gate_signature_payload(gate_id, event, include_reply_to=include_reply_to), sort_keys=True, separators=(",", ":"), ensure_ascii=False, ) timestamp = float(event.get("timestamp", 0) or 0) return hashlib.sha256( f"{gate_id}:{node_id}:{payload_json}:{timestamp}:{int(sequence)}".encode("utf-8") ).hexdigest() def _sanitize_private_gate_event(gate_id: str, event: dict[str, Any]) -> dict[str, Any]: payload = event.get("payload") if isinstance(event.get("payload"), dict) else {} sanitized = { "event_id": str(event.get("event_id", "") or ""), "event_type": "gate_message", "node_id": str(event.get("node_id", "") or ""), "timestamp": float(event.get("timestamp", 0) or 0), "sequence": int(event.get("sequence", 0) or 0), "signature": str(event.get("signature", "") or ""), "public_key": str(event.get("public_key", "") or ""), "public_key_algo": str(event.get("public_key_algo", "") or ""), "protocol_version": str(event.get("protocol_version", "") or ""), "payload": { "gate": str(gate_id or "").strip().lower(), "ciphertext": str(payload.get("ciphertext", "") or ""), "nonce": str(payload.get("nonce", "") or ""), "sender_ref": str(payload.get("sender_ref", "") or ""), "format": str(payload.get("format", "mls1") or "mls1"), }, } epoch = _safe_int(payload.get("epoch", 0) or 0, 0) if epoch > 0: sanitized["payload"]["epoch"] = epoch envelope_hash = str(payload.get("envelope_hash", "") or "").strip() if envelope_hash: sanitized["payload"]["envelope_hash"] = envelope_hash gate_envelope = str(payload.get("gate_envelope", "") or "").strip() if gate_envelope: sanitized["payload"]["gate_envelope"] = gate_envelope transport_lock = str(payload.get("transport_lock", "") or "").strip().lower() if transport_lock: sanitized["payload"]["transport_lock"] = transport_lock reply_to = str(payload.get("reply_to", "") or "").strip() if reply_to: sanitized["payload"]["reply_to"] = reply_to # Local-only decrypted plaintext — persisted on the private chain so # leave/rejoin and restarts never lose readable messages. These fields are # stamped post-decrypt and never leave the node. local_pt = payload.get("_local_plaintext") if isinstance(local_pt, str) and local_pt: sanitized["payload"]["_local_plaintext"] = local_pt local_rt = payload.get("_local_reply_to") if isinstance(local_rt, str) and local_rt: sanitized["payload"]["_local_reply_to"] = local_rt return sanitized def _authorize_private_gate_transport_author( gate_id: str, node_id: str, public_key: str, public_key_algo: str, ) -> tuple[bool, str]: gate_key = str(gate_id or "").strip().lower() candidate = str(node_id or "").strip() if not gate_key or not candidate: return False, "private gate authorization unavailable" try: from services.mesh.mesh_reputation import gate_manager, reputation_ledger except Exception: return False, "private gate authorization unavailable" try: reputation_ledger.register_node(candidate, public_key, public_key_algo) except Exception: return False, "private gate authorization unavailable" ok, reason = gate_manager.can_enter(candidate, gate_key) if ok: return True, "ok" return False, str(reason or "Gate access denied") def _verify_private_gate_transport_event(gate_id: str, event: dict[str, Any]) -> tuple[bool, str, dict[str, Any] | None]: node_id = str(event.get("node_id", "") or event.get("sender_id", "") or "").strip() public_key = str(event.get("public_key", "") or "").strip() public_key_algo = str(event.get("public_key_algo", "") or "").strip() signature = str(event.get("signature", "") or "").strip() protocol_version = str(event.get("protocol_version", "") or "").strip() sequence = _safe_int(event.get("sequence", 0) or 0, 0) if not node_id or not public_key or not public_key_algo or not signature: return False, "missing private gate auth fields", None if sequence <= 0: return False, "invalid private gate sequence", None if protocol_version != PROTOCOL_VERSION: return False, "Unsupported protocol_version", None payload = _private_gate_signature_payload(gate_id, event) ok, reason = validate_event_payload("gate_message", payload) if not ok: return False, reason, None if not verify_node_binding(node_id, public_key): return False, "node_id mismatch", None algo = parse_public_key_algo(public_key_algo) if not algo: return False, "Unsupported public_key_algo", None reply_to = str(((event.get("payload") or {}) if isinstance(event.get("payload"), dict) else {}).get("reply_to", "") or "").strip() legacy_unsigned_reply_to = False legacy_unsigned_epoch = False variants: list[tuple[dict[str, Any], bool, bool]] = [(payload, False, False)] if reply_to: variants.append((_private_gate_signature_payload(gate_id, event, include_reply_to=False), True, False)) if "epoch" in payload: no_epoch = dict(payload) no_epoch.pop("epoch", None) variants.append((no_epoch, False, True)) if reply_to: no_epoch_no_reply = _private_gate_signature_payload(gate_id, event, include_reply_to=False) no_epoch_no_reply.pop("epoch", None) variants.append((no_epoch_no_reply, True, True)) sig_ok = False for candidate_payload, candidate_unsigned_reply, candidate_unsigned_epoch in variants: candidate_sig_payload = build_signature_payload( event_type="gate_message", node_id=node_id, sequence=sequence, payload=candidate_payload, ) if verify_signature( public_key_b64=public_key, public_key_algo=algo, signature_hex=signature, payload=candidate_sig_payload, ): sig_ok = True legacy_unsigned_reply_to = candidate_unsigned_reply legacy_unsigned_epoch = candidate_unsigned_epoch break if not sig_ok: return False, "Invalid signature", None envelope_hash = str(((event.get("payload") or {}) if isinstance(event.get("payload"), dict) else {}).get("envelope_hash", "") or "").strip() gate_envelope = str(((event.get("payload") or {}) if isinstance(event.get("payload"), dict) else {}).get("gate_envelope", "") or "").strip() if envelope_hash: if not gate_envelope: return False, "gate_envelope required when envelope_hash is present", None if hashlib.sha256(gate_envelope.encode("ascii")).hexdigest() != envelope_hash: return False, "gate_envelope does not match envelope_hash", None authorized, reason = _authorize_private_gate_transport_author(gate_id, node_id, public_key, public_key_algo) if not authorized: return False, f"private gate access denied: {reason}", None event_for_id = event if legacy_unsigned_epoch: event_for_id = dict(event) event_payload_for_id = dict((event.get("payload") or {}) if isinstance(event.get("payload"), dict) else {}) event_payload_for_id.pop("epoch", None) event_for_id["payload"] = event_payload_for_id expected_event_id = _private_gate_event_id( gate_id, node_id, sequence, event_for_id, include_reply_to=not legacy_unsigned_reply_to, ) provided_event_id = str(event.get("event_id", "") or "").strip() if provided_event_id and provided_event_id != expected_event_id: return False, "private gate event_id mismatch", None sanitized = _sanitize_private_gate_event(gate_id, event) if legacy_unsigned_reply_to: sanitized["payload"].pop("reply_to", None) if legacy_unsigned_epoch: sanitized["payload"].pop("epoch", None) sanitized["event_id"] = provided_event_id or expected_event_id return True, "ok", sanitized class GateMessageStore: """Private-plane storage for encrypted gate messages.""" def __init__(self, data_dir: str = ""): self._gates: dict[str, list[dict]] = {} self._event_index: dict[str, dict] = {} self._replay_index: dict[str, dict[str, Any]] = {} self._replay_prune_counter = 0 self._data_dir = Path(data_dir) if data_dir else GATE_STORE_DIR self._lock = threading.Lock() self._change_condition = threading.Condition(self._lock) self._load() def _gate_digest(self, gate_id: str) -> str: return hashlib.sha256(str(gate_id or "").encode("utf-8")).hexdigest() def _gate_file_path(self, gate_id: str) -> Path: return self._data_dir / f"gate_{self._gate_digest(gate_id)}.jsonl" def _gate_legacy_domain_filename(self, gate_id: str) -> str: return f"gate_{self._gate_digest(gate_id)}.jsonl" def _gate_manifest_filename_for_digest(self, digest: str) -> str: return f"gate_{digest}.manifest.json" def _gate_manifest_filename(self, gate_id: str) -> str: return self._gate_manifest_filename_for_digest(self._gate_digest(gate_id)) def _gate_segment_filename_for_digest(self, digest: str, segment_no: int) -> str: return f"gate_{digest}_seg_{max(0, int(segment_no)):08d}.gseg" def _gate_segment_filename(self, gate_id: str, segment_no: int) -> str: return self._gate_segment_filename_for_digest(self._gate_digest(gate_id), segment_no) def _gate_storage_base_dir(self) -> Path: return self._data_dir.parent def _gate_domain_dir(self) -> Path: return self._gate_storage_base_dir() / GATE_STORAGE_DOMAIN def _sort_gate(self, gate_id: str) -> None: events = self._gates.get(gate_id, []) events.sort( key=lambda evt: ( float(evt.get("timestamp", 0) or 0), _safe_int(evt.get("sequence", 0) or 0, 0), str(evt.get("event_id", "") or ""), ) ) def _remember_replay_fingerprint(self, replay_fingerprint: str, event: dict) -> None: self._replay_index[replay_fingerprint] = { "event": event, "timestamp": float(event.get("timestamp", 0) or 0.0), } def _replay_existing_event(self, replay_fingerprint: str) -> dict | None: entry = self._replay_index.get(replay_fingerprint) or {} event = entry.get("event") return event if isinstance(event, dict) else None def _prune_replay_index(self, now: float | None = None) -> int: current = float(now if now is not None else time.time()) cutoff = current - GATE_REPLAY_WINDOW_S stale = [ fingerprint for fingerprint, entry in list(self._replay_index.items()) if float((entry or {}).get("timestamp", 0) or 0.0) < cutoff ] for fingerprint in stale: self._replay_index.pop(fingerprint, None) return len(stale) def _maybe_prune_replay_index(self) -> None: self._replay_prune_counter += 1 if self._replay_prune_counter % GATE_REPLAY_PRUNE_INTERVAL == 0: self._prune_replay_index() def _stable_bytes(self, payload: Any) -> bytes: return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") def _segment_material_hash(self, payload: dict[str, Any]) -> str: material = dict(payload) material.pop("segment_hash", None) return hashlib.sha256(self._stable_bytes(material)).hexdigest() def _encode_segment_events(self, events: list[dict]) -> str: raw = self._stable_bytes(events) return base64.b64encode(zlib.compress(raw, level=9)).decode("ascii") def _decode_segment_events(self, segment_payload: dict[str, Any]) -> list[dict[str, Any]]: if not isinstance(segment_payload, dict): return [] if str(segment_payload.get("codec", "") or "") != "zlib": return [] encoded = str(segment_payload.get("events_b64", "") or "") if not encoded: return [] try: raw = zlib.decompress(base64.b64decode(encoded.encode("ascii"))) decoded = json.loads(raw.decode("utf-8")) except Exception: return [] return [evt for evt in decoded if isinstance(evt, dict)] if isinstance(decoded, list) else [] def _build_segment_payload( self, *, gate_digest: str, segment_no: int, events: list[dict], prev_segment_hash: str = "", ) -> dict[str, Any]: encoded_events = self._encode_segment_events(events) first_event_id = str((events[0] or {}).get("event_id", "") or "") if events else "" last_event_id = str((events[-1] or {}).get("event_id", "") or "") if events else "" payload = { "version": GATE_SEGMENT_STORAGE_VERSION, "storage": "gate-segment-v1", "gate_digest": str(gate_digest or ""), "segment_no": int(segment_no), "prev_segment_hash": str(prev_segment_hash or ""), "count": len(events), "first_event_id": first_event_id, "last_event_id": last_event_id, "codec": "zlib", "encoding": "json", "events_b64": encoded_events, } payload["segment_hash"] = self._segment_material_hash(payload) return payload def _segment_meta_from_payload(self, payload: dict[str, Any], filename: str) -> dict[str, Any]: return { "segment_no": int(payload.get("segment_no", 0) or 0), "filename": str(filename or ""), "count": int(payload.get("count", 0) or 0), "first_event_id": str(payload.get("first_event_id", "") or ""), "last_event_id": str(payload.get("last_event_id", "") or ""), "prev_segment_hash": str(payload.get("prev_segment_hash", "") or ""), "segment_hash": str(payload.get("segment_hash", "") or ""), } def _read_gate_manifest(self, gate_id: str) -> dict[str, Any] | None: try: manifest = read_domain_json( GATE_STORAGE_DOMAIN, self._gate_manifest_filename(gate_id), lambda: {}, base_dir=self._gate_storage_base_dir(), ) except Exception: return None if not isinstance(manifest, dict): return None if str(manifest.get("storage", "") or "") != "gate-segments-v1": return None return manifest def _read_segment_file(self, filename: str) -> tuple[dict[str, Any], list[dict[str, Any]]]: payload = read_domain_json( GATE_STORAGE_DOMAIN, filename, lambda: {}, base_dir=self._gate_storage_base_dir(), ) if not isinstance(payload, dict): return {}, [] expected_hash = str(payload.get("segment_hash", "") or "") if expected_hash and expected_hash != self._segment_material_hash(payload): logger.warning("Gate segment hash mismatch for %s", filename) return {}, [] return payload, self._decode_segment_events(payload) def _load_segmented_gates(self) -> set[str]: encrypted_dir = self._gate_domain_dir() loaded_digests: set[str] = set() if not encrypted_dir.exists(): return loaded_digests for manifest_path in sorted(encrypted_dir.glob("gate_*.manifest.json")): try: manifest = read_domain_json( GATE_STORAGE_DOMAIN, manifest_path.name, lambda: {}, base_dir=self._gate_storage_base_dir(), ) except Exception: continue if not isinstance(manifest, dict) or str(manifest.get("storage", "") or "") != "gate-segments-v1": continue gate_digest = str(manifest.get("gate_digest", "") or "") if gate_digest: loaded_digests.add(gate_digest) segments = manifest.get("segments", []) if not isinstance(segments, list): continue for segment_meta in sorted( [item for item in segments if isinstance(item, dict)], key=lambda item: int(item.get("segment_no", 0) or 0), ): filename = str(segment_meta.get("filename", "") or "") if not filename: continue _payload, events = self._read_segment_file(filename) for evt in events: payload = evt.get("payload") or {} if not isinstance(payload, dict): continue gate_id = str(payload.get("gate", "") or "").strip().lower() if not gate_id: continue storage_event = _sanitize_private_gate_event(gate_id, evt) if not str(storage_event.get("event_id", "") or "").strip(): storage_event["event_id"] = self._synth_event_id(gate_id, storage_event) replay_fingerprint = build_gate_replay_fingerprint(gate_id, storage_event) if replay_fingerprint in self._replay_index: continue event_id = str(storage_event.get("event_id", "") or "") if event_id and event_id in self._event_index: continue self._gates.setdefault(gate_id, []).append(storage_event) if event_id: self._event_index[event_id] = storage_event self._remember_replay_fingerprint(replay_fingerprint, storage_event) return loaded_digests def _load(self) -> None: encrypted_dir = self._gate_domain_dir() if not self._data_dir.exists() and not encrypted_dir.exists(): return segmented_digests = self._load_segmented_gates() dirty_gates: set[str] = set() file_names = { path.name for path in self._data_dir.glob("gate_*.jsonl") } | { path.name for path in encrypted_dir.glob("gate_*.jsonl") } for file_name in sorted(file_names): digest = file_name.removeprefix("gate_").removesuffix(".jsonl") if digest in segmented_digests: continue events: list[dict[str, Any]] | None = None encrypted_path = encrypted_dir / file_name loaded_from_legacy_domain_list = False if encrypted_path.exists(): try: loaded = read_domain_json( GATE_STORAGE_DOMAIN, file_name, lambda: [], base_dir=self._gate_storage_base_dir(), ) if isinstance(loaded, list): events = [evt for evt in loaded if isinstance(evt, dict)] loaded_from_legacy_domain_list = True except Exception: events = None if events is None: legacy_path = self._data_dir / file_name if not legacy_path.exists(): continue try: lines = legacy_path.read_text(encoding="utf-8").splitlines() except Exception: continue events = [] for line in lines: if not line.strip(): continue try: evt = json.loads(line) except Exception: continue if isinstance(evt, dict): events.append(evt) loaded_gate_ids: set[str] = set() for evt in events: payload = evt.get("payload") or {} if not isinstance(payload, dict): continue gate_id = str(payload.get("gate", "") or "").strip().lower() if not gate_id: continue storage_event = _sanitize_private_gate_event(gate_id, evt) if storage_event != evt: dirty_gates.add(gate_id) evt = storage_event if not str(evt.get("event_id", "") or "").strip(): evt["event_id"] = self._synth_event_id(gate_id, evt) dirty_gates.add(gate_id) loaded_gate_ids.add(gate_id) replay_fingerprint = build_gate_replay_fingerprint(gate_id, evt) if replay_fingerprint in self._replay_index: dirty_gates.add(gate_id) continue event_id = str(evt.get("event_id", "") or "") if event_id and event_id in self._event_index: dirty_gates.add(gate_id) continue self._gates.setdefault(gate_id, []).append(evt) if event_id: self._event_index[event_id] = evt self._remember_replay_fingerprint(replay_fingerprint, evt) if loaded_from_legacy_domain_list or not encrypted_path.exists(): dirty_gates.update(loaded_gate_ids) self._prune_replay_index() for gate_id in list(self._gates.keys()): self._sort_gate(gate_id) for gate_id in sorted(dirty_gates): self._persist_gate(gate_id) def _persist_gate(self, gate_id: str, events: list[dict] | None = None) -> None: if events is None: events = self._gates.get(gate_id, []) gate_key = str(gate_id or "").strip().lower() if not gate_key: return gate_digest = self._gate_digest(gate_key) old_manifest = self._read_gate_manifest(gate_key) old_segment_files = { str(item.get("filename", "") or "") for item in list((old_manifest or {}).get("segments", []) or []) if isinstance(item, dict) } clean_events = [_sanitize_private_gate_event(gate_key, evt) for evt in list(events or []) if isinstance(evt, dict)] segments: list[dict[str, Any]] = [] prev_hash = "" written_segment_files: set[str] = set() for segment_no, start in enumerate(range(0, len(clean_events), GATE_SEGMENT_EVENT_TARGET)): chunk = clean_events[start : start + GATE_SEGMENT_EVENT_TARGET] filename = self._gate_segment_filename_for_digest(gate_digest, segment_no) segment_payload = self._build_segment_payload( gate_digest=gate_digest, segment_no=segment_no, events=chunk, prev_segment_hash=prev_hash, ) write_domain_json( GATE_STORAGE_DOMAIN, filename, segment_payload, base_dir=self._gate_storage_base_dir(), ) written_segment_files.add(filename) segments.append(self._segment_meta_from_payload(segment_payload, filename)) prev_hash = str(segment_payload.get("segment_hash", "") or "") manifest = { "version": GATE_SEGMENT_STORAGE_VERSION, "storage": "gate-segments-v1", "gate_digest": gate_digest, "segment_event_target": GATE_SEGMENT_EVENT_TARGET, "segment_max_compressed_bytes": GATE_SEGMENT_MAX_COMPRESSED_BYTES, "total_events": len(clean_events), "segment_count": len(segments), "head_segment_hash": prev_hash, "segments": segments, "updated_at": int(time.time()), } write_domain_json( GATE_STORAGE_DOMAIN, self._gate_manifest_filename_for_digest(gate_digest), manifest, base_dir=self._gate_storage_base_dir(), ) for stale_filename in old_segment_files - written_segment_files: if stale_filename: (self._gate_domain_dir() / stale_filename).unlink(missing_ok=True) legacy_domain_path = self._gate_domain_dir() / self._gate_legacy_domain_filename(gate_key) legacy_domain_path.unlink(missing_ok=True) self._gate_file_path(gate_id).unlink(missing_ok=True) def _persist_gate_new_events(self, gate_id: str, new_events: list[dict]) -> None: gate_key = str(gate_id or "").strip().lower() clean_new_events = [ _sanitize_private_gate_event(gate_key, evt) for evt in list(new_events or []) if isinstance(evt, dict) ] if not gate_key or not clean_new_events: return manifest = self._read_gate_manifest(gate_key) if not manifest: self._persist_gate(gate_key, list(self._gates.get(gate_key, [])) + clean_new_events) return gate_digest = self._gate_digest(gate_key) segments = [ dict(item) for item in list(manifest.get("segments", []) or []) if isinstance(item, dict) ] remaining = list(clean_new_events) if not segments: segment_no = 0 events_for_segment: list[dict] = [] filename = self._gate_segment_filename_for_digest(gate_digest, segment_no) prev_for_segment = "" else: last_meta = dict(segments[-1]) segment_no = int(last_meta.get("segment_no", len(segments) - 1) or 0) filename = str(last_meta.get("filename", "") or self._gate_segment_filename_for_digest(gate_digest, segment_no)) segment_payload, events_for_segment = self._read_segment_file(filename) prev_for_segment = str(segment_payload.get("prev_segment_hash", "") or last_meta.get("prev_segment_hash", "") or "") if not events_for_segment: self._persist_gate(gate_key, list(self._gates.get(gate_key, [])) + clean_new_events) return while remaining: candidate = events_for_segment + [remaining[0]] candidate_payload = self._build_segment_payload( gate_digest=gate_digest, segment_no=segment_no, events=candidate, prev_segment_hash=prev_for_segment, ) compressed_len = len(str(candidate_payload.get("events_b64", "") or "")) if ( events_for_segment and ( len(candidate) > GATE_SEGMENT_EVENT_TARGET or compressed_len > GATE_SEGMENT_MAX_COMPRESSED_BYTES ) ): segment_payload = self._build_segment_payload( gate_digest=gate_digest, segment_no=segment_no, events=events_for_segment, prev_segment_hash=prev_for_segment, ) existing_meta_matches = ( bool(segments) and _safe_int(segments[-1].get("segment_no", -1), -1) == segment_no and str(segments[-1].get("segment_hash", "") or "") == str(segment_payload.get("segment_hash", "") or "") ) if existing_meta_matches: meta = segments[-1] else: write_domain_json( GATE_STORAGE_DOMAIN, filename, segment_payload, base_dir=self._gate_storage_base_dir(), ) meta = self._segment_meta_from_payload(segment_payload, filename) if segments and _safe_int(segments[-1].get("segment_no", -1), -1) == segment_no: segments[-1] = meta else: segments.append(meta) prev_for_segment = str(meta.get("segment_hash", "") or segment_payload.get("segment_hash", "") or "") segment_no += 1 filename = self._gate_segment_filename_for_digest(gate_digest, segment_no) events_for_segment = [] continue events_for_segment = candidate remaining.pop(0) segment_payload = self._build_segment_payload( gate_digest=gate_digest, segment_no=segment_no, events=events_for_segment, prev_segment_hash=prev_for_segment, ) write_domain_json( GATE_STORAGE_DOMAIN, filename, segment_payload, base_dir=self._gate_storage_base_dir(), ) meta = self._segment_meta_from_payload(segment_payload, filename) if segments and _safe_int(segments[-1].get("segment_no", -1), -1) == segment_no: segments[-1] = meta else: segments.append(meta) manifest = { "version": GATE_SEGMENT_STORAGE_VERSION, "storage": "gate-segments-v1", "gate_digest": gate_digest, "segment_event_target": GATE_SEGMENT_EVENT_TARGET, "segment_max_compressed_bytes": GATE_SEGMENT_MAX_COMPRESSED_BYTES, "total_events": int(manifest.get("total_events", 0) or 0) + len(clean_new_events), "segment_count": len(segments), "head_segment_hash": str(segment_payload.get("segment_hash", "") or ""), "segments": segments, "updated_at": int(time.time()), } write_domain_json( GATE_STORAGE_DOMAIN, self._gate_manifest_filename(gate_key), manifest, base_dir=self._gate_storage_base_dir(), ) legacy_domain_path = self._gate_domain_dir() / self._gate_legacy_domain_filename(gate_key) legacy_domain_path.unlink(missing_ok=True) self._gate_file_path(gate_key).unlink(missing_ok=True) def _synth_event_id(self, gate_id: str, event: dict) -> str: payload = event.get("payload") if isinstance(event.get("payload"), dict) else {} material = { "gate": str(gate_id or "").strip().lower(), "event_type": str(event.get("event_type", "") or ""), "timestamp": float(event.get("timestamp", 0) or 0), "ciphertext": str(payload.get("ciphertext", "") or ""), "format": str(payload.get("format", "") or ""), } return hashlib.sha256( json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") ).hexdigest() def append(self, gate_id: str, event: dict) -> dict: gate_id = str(gate_id or "").strip().lower() if not gate_id: return event clean_event = _sanitize_private_gate_event(gate_id, event) if not str(clean_event.get("event_id", "") or "").strip(): clean_event["event_id"] = self._synth_event_id(gate_id, clean_event) with self._lock: self._gates.setdefault(gate_id, []) replay_fingerprint = build_gate_replay_fingerprint(gate_id, clean_event) existing = self._replay_existing_event(replay_fingerprint) if existing is not None: return existing event_id = str(clean_event.get("event_id", "") or "") if event_id and event_id in self._event_index: return self._event_index[event_id] # Stage: build new gate list without mutating in-memory state yet staged = list(self._gates[gate_id]) + [clean_event] staged.sort( key=lambda evt: ( float(evt.get("timestamp", 0) or 0), _safe_int(evt.get("sequence", 0) or 0, 0), str(evt.get("event_id", "") or ""), ) ) # Persist first — raises on failure, no in-memory mutation yet self._persist_gate_new_events(gate_id, [clean_event]) # Commit in-memory state only after durable persistence self._gates[gate_id] = staged if event_id: self._event_index[event_id] = clean_event self._remember_replay_fingerprint(replay_fingerprint, clean_event) self._maybe_prune_replay_index() self._change_condition.notify_all() return clean_event def get_messages(self, gate_id: str, limit: int = 20, offset: int = 0) -> list[dict]: messages, _cursor = self.get_messages_with_cursor(gate_id, limit=limit, offset=offset) return messages def get_messages_with_cursor(self, gate_id: str, limit: int = 20, offset: int = 0) -> tuple[list[dict], int]: gate_id = str(gate_id or "").strip().lower() with self._lock: msgs = self._gates.get(gate_id, []) cursor = len(msgs) return list(reversed(msgs))[offset : offset + limit], cursor def gate_cursor(self, gate_id: str) -> int: gate_id = str(gate_id or "").strip().lower() with self._lock: return len(self._gates.get(gate_id, [])) def wait_for_gate_change( self, gate_id: str, after_cursor: int = 0, timeout_s: float = 20.0, ) -> tuple[bool, int]: gate_key = str(gate_id or "").strip().lower() if not gate_key: return False, 0 target_cursor = max(0, _safe_int(after_cursor, 0)) deadline = time.monotonic() + max(0.0, float(timeout_s or 0.0)) with self._lock: current_cursor = len(self._gates.get(gate_key, [])) if current_cursor > target_cursor: return True, current_cursor while True: remaining = deadline - time.monotonic() if remaining <= 0: return False, len(self._gates.get(gate_key, [])) self._change_condition.wait(timeout=remaining) current_cursor = len(self._gates.get(gate_key, [])) if current_cursor > target_cursor: return True, current_cursor def wait_for_any_gate_change( self, gate_cursors: dict[str, int], timeout_s: float = 20.0, ) -> dict[str, int]: normalized = { str(gate_id or "").strip().lower(): max(0, _safe_int(cursor, 0)) for gate_id, cursor in dict(gate_cursors or {}).items() if str(gate_id or "").strip() } if not normalized: return {} deadline = time.monotonic() + max(0.0, float(timeout_s or 0.0)) def _changed() -> dict[str, int]: updates: dict[str, int] = {} for gate_id, after_cursor in normalized.items(): current_cursor = len(self._gates.get(gate_id, [])) if current_cursor > after_cursor: updates[gate_id] = current_cursor return updates with self._lock: updates = _changed() if updates: return updates while True: remaining = deadline - time.monotonic() if remaining <= 0: return {} self._change_condition.wait(timeout=remaining) updates = _changed() if updates: return updates def known_gate_ids(self) -> list[str]: with self._lock: return sorted(self._gates.keys()) def get_event(self, event_id: str) -> dict | None: with self._lock: return self._event_index.get(str(event_id or "")) def stamp_local_plaintext( self, gate_id: str, event_id: str, plaintext: str, reply_to: str = "", ) -> bool: """Stamp decrypted plaintext onto a stored event and re-persist. This is the durable path for the leave/rejoin invariant: once a message is decrypted (by any path — MLS, envelope, self-echo), the plaintext is written into the private chain so it survives restarts and MLS epoch resets. The ``_local_plaintext`` / ``_local_reply_to`` fields are local-only and never transmitted to peers. """ gate_id = str(gate_id or "").strip().lower() event_id = str(event_id or "").strip() if not gate_id or not event_id or not plaintext: return False with self._lock: evt = self._event_index.get(event_id) if evt is None: return False payload = evt.get("payload") if not isinstance(payload, dict): return False if payload.get("_local_plaintext"): return True # already stamped payload["_local_plaintext"] = plaintext if reply_to: payload["_local_reply_to"] = reply_to self._persist_gate(gate_id) return True def lookup_local_plaintext( self, gate_id: str, event_id: str, ) -> tuple[str, str] | None: """Return stamped plaintext for an event, or None.""" event_id = str(event_id or "").strip() if not event_id: return None with self._lock: evt = self._event_index.get(event_id) if evt is None: return None payload = evt.get("payload") if not isinstance(payload, dict): return None pt = payload.get("_local_plaintext") if not isinstance(pt, str) or not pt: return None return pt, str(payload.get("_local_reply_to", "") or "") def ingest_peer_events(self, gate_id: str, events: list[dict]) -> dict: gate_id = str(gate_id or "").strip().lower() duplicates = 0 rejected = 0 if not gate_id: return {"accepted": 0, "duplicates": 0, "rejected": 0} with self._lock: self._gates.setdefault(gate_id, []) # Collect validated candidates without mutating in-memory state candidates: list[tuple[dict, str, str]] = [] # (clean_event, event_id, fingerprint) batch_fingerprints: set[str] = set() batch_event_ids: set[str] = set() for evt in events: if not isinstance(evt, dict): rejected += 1 continue event_id = str(evt.get("event_id", "") or "") payload = evt.get("payload") if not isinstance(payload, dict): rejected += 1 continue if not payload.get("ciphertext"): rejected += 1 continue if evt.get("event_type") != "gate_message": rejected += 1 continue ts = evt.get("timestamp", 0) now = time.time() if not isinstance(ts, (int, float)) or ts > now + 300 or ts < now - 86400 * 30: rejected += 1 continue replay_fingerprint = build_gate_replay_fingerprint(gate_id, evt) if replay_fingerprint in self._replay_index or replay_fingerprint in batch_fingerprints: duplicates += 1 continue if event_id: if len(event_id) != 64: rejected += 1 continue try: int(event_id, 16) except ValueError: rejected += 1 continue else: event_id = "" if event_id and (event_id in self._event_index or event_id in batch_event_ids): duplicates += 1 continue ok, reason, clean_event = _verify_private_gate_transport_event(gate_id, evt) if not ok or clean_event is None: logger.warning("Rejected private gate peer event: %s", reason) rejected += 1 continue event_id = str(clean_event.get("event_id", "") or "") if event_id in self._event_index or event_id in batch_event_ids: duplicates += 1 continue candidates.append((clean_event, event_id, replay_fingerprint)) batch_fingerprints.add(replay_fingerprint) if event_id: batch_event_ids.add(event_id) if not candidates: return {"accepted": 0, "duplicates": duplicates, "rejected": rejected} # Stage: build new gate list without mutating in-memory state staged = list(self._gates[gate_id]) for clean_event, _, _ in candidates: staged.append(clean_event) staged.sort( key=lambda evt: ( float(evt.get("timestamp", 0) or 0), _safe_int(evt.get("sequence", 0) or 0, 0), str(evt.get("event_id", "") or ""), ) ) # Persist first — raises on failure, no in-memory mutation yet self._persist_gate_new_events(gate_id, [clean_event for clean_event, _, _ in candidates]) # Commit in-memory state only after durable persistence self._gates[gate_id] = staged for clean_event, event_id, replay_fingerprint in candidates: if event_id: self._event_index[event_id] = clean_event self._remember_replay_fingerprint(replay_fingerprint, clean_event) self._maybe_prune_replay_index() return {"accepted": len(candidates), "duplicates": duplicates, "rejected": rejected} class ReplayFilter: """Bounded bloom-style replay filter with rotation.""" def __init__( self, *, size_bits: int = REPLAY_FILTER_BITS, hash_count: int = REPLAY_FILTER_HASHES, rotate_s: int = REPLAY_FILTER_ROTATE_S, ) -> None: self._size_bits = max(1024, int(size_bits)) self._hash_count = max(2, int(hash_count)) self._rotate_s = max(60, int(rotate_s)) self._salt = os.urandom(16) self._active = bytearray(self._size_bits // 8 + 1) self._previous = bytearray(self._size_bits // 8 + 1) self._last_rotate = time.time() def _rotate_if_needed(self) -> None: now = time.time() if now - self._last_rotate < self._rotate_s: return self._previous = self._active self._active = bytearray(self._size_bits // 8 + 1) self._last_rotate = now def _positions(self, value: str) -> list[int]: positions: list[int] = [] data = value.encode("utf-8") for idx in range(self._hash_count): digest = hashlib.sha256(self._salt + idx.to_bytes(2, "big") + data).digest() pos = int.from_bytes(digest[:8], "big") % self._size_bits positions.append(pos) return positions def add(self, value: str) -> None: self._rotate_if_needed() for pos in self._positions(value): byte_idx = pos // 8 bit = 1 << (pos % 8) self._active[byte_idx] |= bit def seen(self, value: str) -> bool: self._rotate_if_needed() for pos in self._positions(value): byte_idx = pos // 8 bit = 1 << (pos % 8) if not (self._active[byte_idx] & bit or self._previous[byte_idx] & bit): return False return True class ChainEvent: """Single event on the Infonet.""" __slots__ = ( "event_id", "prev_hash", "event_type", "node_id", "payload", "timestamp", "sequence", "signature", "network_id", "public_key", "public_key_algo", "protocol_version", ) def __init__( self, prev_hash: str, event_type: str, node_id: str, payload: dict, timestamp: float = 0, sequence: int = 0, signature: str = "", network_id: str = "", public_key: str = "", public_key_algo: str = "", protocol_version: str = "", ): self.prev_hash = prev_hash self.event_type = event_type self.node_id = node_id self.payload = payload self.timestamp = timestamp or time.time() self.sequence = sequence self.signature = signature self.network_id = network_id or NETWORK_ID self.public_key = public_key self.public_key_algo = public_key_algo self.protocol_version = protocol_version or PROTOCOL_VERSION # Compute deterministic event ID self.event_id = self._compute_hash() def _compute_hash(self) -> str: """Deterministic SHA-256 hash of the event content.""" content = ( f"{self.prev_hash}:{self.event_type}:{self.node_id}:" f"{json.dumps(self.payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)}:" f"{self.timestamp}:{self.sequence}:{self.network_id}" ) return hashlib.sha256(content.encode("utf-8")).hexdigest() def to_dict(self) -> dict: return { "event_id": self.event_id, "prev_hash": self.prev_hash, "event_type": self.event_type, "node_id": self.node_id, "payload": self.payload, "timestamp": self.timestamp, "sequence": self.sequence, "signature": self.signature, "network_id": self.network_id, "public_key": self.public_key, "public_key_algo": self.public_key_algo, "protocol_version": self.protocol_version, } @classmethod def from_dict(cls, d: dict) -> "ChainEvent": evt = cls( prev_hash=d["prev_hash"], event_type=d["event_type"], node_id=d["node_id"], payload=d["payload"], timestamp=d["timestamp"], sequence=d.get("sequence", 0), signature=d.get("signature", ""), network_id=d.get("network_id", NETWORK_ID), public_key=d.get("public_key", ""), public_key_algo=d.get("public_key_algo", ""), protocol_version=d.get("protocol_version", PROTOCOL_VERSION), ) # Verify hash matches if evt.event_id != d.get("event_id"): raise ValueError( f"Hash mismatch on event load: computed {evt.event_id[:16]}, " f"stored {d.get('event_id', '?')[:16]}" ) return evt class Infonet: """The Infonet — ShadowBroker's append-only signed event ledger. The Infonet is the single source of truth. All actions go through here. The reputation ledger, gates, and oracle are computed views of Infonet state. """ def __init__(self): self.events: list[dict] = [] # Stored as dicts for efficiency self.head_hash: str = GENESIS_HASH # Hash of the latest event self.node_sequences: dict[str, int] = {} # {node_id: last_sequence} self.sequence_domains: dict[str, int] = {} # {node_id|domain: last_sequence} self.event_index: dict[str, int] = {} # {event_id: index in events list} self.public_key_bindings: dict[str, str] = {} # {public_key: canonical node_id} self.revocations: dict[str, dict] = {} self._replay_filter = ReplayFilter() self._last_validated_index: int = 0 # For incremental validation # Running counters — avoid O(N) scans in get_info() self._type_counts: dict[str, int] = {} self._active_count: int = 0 self._registered_nodes: set[str] = set() self._chain_bytes: int = 2 # Start with "[]" empty JSON array self._dirty = False self._save_lock = threading.Lock() self._save_timer: threading.Timer | None = None self._SAVE_INTERVAL = 5.0 # seconds — coalesce writes # Issue #208: Merkle levels cache so get_merkle_proofs() doesn't # rebuild O(n) levels on every public call. Invalidated whenever # self.events mutates. Computed lazily on first read after an # invalidation. self._merkle_levels_cache: list[list[str]] | None = None self._merkle_levels_for_event_count: int = -1 atexit.register(self._flush) self._load() def _invalidate_merkle_cache(self) -> None: """Clear the precomputed Merkle levels. Called whenever ``self.events`` may have mutated (append, rebuild, cleanup, fork resolution). The next call to ``get_merkle_root()`` or ``get_merkle_proofs()`` will recompute and re-cache. """ self._merkle_levels_cache = None self._merkle_levels_for_event_count = -1 def _get_merkle_levels(self) -> list[list[str]]: """Return Merkle levels for the current chain, recomputing if the cache is invalid or out of date. Issue #208: a public endpoint (``/api/mesh/infonet/sync?include_proofs=true``) used to rebuild Merkle levels on every request, which is O(n) in chain length and trivially abusable for CPU exhaustion. By caching the levels and invalidating on mutation, repeated proof requests become O(1) per proof; the rebuild only happens after a genuine append/rebuild/cleanup. """ from services.mesh.mesh_merkle import build_merkle_levels current_count = len(self.events) if ( self._merkle_levels_cache is not None and self._merkle_levels_for_event_count == current_count ): return self._merkle_levels_cache leaves = [e["event_id"] for e in self.events] levels = build_merkle_levels(leaves) self._merkle_levels_cache = levels self._merkle_levels_for_event_count = current_count return levels # ─── Persistence ────────────────────────────────────────────────── def _load(self): """Load Infonet from disk, self-healing on corruption. Sprint 2 / Rec #8: if the chain file or WAL is unreadable we quarantine the bad files, reset to genesis, and let the peer sync worker rebuild state from the network. The user never sees a crashed backend — recovery happens in the background. """ if CHAIN_FILE.exists(): try: data = json.loads(CHAIN_FILE.read_text(encoding="utf-8")) loaded_events = data.get("events", []) if not isinstance(loaded_events, list): raise ValueError("Malformed chain: events must be a list") for evt in loaded_events: if not isinstance(evt, dict): raise ValueError("Malformed chain: event entry must be an object") ChainEvent.from_dict(evt) self.events = loaded_events self.head_hash = data.get("head_hash", GENESIS_HASH) self.node_sequences = data.get("node_sequences", {}) self.sequence_domains = data.get("sequence_domains", {}) self._rebuild_state() self._rebuild_revocations() self._rebuild_counters() logger.info( f"Loaded Infonet: {len(self.events)} events, head={self.head_hash[:16]}..." ) except Exception as e: logger.error("Failed to load Infonet: %s — quarantining and resetting", e) self._quarantine_chain_file(reason=f"load_failed:{e}") self._reset_to_genesis() try: self._replay_wal() except RuntimeError as exc: # WAL quarantine already happened inside _replay_wal — the # chain advances we lost will re-flow from peers. Degraded # state, not a crash. logger.error("[infonet] WAL replay failed, continuing in re-sync mode: %s", exc) self._reset_to_genesis() def _quarantine_chain_file(self, *, reason: str) -> None: """Move a corrupt chain file aside so the next boot starts clean.""" try: if not CHAIN_FILE.exists(): return stamp = int(time.time()) dest = CHAIN_FILE.with_suffix(f".json.quarantine.{stamp}") CHAIN_FILE.rename(dest) logger.error( "[infonet] Chain file quarantined (%s) → %s. Node will re-sync from peers.", reason, dest.name, ) except Exception as exc: logger.error("[infonet] Failed to quarantine chain file: %s", exc) def _reset_to_genesis(self) -> None: """In-memory reset to empty state so peer sync can rebuild.""" self.events = [] self.head_hash = GENESIS_HASH self.node_sequences = {} self.sequence_domains = {} self.event_index = {} self.public_key_bindings = {} self.revocations = {} self._replay_filter = ReplayFilter() self._last_validated_index = 0 self._type_counts = {} self._active_count = 0 self._registered_nodes = set() self._chain_bytes = 2 def _rebuild_state(self) -> None: self.event_index = {} self.node_sequences = {} # Keep private signed-write replay domains across public-chain # rebuilds; these domains protect local side effects that are not # represented as public Infonet events. if not isinstance(getattr(self, "sequence_domains", None), dict): self.sequence_domains = {} self.public_key_bindings = {} self.revocations = {} self._replay_filter = ReplayFilter() for idx, evt in enumerate(self.events): event_id = evt.get("event_id", "") if event_id: self.event_index[event_id] = idx self._replay_filter.add(event_id) node_id = evt.get("node_id", "") sequence = _safe_int(evt.get("sequence", 0) or 0, 0) if node_id and sequence: last = self.node_sequences.get(node_id, 0) if sequence > last: self.node_sequences[node_id] = sequence public_key = str(evt.get("public_key", "") or "") if public_key and node_id: existing = self.public_key_bindings.get(public_key) if not existing: self.public_key_bindings[public_key] = node_id elif existing != node_id: logger.warning( "Public key binding conflict in stored chain for %s: %s vs %s", public_key[:12], _redact_node(existing), _redact_node(node_id), ) if evt.get("event_type") == "key_revoke": self._apply_revocation(evt) if self.events: self.head_hash = self.events[-1].get("event_id", GENESIS_HASH) else: self.head_hash = GENESIS_HASH def _rebuild_counters(self) -> None: """Rebuild running counters from the full event list (called on load).""" now = time.time() self._type_counts = {} self._active_count = 0 self._registered_nodes = set() self._chain_bytes = 2 # "[]" for evt in self.events: t = evt.get("event_type", "unknown") self._type_counts[t] = self._type_counts.get(t, 0) + 1 if t == "node_register": node_id = str(evt.get("node_id", "") or "") if node_id: self._registered_nodes.add(node_id) is_eph = evt.get("payload", {}).get("ephemeral") or evt.get("payload", {}).get("_ephemeral") if not is_eph or (now - evt.get("timestamp", 0)) < EPHEMERAL_TTL: self._active_count += 1 self._chain_bytes += len(json.dumps(evt)) + 2 # +2 for ", " separator def _update_counters_for_event(self, evt: dict) -> None: """Incrementally update counters when a new event is appended.""" t = evt.get("event_type", "unknown") self._type_counts[t] = self._type_counts.get(t, 0) + 1 if t == "node_register": node_id = str(evt.get("node_id", "") or "") if node_id: self._registered_nodes.add(node_id) self._active_count += 1 self._chain_bytes += len(json.dumps(evt)) + 2 def _write_wal(self, event_dict: dict) -> None: try: DATA_DIR.mkdir(parents=True, exist_ok=True) _atomic_write_text(WAL_FILE, json.dumps({"event": event_dict}), encoding="utf-8") except Exception as e: logger.error(f"Failed to write WAL: {e}") def _clear_wal(self) -> None: try: if WAL_FILE.exists(): WAL_FILE.unlink() except Exception as e: logger.error(f"Failed to clear WAL: {e}") def _replay_wal(self) -> None: """Replay any surviving WAL entry after a crash, fail-closed. Sprint 2 / Rec #8: a corrupt or unreplayable WAL means the node crashed mid-append — we do NOT silently discard it. Instead we quarantine the WAL file, log loudly, and raise so the caller (__init__ via _load) can surface the degraded state rather than pretending the chain is healthy. The user never gets a silent data-loss window. """ if not WAL_FILE.exists(): return try: data = json.loads(WAL_FILE.read_text(encoding="utf-8")) except Exception as exc: self._quarantine_wal(f"corrupt_json:{exc}") raise RuntimeError( "Infonet WAL is corrupt — quarantined. Chain is in a degraded state; " "recover by re-syncing from peers." ) from exc evt = data.get("event") if isinstance(data, dict) else None if not isinstance(evt, dict): self._quarantine_wal("malformed_shape") raise RuntimeError( "Infonet WAL shape invalid — quarantined. Chain is in a degraded state." ) if evt.get("event_id") in self.event_index: # Already durable in the chain file — WAL is stale but safe. self._clear_wal() return if evt.get("prev_hash") != self.head_hash: # The WAL entry is for an older head — the chain advanced past # it through another path. Safe to drop. self._clear_wal() return try: result = self.ingest_events([evt]) except Exception as exc: self._quarantine_wal(f"replay_raised:{exc}") raise RuntimeError( "Infonet WAL event failed to replay — quarantined. Recover by re-syncing." ) from exc if not result.get("accepted"): self._quarantine_wal("replay_rejected") raise RuntimeError( f"Infonet WAL event rejected on replay: {result.get('rejected') or 'unknown'}" ) logger.info("Replayed WAL event after restart") # Force a synchronous flush so the replayed event is durable # before we hand control back to the rest of the boot sequence. # _flush() clears the WAL as part of a successful write. self._flush() def _quarantine_wal(self, reason: str) -> None: """Move a bad WAL file aside so subsequent boots don't loop on it.""" try: if not WAL_FILE.exists(): return stamp = int(time.time()) dest = WAL_FILE.with_suffix(f".wal.quarantine.{stamp}") WAL_FILE.rename(dest) logger.error( "[infonet] WAL quarantined (%s) → %s. Node is degraded until re-sync.", reason, dest.name, ) except Exception as exc: logger.error("[infonet] Failed to quarantine WAL: %s", exc) def reset_chain(self) -> None: """Wipe local chain state so the next sync starts from genesis. Used for automatic fork recovery when the local chain is small and has diverged from the network. Does NOT touch gate_store or WAL. """ prev_len = len(self.events) self.events = [] self.head_hash = GENESIS_HASH self.node_sequences = {} self.sequence_domains = {} self.event_index = {} self.public_key_bindings = {} self.revocations = {} self._replay_filter = ReplayFilter() self._last_validated_index = 0 self._type_counts = {} self._active_count = 0 self._chain_bytes = 2 self._dirty = True self._flush() logger.warning("Chain reset: discarded %d local events for fork recovery", prev_len) def _save(self): """Mark dirty and schedule a coalesced disk write. Instead of writing multi-MB JSON on every event, we set a dirty flag and schedule a single write after _SAVE_INTERVAL seconds. Multiple rapid calls collapse into one I/O operation. """ self._dirty = True with self._save_lock: if self._save_timer is None or not self._save_timer.is_alive(): self._save_timer = threading.Timer(self._SAVE_INTERVAL, self._flush) self._save_timer.daemon = True self._save_timer.start() def _flush(self): """Actually write to disk (called by timer or atexit). Sprint 2 / Rec #8: clears the WAL only after the chain file has been durably written. A crash before _flush() succeeds leaves the WAL in place so _replay_wal() can recover on next boot. """ if not self._dirty: return try: DATA_DIR.mkdir(parents=True, exist_ok=True) data = { "protocol": "infonet", "network_id": NETWORK_ID, "head_hash": self.head_hash, "node_sequences": self.node_sequences, "sequence_domains": self.sequence_domains, "events": self.events, } _atomic_write_text(CHAIN_FILE, json.dumps(data, indent=2), encoding="utf-8") self._dirty = False # Chain file is now durable — safe to retire the WAL entry. self._clear_wal() except Exception as e: logger.error(f"Failed to save Infonet: {e}") def ensure_materialized(self) -> None: """Write the current chain state to disk even if nothing is dirty yet.""" try: DATA_DIR.mkdir(parents=True, exist_ok=True) data = { "protocol": "infonet", "network_id": NETWORK_ID, "head_hash": self.head_hash, "node_sequences": self.node_sequences, "sequence_domains": self.sequence_domains, "events": self.events, } _atomic_write_text(CHAIN_FILE, json.dumps(data, indent=2), encoding="utf-8") except Exception as e: logger.error(f"Failed to materialize Infonet: {e}") raise def confirmations_for_event(self, event_id: str) -> int: idx = self.event_index.get(event_id) if idx is None: return 0 return max(0, len(self.events) - 1 - idx) def decorate_event(self, evt: dict) -> dict: if evt.get("event_type") not in CRITICAL_EVENT_TYPES: return evt confirmations = self.confirmations_for_event(evt.get("event_id", "")) decorated = dict(evt) decorated["confirmations"] = confirmations decorated["confirmed"] = confirmations >= MIN_CONFIRMATIONS_CRITICAL return decorated def decorate_events(self, events: list[dict]) -> list[dict]: return [self.decorate_event(evt) for evt in events] def chain_lock(self) -> dict: if len(self.events) <= CHAIN_LOCK_DEPTH: return {"depth": CHAIN_LOCK_DEPTH, "event_id": "", "active": False} idx = max(0, len(self.events) - 1 - CHAIN_LOCK_DEPTH) return { "depth": CHAIN_LOCK_DEPTH, "event_id": self.events[idx].get("event_id", ""), "active": True, } def _bind_public_key(self, public_key: str, node_id: str) -> tuple[bool, str]: key = str(public_key or "") node = str(node_id or "") if not key or not node: return False, "Missing public key binding fields" existing = self.public_key_bindings.get(key) if existing and existing != node: return False, f"public key already bound to {existing}" self.public_key_bindings[key] = node return True, "ok" def _apply_revocation(self, evt: dict) -> None: payload = evt.get("payload", {}) public_key = payload.get("revoked_public_key") or evt.get("public_key", "") if not public_key: return revoked_at = _safe_int(payload.get("revoked_at", 0) or 0, 0) grace_until = _safe_int(payload.get("grace_until", revoked_at) or revoked_at, revoked_at) info = { "public_key": public_key, "public_key_algo": payload.get("revoked_public_key_algo") or evt.get("public_key_algo"), "revoked_at": revoked_at, "grace_until": grace_until, "reason": payload.get("reason", ""), "event_id": evt.get("event_id", ""), "node_id": evt.get("node_id", ""), } existing = self.revocations.get(public_key) if not existing or revoked_at >= _safe_int(existing.get("revoked_at", 0), 0): self.revocations[public_key] = info def _rebuild_revocations(self) -> None: self.revocations = {} for evt in self.events: if evt.get("event_type") == "key_revoke": self._apply_revocation(evt) def _revocation_status(self, public_key: str) -> tuple[bool, dict | None]: info = self.revocations.get(public_key) if not info: return False, None now = time.time() if now > _safe_int(info.get("grace_until", 0) or 0, 0): return True, info return False, info # ─── Append ─────────────────────────────────────────────────────── def validate_and_set_sequence( self, node_id: str, sequence: int, *, domain: str = "", ) -> tuple[bool, str]: """Validate monotonic sequence and update last-seen value if valid.""" if sequence <= 0: return False, "Sequence must be a positive integer" normalized_domain = str(domain or "").strip().lower() table = self.sequence_domains if normalized_domain else self.node_sequences key = f"{node_id}|{normalized_domain}" if normalized_domain else node_id last = table.get(key, 0) if sequence <= last: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("replay_attempts") return False, f"Replay detected: sequence {sequence} <= last {last}" table[key] = sequence self._save() return True, "ok" def append( self, event_type: str, node_id: str, payload: dict, signature: str = "", sequence: int = 0, ephemeral: bool = False, public_key: str = "", public_key_algo: str = "", protocol_version: str = "", timestamp_bucket_s: int = 0, ) -> dict: """Append a new event to the Infonet. Returns the event dict. Args: event_type: Type of event (message, vote, gate_create, etc.) node_id: Author node ID payload: Event-specific data signature: Cryptographic signature from node's private key ephemeral: If True, event auto-purges after 24h Returns: The event dict with computed event_id """ from services.mesh.mesh_crypto import ( build_signature_payload, parse_public_key_algo, verify_node_binding, verify_signature, ) if event_type not in ACTIVE_APPEND_EVENT_TYPES: raise ValueError(f"Unsupported event_type: {event_type}") if sequence <= 0: raise ValueError("sequence is required and must be > 0") last = self.node_sequences.get(node_id, 0) if sequence <= last: raise ValueError(f"Replay detected: sequence {sequence} <= last {last}") payload = normalize_payload(event_type, dict(payload or {})) ok, reason = validate_event_payload(event_type, payload) if not ok: raise ValueError(reason) ok, reason = validate_public_ledger_payload(event_type, payload) if not ok: raise ValueError(reason) if event_type == "message": if "ephemeral" not in payload: payload["ephemeral"] = bool(ephemeral) else: payload.pop("ephemeral", None) payload_json = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) if len(payload_json.encode("utf-8")) > MAX_PAYLOAD_BYTES: raise ValueError("payload exceeds max size") protocol_version = str(protocol_version or PROTOCOL_VERSION) ok, reason = validate_protocol_fields(protocol_version, NETWORK_ID) if not ok: raise ValueError(reason) if not (signature and public_key and public_key_algo): raise ValueError("Missing signature fields") if not parse_public_key_algo(public_key_algo): raise ValueError("Unsupported public_key_algo") if not verify_node_binding(node_id, public_key): raise ValueError("node_id mismatch") bound, bind_reason = self._bind_public_key(public_key, node_id) if not bound: raise ValueError(bind_reason) sig_payload = build_signature_payload( event_type=event_type, node_id=node_id, sequence=sequence, payload=payload, ) if not verify_signature( public_key_b64=public_key, public_key_algo=public_key_algo, signature_hex=signature, payload=sig_payload, ): raise ValueError("Invalid signature") if public_key: revoked, _info = self._revocation_status(public_key) if revoked and event_type != "key_revoke": raise ValueError("public key is revoked") if event_type == "key_revoke": if payload.get("revoked_public_key") and payload.get("revoked_public_key") != public_key: raise ValueError("revoked_public_key must match event public_key") if payload.get("revoked_public_key_algo") and payload.get( "revoked_public_key_algo" ) != public_key_algo: raise ValueError("revoked_public_key_algo must match event public_key_algo") if timestamp_bucket_s > 0: ts = time.time() ts = float(int(ts / timestamp_bucket_s) * timestamp_bucket_s) else: ts = time.time() # Create event event = ChainEvent( prev_hash=self.head_hash, event_type=event_type, node_id=node_id, payload=payload, timestamp=ts, sequence=sequence, signature=signature, public_key=public_key, public_key_algo=public_key_algo, protocol_version=protocol_version, ) event_dict = event.to_dict() self._write_wal(event_dict) self.events.append(event_dict) self.event_index[event.event_id] = len(self.events) - 1 self.head_hash = event.event_id self.node_sequences[node_id] = sequence self._replay_filter.add(event.event_id) # Issue #208: chain advanced, cached Merkle levels are stale. self._invalidate_merkle_cache() self._update_counters_for_event(event_dict) if event_type == "key_revoke": self._apply_revocation(event_dict) # Sprint 2 / Rec #8: do NOT clear the WAL here. _save() only # schedules a coalesced flush; clearing now would open a crash # window where the event is gone from the WAL but not yet in # the chain file. _flush() clears the WAL only after a # successful durable write. self._save() try: from services.mesh.mesh_rns import rns_bridge rns_bridge.publish_event(event_dict) except Exception: pass _notify_public_event_append_hooks(event_dict) logger.info( f"Infonet append [{event_type}] by {_redact_node(node_id)} seq={sequence} " f"id={event.event_id[:16]}..." ) return event_dict def ingest_events(self, events: list[dict]) -> dict: """Ingest a sequence of external events. Requires contiguous prev_hash.""" accepted = 0 duplicates = 0 rejected: list[dict] = [] expected_prev = self.head_hash for idx, evt in enumerate(events): if not isinstance(evt, dict): rejected.append({"index": idx, "reason": "Event is not an object"}) continue event_type = evt.get("event_type", "") node_id = evt.get("node_id", "") event_id = evt.get("event_id", "") prev_hash = evt.get("prev_hash", "") sequence = _safe_int(evt.get("sequence", 0) or 0, 0) if event_type not in ALLOWED_EVENT_TYPES: rejected.append({"index": idx, "reason": "Unsupported event_type"}) continue if not event_id or not prev_hash: rejected.append({"index": idx, "reason": "Missing event_id or prev_hash"}) continue if prev_hash != expected_prev: try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("ingest_prev_hash_mismatch") except Exception: pass rejected.append({"index": idx, "reason": "prev_hash does not match head"}) continue if evt.get("network_id") != NETWORK_ID: try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("ingest_network_mismatch") except Exception: pass rejected.append({"index": idx, "reason": "network_id mismatch"}) continue if event_id in self.event_index: duplicates += 1 continue if self._replay_filter.seen(event_id): try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("ingest_replay_seen") except Exception: pass duplicates += 1 continue if prev_hash != self.head_hash: rejected.append({"index": idx, "reason": "prev_hash does not match head"}) continue if sequence <= 0: rejected.append({"index": idx, "reason": "Invalid sequence"}) continue last = self.node_sequences.get(node_id, 0) if sequence <= last: rejected.append({"index": idx, "reason": "Replay detected"}) continue # Hardening Rec #8: timestamp freshness bound. The sequence check # above catches replays once a node has observed the author, but # a fresh peer (node_sequences[node_id] == 0) accepts any # sequence > 0 — so an attacker could replay an ancient signed # event into a node that's never seen the author. Rejecting # events whose timestamp is outside a bounded freshness window # closes that hole without breaking catch-up sync for # short-lived network partitions. try: from services.mesh.mesh_rollout_flags import ingest_event_max_age_s max_age_s = int(ingest_event_max_age_s() or 0) except Exception: max_age_s = 0 if max_age_s > 0: evt_ts = _safe_int(evt.get("timestamp", 0) or 0, 0) if evt_ts > 0 and abs(int(time.time()) - evt_ts) > max_age_s: try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("ingest_timestamp_stale") except Exception: pass rejected.append({"index": idx, "reason": "Event timestamp outside freshness window"}) continue payload = evt.get("payload", {}) ok, reason = validate_event_payload(event_type, payload) if not ok: rejected.append({"index": idx, "reason": reason}) continue ok, reason = validate_public_ledger_payload(event_type, payload) if not ok: rejected.append({"index": idx, "reason": reason}) continue if event_type == "message" and "ephemeral" not in payload: rejected.append({"index": idx, "reason": "Missing ephemeral flag"}) continue payload_json = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) if len(payload_json.encode("utf-8")) > MAX_PAYLOAD_BYTES: try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("ingest_payload_too_large") except Exception: pass rejected.append({"index": idx, "reason": "Payload too large"}) continue proto = evt.get("protocol_version") or PROTOCOL_VERSION if proto != PROTOCOL_VERSION: try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("ingest_proto_mismatch") except Exception: pass rejected.append({"index": idx, "reason": "Unsupported protocol_version"}) continue signature = evt.get("signature", "") public_key = evt.get("public_key", "") public_key_algo = evt.get("public_key_algo", "") if not (signature and public_key and public_key_algo): try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("ingest_signature_missing") except Exception: pass rejected.append({"index": idx, "reason": "Missing signature fields"}) continue from services.mesh.mesh_crypto import parse_public_key_algo if not parse_public_key_algo(public_key_algo): rejected.append({"index": idx, "reason": "Unsupported public_key_algo"}) continue if event_type == "key_revoke": if payload.get("revoked_public_key") and payload.get( "revoked_public_key" ) != public_key: rejected.append( {"index": idx, "reason": "revoked_public_key must match public_key"} ) continue if payload.get("revoked_public_key_algo") and payload.get( "revoked_public_key_algo" ) != public_key_algo: rejected.append( { "index": idx, "reason": "revoked_public_key_algo must match public_key_algo", } ) continue revoked, _info = self._revocation_status(public_key) if revoked and event_type != "key_revoke": try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("ingest_key_revoked") except Exception: pass rejected.append({"index": idx, "reason": "public key is revoked"}) continue last_seq = self.node_sequences.get(node_id, 0) if sequence <= last_seq: try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("ingest_replay_sequence") except Exception: pass rejected.append( { "index": idx, "reason": f"Replay detected: sequence {sequence} <= last {last_seq}", } ) continue from services.mesh.mesh_crypto import ( build_signature_payload, verify_signature, verify_node_binding, ) if not verify_node_binding(node_id, public_key): try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("ingest_node_mismatch") except Exception: pass rejected.append({"index": idx, "reason": "node_id mismatch"}) continue bound, bind_reason = self._bind_public_key(public_key, node_id) if not bound: rejected.append({"index": idx, "reason": bind_reason}) continue sig_payload = build_signature_payload( event_type=event_type, node_id=node_id, sequence=sequence, payload=payload, ) if not verify_signature( public_key_b64=public_key, public_key_algo=public_key_algo, signature_hex=signature, payload=sig_payload, ): try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("ingest_signature_invalid") except Exception: pass rejected.append({"index": idx, "reason": "Invalid signature"}) continue # Verify event_id/hash linkage try: computed = ChainEvent.from_dict(evt).event_id except (ValueError, KeyError, TypeError) as exc: rejected.append({"index": idx, "reason": f"event_id hash mismatch: {exc}"}) continue if computed != event_id: try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("ingest_event_id_mismatch") except Exception: pass rejected.append({"index": idx, "reason": "event_id mismatch"}) continue # Accept self.events.append(evt) self.event_index[event_id] = len(self.events) - 1 self.head_hash = event_id self.node_sequences[node_id] = sequence self._update_counters_for_event(evt) accepted += 1 expected_prev = event_id self._replay_filter.add(event_id) if event_type == "key_revoke": self._apply_revocation(evt) if accepted: # Issue #208: any accepted event invalidates the cached Merkle # levels. One invalidation per batch, not per event. self._invalidate_merkle_cache() self._save() return {"accepted": accepted, "duplicates": duplicates, "rejected": rejected} # ─── Validation ─────────────────────────────────────────────────── def validate_chain(self, verify_signatures: bool = False) -> tuple[bool, str]: """Verify the entire Infonet's integrity. Checks that each event's prev_hash matches the previous event's event_id, and that each event's hash is correct. Returns (valid, reason) """ if not self.events: return True, "Empty chain" prev = GENESIS_HASH seen_public_keys: dict[str, str] = {} for i, evt_dict in enumerate(self.events): # Check prev_hash linkage if evt_dict["prev_hash"] != prev: return False, ( f"Broken link at index {i}: expected prev_hash " f"{prev[:16]}..., got {evt_dict['prev_hash'][:16]}..." ) # Recompute hash and verify evt = ChainEvent.from_dict(evt_dict) if evt.event_id != evt_dict["event_id"]: return False, ( f"Hash mismatch at index {i}: computed " f"{evt.event_id[:16]}..., stored {evt_dict['event_id'][:16]}..." ) if verify_signatures: proto = evt_dict.get("protocol_version") or PROTOCOL_VERSION if proto != PROTOCOL_VERSION: return False, f"Unsupported protocol_version at index {i}: {proto}" signature = evt_dict.get("signature", "") public_key = evt_dict.get("public_key", "") public_key_algo = evt_dict.get("public_key_algo", "") if not (signature and public_key and public_key_algo): return False, f"Missing signature fields at index {i}" from services.mesh.mesh_crypto import ( build_signature_payload, parse_public_key_algo, verify_signature, verify_node_binding, ) node_id = evt_dict.get("node_id", "") if not parse_public_key_algo(public_key_algo): return False, f"Unsupported public_key_algo at index {i}" if not verify_node_binding(node_id, public_key): return False, f"node_id mismatch at index {i}" existing = seen_public_keys.get(public_key) if existing and existing != node_id: return False, f"public key binding conflict at index {i}" seen_public_keys[public_key] = node_id normalized = normalize_payload( evt_dict.get("event_type", ""), evt_dict.get("payload", {}) ) sig_payload = build_signature_payload( event_type=evt_dict.get("event_type", ""), node_id=node_id, sequence=_safe_int(evt_dict.get("sequence", 0) or 0, 0), payload=normalized, ) if not verify_signature( public_key_b64=public_key, public_key_algo=public_key_algo, signature_hex=signature, payload=sig_payload, ): return False, f"Invalid signature at index {i}" prev = evt_dict["event_id"] if prev != self.head_hash: return ( False, f"Head hash mismatch: chain ends at {prev[:16]}... but head is {self.head_hash[:16]}...", ) return True, f"Valid Infonet: {len(self.events)} events" def validate_chain_incremental(self, verify_signatures: bool = False) -> tuple[bool, str]: """Validate only events appended since last successful validation. Much faster than full validate_chain() on large chains — O(new) vs O(N). Falls back to full validation if the chain has been restructured. """ total = len(self.events) start = self._last_validated_index if start > total: # Chain was truncated (fork resolution) — fall back to full self._last_validated_index = 0 return self.validate_chain(verify_signatures=verify_signatures) if start >= total: return True, f"No new events (chain has {total} events)" # Determine expected prev_hash at the start index if start == 0: prev = GENESIS_HASH else: prev = self.events[start - 1]["event_id"] for i in range(start, total): evt_dict = self.events[i] if evt_dict["prev_hash"] != prev: return False, ( f"Broken link at index {i}: expected prev_hash " f"{prev[:16]}..., got {evt_dict['prev_hash'][:16]}..." ) evt = ChainEvent.from_dict(evt_dict) if evt.event_id != evt_dict["event_id"]: return False, ( f"Hash mismatch at index {i}: computed " f"{evt.event_id[:16]}..., stored {evt_dict['event_id'][:16]}..." ) if verify_signatures: proto = evt_dict.get("protocol_version") or PROTOCOL_VERSION if proto != PROTOCOL_VERSION: return False, f"Unsupported protocol_version at index {i}: {proto}" signature = evt_dict.get("signature", "") public_key = evt_dict.get("public_key", "") public_key_algo = evt_dict.get("public_key_algo", "") if not (signature and public_key and public_key_algo): return False, f"Missing signature fields at index {i}" from services.mesh.mesh_crypto import ( build_signature_payload, parse_public_key_algo, verify_signature, verify_node_binding, ) node_id = evt_dict.get("node_id", "") if not parse_public_key_algo(public_key_algo): return False, f"Unsupported public_key_algo at index {i}" if not verify_node_binding(node_id, public_key): return False, f"node_id mismatch at index {i}" normalized = normalize_payload( evt_dict.get("event_type", ""), evt_dict.get("payload", {}) ) sig_payload = build_signature_payload( event_type=evt_dict.get("event_type", ""), node_id=node_id, sequence=_safe_int(evt_dict.get("sequence", 0) or 0, 0), payload=normalized, ) if not verify_signature( public_key_b64=public_key, public_key_algo=public_key_algo, signature_hex=signature, payload=sig_payload, ): return False, f"Invalid signature at index {i}" prev = evt_dict["event_id"] if prev != self.head_hash: return False, ( f"Head hash mismatch: chain ends at {prev[:16]}... but head is {self.head_hash[:16]}..." ) self._last_validated_index = total return True, f"Valid Infonet: {total} events ({total - start} new)" def _order_chain_from(self, prev_hash: str, events: list[dict]) -> list[dict] | None: by_prev: dict[str, dict] = {} for evt in events: p = evt.get("prev_hash", "") if not p: return None if p in by_prev: return None by_prev[p] = evt ordered = [] current = prev_hash while current in by_prev: evt = by_prev[current] ordered.append(evt) current = evt.get("event_id", "") if not current: return None if len(ordered) != len(events): return None return ordered def apply_fork(self, events: list[dict], head_hash: str, proof_count: int, quorum: int) -> tuple[bool, str]: if not events: return False, "empty fork" if proof_count < max(2, int(quorum)): return False, "insufficient quorum" prev_hash = events[0].get("prev_hash", "") if not prev_hash: return False, "missing prev_hash" prev_index = self.event_index.get(prev_hash) if prev_index is None: return False, "unknown ancestor" depth_from_head = len(self.events) - 1 - prev_index if depth_from_head > CHAIN_LOCK_DEPTH: return False, "chain lock prevents reorg" ordered = self._order_chain_from(prev_hash, events) if not ordered: return False, "non-contiguous fork" if ordered[-1].get("event_id", "") != head_hash: return False, "head_hash mismatch" current_tail_len = len(self.events) - 1 - prev_index if len(ordered) <= current_tail_len: return False, "fork not longer" # Validate events and sequences against prefix prefix = self.events[: prev_index + 1] last_seq: dict[str, int] = {} seen_public_keys: dict[str, str] = {} for evt in prefix: node_id = evt.get("node_id", "") sequence = _safe_int(evt.get("sequence", 0) or 0, 0) if node_id and sequence: last_seq[node_id] = max(last_seq.get(node_id, 0), sequence) public_key = str(evt.get("public_key", "") or "") if public_key and node_id: seen_public_keys.setdefault(public_key, node_id) for evt in ordered: event_type = evt.get("event_type", "") node_id = evt.get("node_id", "") event_id = evt.get("event_id", "") sequence = _safe_int(evt.get("sequence", 0) or 0, 0) payload = evt.get("payload", {}) if event_type not in ALLOWED_EVENT_TYPES: return False, "unsupported event_type" if not event_id or not node_id: return False, "missing fields" if evt.get("network_id") != NETWORK_ID: return False, "network mismatch" existing_idx = self.event_index.get(event_id) if existing_idx is not None and existing_idx <= prev_index: return False, "duplicate event_id" payload = normalize_payload(event_type, dict(payload or {})) ok, reason = validate_event_payload(event_type, payload) if not ok: return False, reason proto = evt.get("protocol_version") or PROTOCOL_VERSION if proto != PROTOCOL_VERSION: return False, "unsupported protocol_version" signature = evt.get("signature", "") public_key = evt.get("public_key", "") public_key_algo = evt.get("public_key_algo", "") if not (signature and public_key and public_key_algo): return False, "missing signature fields" revoked, _info = self._revocation_status(public_key) if revoked and event_type != "key_revoke": return False, "public key revoked" last = last_seq.get(node_id, 0) if sequence <= last: return False, "sequence replay" from services.mesh.mesh_crypto import ( build_signature_payload, parse_public_key_algo, verify_signature, verify_node_binding, ) if not parse_public_key_algo(public_key_algo): return False, "unsupported public_key_algo" if not verify_node_binding(node_id, public_key): return False, "node_id mismatch" existing = seen_public_keys.get(public_key) if existing and existing != node_id: return False, "public key binding conflict" seen_public_keys[public_key] = node_id sig_payload = build_signature_payload( event_type=event_type, node_id=node_id, sequence=sequence, payload=payload, ) if not verify_signature( public_key_b64=public_key, public_key_algo=public_key_algo, signature_hex=signature, payload=sig_payload, ): return False, "invalid signature" computed = ChainEvent.from_dict(evt).event_id if computed != event_id: return False, "event_id mismatch" last_seq[node_id] = sequence # Apply fork self.events = prefix + ordered self._rebuild_state() self._rebuild_revocations() self._rebuild_counters() # Issue #208: chain replaced, cached Merkle levels are stale. self._invalidate_merkle_cache() self._save() try: from services.mesh.mesh_metrics import increment as metrics_inc metrics_inc("fork_applied") except Exception: pass return True, "applied" def check_replay(self, node_id: str, sequence: int) -> bool: """Check if a sequence number has already been used by this node. Returns True if this is a REPLAY (bad), False if fresh (good). """ return sequence <= self.node_sequences.get(node_id, 0) # ─── Queries ────────────────────────────────────────────────────── def get_event(self, event_id: str) -> dict | None: """Look up a single event by ID.""" idx = self.event_index.get(event_id) if idx is not None and idx < len(self.events): return self.events[idx] return None def annotate_event(self, event_id: str, meta: dict) -> bool: """Attach non-consensus metadata to an event (not part of hash).""" idx = self.event_index.get(event_id) if idx is None or idx >= len(self.events): return False self.events[idx]["meta"] = meta self._save() return True def get_events_by_type( self, event_type: str, limit: int = 50, offset: int = 0, ) -> list[dict]: """Get recent events of a specific type (newest first).""" matching = [] for e in reversed(self.events): if e["event_type"] != event_type: continue matching.append(e) return matching[offset : offset + limit] def get_events_by_node(self, node_id: str, limit: int = 50) -> list[dict]: """Get recent events by a specific node (newest first).""" matching = [] for e in reversed(self.events): if e["node_id"] != node_id: continue matching.append(e) return matching[:limit] def get_messages( self, gate_id: str = "", limit: int = 50, offset: int = 0, ) -> list[dict]: """Get messages, optionally filtered by gate. Returns public-plane 'message' events only. Returns newest first with message-specific fields extracted. """ results = [] for evt in reversed(self.events): if evt["event_type"] != "message": continue payload = evt.get("payload", {}) # Skip ephemeral messages that have expired if payload.get("ephemeral") or payload.get("_ephemeral"): age = time.time() - evt["timestamp"] if age > EPHEMERAL_TTL: continue # Gate filter msg_gate = payload.get("gate", "") if gate_id and msg_gate != gate_id: continue # Skip transport-routed messages (Meshtastic/APRS) from InfoNet feed — # they belong in their own tab. Only direct InfoNet/gate posts appear here. meta = evt.get("meta", {}) if payload.get("routed_via") or meta.get("routed_via"): continue results.append( { "event_id": evt["event_id"], "event_type": evt.get("event_type", ""), "node_id": evt["node_id"], "message": payload.get("message", payload.get("text", "")), "ciphertext": payload.get("ciphertext", ""), "epoch": payload.get("epoch", 0), "nonce": payload.get("nonce", payload.get("iv", "")), "sender_ref": payload.get("sender_ref", ""), "format": payload.get("format", ""), "destination": payload.get("destination", "broadcast"), "channel": payload.get("channel", "LongFast"), "priority": payload.get("priority", "normal"), "gate": msg_gate, "timestamp": evt["timestamp"], "sequence": evt.get("sequence", 0), "ephemeral": payload.get("ephemeral", payload.get("_ephemeral", False)), "signature": evt.get("signature", ""), "public_key": evt.get("public_key", ""), "public_key_algo": evt.get("public_key_algo", ""), "protocol_version": evt.get("protocol_version", ""), } ) if len(results) >= offset + limit: break return results[offset : offset + limit] def get_info(self) -> dict: """Infonet metadata for status display. O(1) via running counters.""" return { "protocol": "infonet", "network_id": NETWORK_ID, "total_events": len(self.events), "active_events": self._active_count, "head_hash": self.head_hash[:16] + "...", "head_hash_full": self.head_hash, "chain_lock": self.chain_lock(), "known_nodes": len(self.node_sequences), "author_nodes": len(self.node_sequences), "registered_nodes": len(self._registered_nodes), "event_types": dict(self._type_counts), "chain_size_kb": round(self._chain_bytes / 1024, 1), "unsigned_events": 0, } # ─── Cleanup ────────────────────────────────────────────────────── def cleanup(self): """Remove expired ephemeral events and old events beyond retention window. Note: This breaks the chain linkage for removed events, so we only remove from the beginning (oldest events). The chain remains valid from the first remaining event forward. """ now = time.time() retention_cutoff = now - (MESSAGE_RETENTION_DAYS * 86400) before = len(self.events) # Remove events that are both old AND ephemeral-expired new_events = [] for evt in self.events: payload = evt.get("payload", {}) is_ephemeral = payload.get("ephemeral", payload.get("_ephemeral", False)) age = now - evt["timestamp"] # Keep if: not ephemeral-expired AND within retention window if is_ephemeral and age > EPHEMERAL_TTL: continue # Expired ephemeral — drop if evt["timestamp"] < retention_cutoff and is_ephemeral: continue # Old ephemeral — drop new_events.append(evt) if len(new_events) != before: self.events = new_events self._rebuild_state() self._rebuild_revocations() self._rebuild_counters() # Issue #208: cleanup may have dropped expired events. self._invalidate_merkle_cache() self._save() logger.info(f"Infonet cleanup: removed {before - len(new_events)} expired events") # ─── Gossip Sync (Future) ──────────────────────────────────────── def get_merkle_root(self) -> str: """Compute a Merkle root hash of the Infonet for sync comparison. Two nodes with the same Merkle root have identical chains. Reads from the cached Merkle levels (issue #208) — O(1) when the chain hasn't changed since the last computation. """ if not self.events: return GENESIS_HASH levels = self._get_merkle_levels() if not levels or not levels[-1]: return GENESIS_HASH return levels[-1][0] or GENESIS_HASH def get_merkle_proofs(self, start_index: int, count: int) -> dict: """Return merkle proofs for a contiguous range of events. Issue #208: uses the cached Merkle levels so this is O(count * log n) per request, not O(n + count * log n). Anonymous peers hitting ``/api/mesh/infonet/sync?include_proofs=true`` no longer force a rebuild on every call. """ total = len(self.events) if total == 0: return {"root": GENESIS_HASH, "total": 0, "start": 0, "proofs": []} from services.mesh.mesh_merkle import merkle_proof_from_levels leaves = [e["event_id"] for e in self.events] start = max(0, start_index) end = min(total, start + max(0, count)) levels = self._get_merkle_levels() root = levels[-1][0] if levels and levels[-1] else GENESIS_HASH proofs = [] for idx in range(start, end): proofs.append( { "index": idx, "leaf": leaves[idx], "proof": merkle_proof_from_levels(levels, idx), } ) return {"root": root, "total": total, "start": start, "proofs": proofs} def get_locator(self, max_entries: int = 32) -> list[str]: """Build a block locator for fork-aware sync.""" if not self.events: return [GENESIS_HASH] locator: list[str] = [] idx = len(self.events) - 1 step = 1 count = 0 while idx >= 0 and len(locator) < max_entries - 1: locator.append(self.events[idx]["event_id"]) if count >= 9: step *= 2 idx -= step count += 1 locator.append(GENESIS_HASH) return locator def get_events_after_locator( self, locator: list[str], limit: int = 100 ) -> tuple[str, int, list[dict]]: """Find a common ancestor in the locator and return events after it.""" if not locator: locator = [GENESIS_HASH] for hsh in locator: if hsh == GENESIS_HASH: return GENESIS_HASH, 0, self.events[:limit] idx = self.event_index.get(hsh) if idx is not None: start = idx + 1 return hsh, start, self.events[start : start + limit] return "", -1, [] def get_events_after(self, after_hash: str, limit: int = 100) -> list[dict]: """Get events after a given hash (for delta sync). If after_hash is GENESIS_HASH, returns from the beginning. """ if after_hash == GENESIS_HASH: return self.events[:limit] # Find the event with this hash idx = self.event_index.get(after_hash) if idx is None: return [] # Hash not found — full sync needed return self.events[idx + 1 : idx + 1 + limit] # ─── Module-level singleton ───────────────────────────────────────────── infonet = Infonet() gate_store = GateMessageStore(data_dir=str(GATE_STORE_DIR)) # Backwards-compatible alias so existing imports don't break hashchain = infonet