mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-03 12:58:11 +02:00
2e14e75a0e
Before this change, every peer-push HMAC was derived from the single fleet-shared MESH_PEER_PUSH_SECRET. The receiver could prove "this request was signed by someone who knows the fleet secret" but it could NOT prove which peer signed it. Any peer that knew the global secret could compute the expected HMAC for any other peer URL and forge a push pretending to be that peer. Fix: introduce MESH_PEER_SECRETS, an optional comma-separated url=secret map. When a peer URL appears in the map, only the listed per-peer secret is accepted for it -- the global secret is ignored for that specific URL. Peer A no longer knows peer B's secret, so peer A cannot forge a push claiming to be peer B. The new helper resolve_peer_key_for_url() in mesh_crypto.py wraps the lookup and is called from every existing peer-push call site: - backend/auth.py:_verify_peer_push_hmac (receiver) - backend/main.py:_http_peer_push_loop (Infonet event push) - backend/main.py:_http_gate_pull_loop (gate event pull) - backend/main.py:_http_gate_push_loop (gate event push) - backend/services/mesh/mesh_router.py (two transports, push) - backend/services/mesh/mesh_hashchain.py (gate wire ref key) - backend/services/mesh/mesh_wormhole_prekey.py (peer prekey lookup) Zero hostility, by design: - Single-peer installs leave MESH_PEER_SECRETS empty -> resolver falls back to MESH_PEER_PUSH_SECRET -> behavior is byte-for-byte unchanged. - Multi-peer installs that haven't migrated yet behave exactly as before. - Multi-peer installs that DO migrate set MESH_PEER_SECRETS on both ends of each peering and immediately close the impersonation surface for those URLs. Migration is incremental: unlisted peers keep using the global secret. Tests in backend/tests/test_per_peer_secret_resolver.py: - env parsing (default, override, whitespace, malformed entries, cache) - precedence: per-peer beats global - migration window: unlisted peer falls back to global - IMPERSONATION REFUSAL: peer A with global-secret-only cannot forge HMAC for peer B that has a per-peer secret configured - IMPERSONATION REFUSAL: peer A with its OWN per-peer secret cannot forge HMAC for peer B - positive control: legitimate peer B request verifies - zero-behavior-change: single-peer install produces the same key bytes as before the change Credit: tg12 (external security audit, P1/High/High confidence)
2902 lines
119 KiB
Python
2902 lines
119 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.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
|