Files
Shadowbroker/backend/services/mesh/mesh_hashchain.py
T
2026-05-01 22:56:50 -06:00

2826 lines
115 KiB
Python

"""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.config import get_settings
from services.mesh.mesh_crypto import _derive_peer_key, normalize_peer_url
secret = str(get_settings().MESH_PEER_PUSH_SECRET or "").strip()
except Exception:
return b""
if not secret:
return b""
normalized = normalize_peer_url(peer_url or "")
if not normalized:
return b""
peer_key = _derive_peer_key(secret, 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._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
atexit.register(self._flush)
self._load()
# ─── 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._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._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
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
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)
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
accepted += 1
expected_prev = event_id
self._replay_filter.add(event_id)
if event_type == "key_revoke":
self._apply_revocation(evt)
if accepted:
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._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),
"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
# Rebuild index
self.event_index = {e["event_id"]: i for i, e in enumerate(self.events)}
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.
"""
if not self.events:
return GENESIS_HASH
from services.mesh.mesh_merkle import merkle_root
leaves = [e["event_id"] for e in self.events]
root = merkle_root(leaves)
return root or GENESIS_HASH
def get_merkle_proofs(self, start_index: int, count: int) -> dict:
"""Return merkle proofs for a contiguous range of events."""
leaves = [e["event_id"] for e in self.events]
total = len(leaves)
if total == 0:
return {"root": GENESIS_HASH, "total": 0, "start": 0, "proofs": []}
from services.mesh.mesh_merkle import build_merkle_levels, merkle_proof_from_levels
start = max(0, start_index)
end = min(total, start + max(0, count))
levels = build_merkle_levels(leaves)
root = levels[-1][0] if levels 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