arborist/aborist/mesh/wire.py
russell@unturf.com c92ebad4ed
mesh: per-peer audit chain-of-claims tracking on the wire
Receiver now rejects gossip envelopes whose prev_event_hash doesn't
extend the sender's last-seen event_hash — fork detection lives at the
wire layer, not just in docs/mesh.md.

Schema: new mesh_peer_chains table (peer_member_id PK, last_event_hash,
last_seq, last_seen_at) with idempotent _migrate_mesh_peer_chains
matching the _migrate_audit_mode pattern.

Envelope: WireEnvelope gains prev_event_hash + event_hash, both
optional for back-compat. canonical_bytes() excludes event_hash to
avoid the circular dependency (the hash is computed FROM the canonical
bytes). New WireEnvelope.with_chain(prev_event_hash=...) builds a
linked envelope with event_hash = sha256(prev || canonical_bytes).

Sender (MeshWireClient._signed_envelope, MeshWireServer.handle_request
DELIVER_BODY): reads our latest_event_hash, builds the linked
envelope, then appends a 'mesh_sent' audit event whose body is the
envelope's canonical (event-hash-excluded) dict. By construction the
audit event_hash equals the envelope event_hash — wire chain-of-claims
and local audit chain stay in lockstep, so back-to-back sends advance
the chain naturally.

Receiver (handle_announce): when an envelope carries chain fields,
recompute event_hash and 400 on mismatch; look up
mesh_peer_chains[sender] and 409 on prev mismatch; on accept update
the row + write the existing mesh_received event.

Backward compat: legacy envelopes (event_hash=None) bypass chain
enforcement — kept the existing test_mesh_wire.py fixtures verbatim
since they construct WireEnvelope directly without with_chain(). All
production traffic goes through MeshWireClient and is always tracked.

v1 deferred (documented in module docstring): no multi-event catchup;
operator retries on 409. No cross-peer reconciliation.

Tests: 12 new in tests/test_mesh_chain.py covering the 7 required
cases plus canonical-bytes exclusion, migration idempotence, and
legacy-envelope fallback. 246 passed, 1 skipped overall.
2026-04-28 19:41:25 -04:00

966 lines
36 KiB
Python

"""HTTP gossip wire — minimum viable.
Implements the protocol contract pinned in ``docs/mesh.md``:
ANNOUNCE_ROOT peer says "I have this document_root"
ANNOUNCE_DERIVATION peer says "core C derives from surfaces S1..Sn"
ANNOUNCE_PROVIDENCE peer says "I cached an answer at this 8-dim key"
ANNOUNCE_FALSIFICATION peer says "this cache_key is wrong"
REQUEST_BODY peer asks for a document body on local miss
DELIVER_BODY peer responds with bytes + Merkle proof
Every envelope is signed (Ed25519) by the sender's `sign_priv`; receiver
verifies against the sender's `sign_pub` looked up in `mesh_roster` at
the envelope's `epoch_id`. A non-member of that epoch cannot produce a
valid signature, so non-members are silently rejected.
Each receive writes a `mesh_received` event into the *local* audit
chain whose body is the full signed envelope. The receiver's chain
stays internally consistent because we append in receive-order.
**Per-peer chain-of-claims tracking.** Every envelope built via the
sender helper (``MeshWireClient`` outbound traffic, plus
``WireEnvelope.with_chain``) carries two extra fields:
- ``prev_event_hash`` — the sender's previous chain-of-claims hash, or
``None`` for the first envelope a peer ever broadcasts.
- ``event_hash`` — ``sha256(prev_event_hash || canonical_envelope_bytes)``.
The receiver:
1. Verifies the Ed25519 signature (existing).
2. Recomputes ``event_hash`` from ``prev_event_hash`` + the envelope's
canonical bytes (excluding ``event_hash`` itself, which is circular).
Mismatch → 400.
3. Looks up the sender's last-seen ``event_hash`` in
``mesh_peer_chains``. If a row exists, the envelope's
``prev_event_hash`` MUST equal it; otherwise the chain has forked
and we return 409 Conflict. If no row exists, this is the first
envelope we've seen from this peer; any ``prev_event_hash`` is
accepted (we have no prior to compare against).
4. On accept, ``mesh_peer_chains`` is updated to the new
``event_hash`` + bumped ``last_seq`` + ``now``.
Backward compatibility: envelopes that arrive WITHOUT
``prev_event_hash`` and ``event_hash`` (legacy, hand-built test fixtures
from before the chain-tracking rollout) skip per-peer chain enforcement.
The signature check still runs. New code paths always carry chain
fields, so this fallback only matters for legacy test envelopes.
v1 limitations (deferred to a later commit):
- **No multi-event catchup / replay.** If a receiver missed event N
(peer sent N+1 with prev=N's hash but receiver still has prev=N-1),
we reject with 409 and rely on an operator to retry or run a catchup
pass. v2 will fetch the missing range from the peer and replay.
- **No cross-peer reconciliation.** Each peer's chain is tracked
independently — alice's broadcasts to bob and bob's broadcasts to
carol are unrelated streams.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import threading
import time
from dataclasses import asdict, dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
import httpx
from aborist.mesh.crypto import aead_decrypt, aead_encrypt, sign, verify
from aborist.mesh.state import (
current_epoch,
load_identity,
recover_epoch_secret,
roster_at,
)
from aborist.store import append_audit, connect, latest_event_hash
# ---------------------------------------------------------------------------
# Wire constants
# ---------------------------------------------------------------------------
WIRE_VERSION = 1
TYPE_ANNOUNCE_ROOT = "ANNOUNCE_ROOT"
TYPE_ANNOUNCE_DERIVATION = "ANNOUNCE_DERIVATION"
TYPE_ANNOUNCE_PROVIDENCE = "ANNOUNCE_PROVIDENCE"
TYPE_ANNOUNCE_FALSIFICATION = "ANNOUNCE_FALSIFICATION"
TYPE_REQUEST_BODY = "REQUEST_BODY"
TYPE_DELIVER_BODY = "DELIVER_BODY"
ALL_TYPES = frozenset({
TYPE_ANNOUNCE_ROOT,
TYPE_ANNOUNCE_DERIVATION,
TYPE_ANNOUNCE_PROVIDENCE,
TYPE_ANNOUNCE_FALSIFICATION,
TYPE_REQUEST_BODY,
TYPE_DELIVER_BODY,
})
# HTTP paths
PATH_ANNOUNCE = "/mesh/announce"
PATH_REQUEST = "/mesh/request"
PATH_INFO = "/mesh/info"
DEFAULT_PORT = 8400
# ---------------------------------------------------------------------------
# Envelope: the signed unit of gossip
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class WireEnvelope:
"""One signed gossip message.
`body` is type-specific; canonicalization is via JSON sorted-keys with
no whitespace, identical to `aborist.store._canonical_json`.
Confidentiality is opt-in. When `encrypted_body` is set, `body` is a
placeholder (typically `{}`) and the real body lives AEAD-encrypted
inside `encrypted_body = {"nonce_b64": ..., "ct_b64": ...}`. Both
fields participate in `canonical_bytes()`, so the Ed25519 signature
binds the ciphertext: a MITM cannot swap `encrypted_body` without
invalidating the signature. Receivers detect encryption by the
presence of `encrypted_body` and decrypt before handler dispatch.
`prev_event_hash` and `event_hash` carry the sender's chain-of-claims
state. Both default to ``None`` for backward compatibility with hand-
built test envelopes. Production traffic is built via
:meth:`with_chain` (or `MeshWireClient`) which always populates them.
`event_hash` is intentionally NOT included in the signed canonical
bytes — it is computed FROM those bytes, so embedding it would be
circular. The receiver re-derives ``event_hash`` from
``sha256(prev_event_hash || canonical_bytes_without_event_hash)``
and asserts equality before accepting.
"""
type: str
sender_id: str
epoch_id: int
ts: int
body: dict[str, Any]
v: int = WIRE_VERSION
encrypted_body: dict[str, str] | None = None
prev_event_hash: str | None = None
event_hash: str | None = None
def canonical_bytes(self) -> bytes:
"""Bytes that get signed. Stable, reproducible.
Excludes ``event_hash`` (circular: it's the hash OF these bytes)
but includes ``prev_event_hash`` (linkage proof — the sender is
committing to a specific predecessor) and ``encrypted_body``
(so signatures bind the ciphertext too). The ``encrypted_body``
field is dropped from the canonical form when ``None`` so
cleartext-only envelopes stay byte-identical to the v1 wire
format — forward-compat for receivers that don't speak AEAD yet.
"""
d = asdict(self)
d.pop("event_hash", None)
if d.get("encrypted_body") is None:
d.pop("encrypted_body", None)
return _canonical_json(d).encode("utf-8")
def compute_event_hash(self) -> str:
"""Deterministic hash of this envelope as a chain-of-claims node.
``sha256(prev_event_hash_bytes || canonical_bytes)`` where
``prev_event_hash_bytes`` is the raw 32-byte digest if
``prev_event_hash`` is set, else empty.
"""
h = hashlib.sha256()
if self.prev_event_hash is not None:
h.update(bytes.fromhex(self.prev_event_hash))
h.update(self.canonical_bytes())
return h.hexdigest()
def with_chain(self, *, prev_event_hash: str | None) -> "WireEnvelope":
"""Return a copy with chain fields populated.
``event_hash`` is computed deterministically from
``prev_event_hash`` + this envelope's canonical bytes. The
returned envelope is the one the sender signs and broadcasts.
"""
# Construct the linked envelope first so canonical_bytes() sees
# the new prev_event_hash. event_hash itself is excluded from the
# canonical bytes, so we set it last.
linked = WireEnvelope(
type=self.type,
sender_id=self.sender_id,
epoch_id=self.epoch_id,
ts=self.ts,
body=self.body,
v=self.v,
encrypted_body=self.encrypted_body,
prev_event_hash=prev_event_hash,
event_hash=None,
)
eh = linked.compute_event_hash()
return WireEnvelope(
type=linked.type,
sender_id=linked.sender_id,
epoch_id=linked.epoch_id,
ts=linked.ts,
body=linked.body,
v=linked.v,
encrypted_body=linked.encrypted_body,
prev_event_hash=linked.prev_event_hash,
event_hash=eh,
)
def sign_with(self, sign_priv: bytes) -> dict[str, Any]:
"""Wrap envelope + Ed25519 signature in a dict ready for HTTP body."""
sig = sign(sign_priv, self.canonical_bytes())
env_d = asdict(self)
if env_d.get("encrypted_body") is None:
env_d.pop("encrypted_body", None)
return {
"envelope": env_d,
"sig_b64": base64.b64encode(sig).decode("ascii"),
}
@classmethod
def from_signed(cls, signed: dict[str, Any]) -> tuple["WireEnvelope", bytes]:
"""Reverse of `sign_with`. Returns (envelope, sig_bytes).
Does NOT verify — caller verifies via `verify_envelope_sig` and
(when chain fields are present) via the receiver's chain check.
``prev_event_hash`` and ``event_hash`` are optional in the wire
payload — legacy envelopes that pre-date chain tracking decode
with both as ``None``.
"""
if not isinstance(signed, dict) or "envelope" not in signed or "sig_b64" not in signed:
raise ValueError("signed payload missing envelope/sig_b64")
env_d = signed["envelope"]
if not isinstance(env_d, dict):
raise ValueError("envelope is not a dict")
for required in ("type", "sender_id", "epoch_id", "ts", "body", "v"):
if required not in env_d:
raise ValueError(f"envelope missing field: {required}")
if env_d["type"] not in ALL_TYPES:
raise ValueError(f"unknown envelope type: {env_d['type']!r}")
if env_d["v"] != WIRE_VERSION:
raise ValueError(f"unsupported wire version: {env_d['v']!r}")
encrypted_body = env_d.get("encrypted_body")
if encrypted_body is not None:
if not isinstance(encrypted_body, dict):
raise ValueError("encrypted_body must be a dict if present")
for k in ("nonce_b64", "ct_b64"):
if k not in encrypted_body:
raise ValueError(f"encrypted_body missing field: {k}")
prev = env_d.get("prev_event_hash")
eh = env_d.get("event_hash")
if prev is not None and not isinstance(prev, str):
raise ValueError("prev_event_hash must be str or null")
if eh is not None and not isinstance(eh, str):
raise ValueError("event_hash must be str or null")
env = cls(
type=env_d["type"],
sender_id=env_d["sender_id"],
epoch_id=int(env_d["epoch_id"]),
ts=int(env_d["ts"]),
body=env_d["body"],
v=int(env_d["v"]),
encrypted_body=encrypted_body,
prev_event_hash=prev,
event_hash=eh,
)
try:
sig = base64.b64decode(signed["sig_b64"], validate=True)
except Exception as e:
raise ValueError(f"sig_b64 is not valid base64: {e!r}") from e
return env, sig
def _envelope_aad(
*,
type_: str,
sender_id: str,
epoch_id: int,
ts: int,
) -> bytes:
"""AEAD additional-authenticated-data for body encryption.
Binds the ciphertext to the envelope metadata so a captured
(nonce, ct) cannot be replayed under a different `(type, sender,
epoch, ts)` tuple. The signature already binds these fields — AAD
is belt-and-suspenders against the case where a peer accepts an
envelope with a fresh sig but recycled ciphertext (e.g. cross-type
confusion).
"""
return f"{type_}|{sender_id}|{epoch_id}|{ts}".encode("utf-8")
def encrypt_body_for_envelope(
*,
type_: str,
sender_id: str,
epoch_id: int,
ts: int,
body: dict[str, Any],
epoch_secret: bytes,
) -> dict[str, str]:
"""Serialize `body` canonically, AEAD-encrypt under `epoch_secret`.
Returns a dict of base64-encoded `nonce_b64` + `ct_b64` suitable to
drop into `WireEnvelope.encrypted_body`. The plaintext is the same
canonical JSON that would have appeared in `body`, so a receiver
decrypts and JSON-parses to recover the original dict.
"""
plaintext = _canonical_json(body).encode("utf-8")
nonce = os.urandom(12)
aad = _envelope_aad(
type_=type_, sender_id=sender_id, epoch_id=epoch_id, ts=ts
)
ct = aead_encrypt(epoch_secret, nonce, plaintext, aad=aad)
return {
"nonce_b64": base64.b64encode(nonce).decode("ascii"),
"ct_b64": base64.b64encode(ct).decode("ascii"),
}
def decrypt_envelope_body(
envelope: WireEnvelope,
epoch_secret: bytes,
) -> dict[str, Any]:
"""AEAD-decrypt `envelope.encrypted_body` and return the recovered dict.
Raises ValueError on missing encrypted_body, malformed base64, AEAD
tag mismatch (wrong key, tampering, AAD drift), or non-dict
plaintext.
"""
if envelope.encrypted_body is None:
raise ValueError("envelope has no encrypted_body to decrypt")
try:
nonce = base64.b64decode(envelope.encrypted_body["nonce_b64"])
ct = base64.b64decode(envelope.encrypted_body["ct_b64"])
except Exception as e:
raise ValueError(f"encrypted_body base64 decode failed: {e!r}") from e
aad = _envelope_aad(
type_=envelope.type,
sender_id=envelope.sender_id,
epoch_id=envelope.epoch_id,
ts=envelope.ts,
)
plaintext = aead_decrypt(epoch_secret, nonce, ct, aad=aad)
decoded = json.loads(plaintext.decode("utf-8"))
if not isinstance(decoded, dict):
raise ValueError("decrypted body must be a JSON object")
return decoded
def _peer_chain_row(conn, peer_member_id: str) -> tuple[str, int] | None:
"""Return (last_event_hash, last_seq) for a peer, or None if unseen."""
row = conn.execute(
"SELECT last_event_hash, last_seq FROM mesh_peer_chains "
"WHERE peer_member_id = ?",
(peer_member_id,),
).fetchone()
if row is None:
return None
return row["last_event_hash"], int(row["last_seq"])
def _update_peer_chain(
conn,
peer_member_id: str,
new_event_hash: str,
*,
now: int,
) -> None:
"""Upsert mesh_peer_chains[peer] = new_event_hash, last_seq+=1, now."""
existing = _peer_chain_row(conn, peer_member_id)
if existing is None:
conn.execute(
"INSERT INTO mesh_peer_chains "
"(peer_member_id, last_event_hash, last_seq, last_seen_at) "
"VALUES (?, ?, ?, ?)",
(peer_member_id, new_event_hash, 1, now),
)
else:
_, last_seq = existing
conn.execute(
"UPDATE mesh_peer_chains "
"SET last_event_hash = ?, last_seq = ?, last_seen_at = ? "
"WHERE peer_member_id = ?",
(new_event_hash, last_seq + 1, now, peer_member_id),
)
def verify_envelope_sig(
conn,
envelope: WireEnvelope,
sig: bytes,
) -> bool:
"""True iff `sig` is a valid Ed25519 signature over the envelope's
canonical bytes by `envelope.sender_id`'s `sign_pub` at
`envelope.epoch_id` in the local roster.
Returns False (never raises) on missing roster entry, bad signature,
or any structural issue. The caller treats False as "drop the message."
"""
try:
roster = roster_at(conn, envelope.epoch_id)
except Exception:
return False
sender = next((m for m in roster if m.member_id == envelope.sender_id), None)
if sender is None:
return False
return verify(sender.sign_pub, sig, envelope.canonical_bytes())
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
def _make_handler_class(mesh_server: "MeshWireServer") -> type[BaseHTTPRequestHandler]:
"""Build a request-handler class bound to this MeshWireServer.
Done as a factory so the handler can reach back to the mesh server
without colliding with BaseHTTPRequestHandler's own ``self.server``
attribute (which is the underlying ThreadingHTTPServer).
"""
class Handler(BaseHTTPRequestHandler):
# Bound at class level by the factory closure.
mesh: MeshWireServer = mesh_server
def log_message(self, fmt, *args): # silence default stderr spam
return
def _read_json(self) -> dict[str, Any]:
n = int(self.headers.get("content-length", "0"))
raw = self.rfile.read(n) if n > 0 else b""
return json.loads(raw or b"{}")
def _respond(self, status: int, body: dict[str, Any]) -> None:
data = json.dumps(body).encode("utf-8")
self.send_response(status)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self) -> None:
if self.path == PATH_INFO:
self._respond(200, self.mesh.info())
return
self._respond(404, {"error": "not found"})
def do_POST(self) -> None:
try:
payload = self._read_json()
except Exception as e:
self._respond(400, {"error": f"bad json: {e!s}"})
return
if self.path == PATH_ANNOUNCE:
self._respond(*self.mesh.handle_announce(payload))
return
if self.path == PATH_REQUEST:
self._respond(*self.mesh.handle_request(payload))
return
self._respond(404, {"error": "not found"})
return Handler
class MeshWireServer:
"""Threaded HTTP gossip server.
Use as ``with MeshWireServer(db_path, host, port) as srv: srv.serve()`` for
one-shot scripts, or call ``start_in_thread()`` for tests / long-lived
daemons. ``stop()`` is idempotent.
"""
def __init__(self, db_path: Path | str, host: str = "127.0.0.1", port: int = DEFAULT_PORT):
self.db_path = str(db_path)
handler_cls = _make_handler_class(self)
self._http = ThreadingHTTPServer((host, port), handler_cls)
self._thread: threading.Thread | None = None
# ------------------------------------------------------------------ context
def __enter__(self) -> "MeshWireServer":
return self
def __exit__(self, *_a) -> None:
self.stop()
# ------------------------------------------------------------------ lifecycle
@property
def address(self) -> tuple[str, int]:
return self._http.server_address # type: ignore[return-value]
@property
def url(self) -> str:
host, port = self.address
return f"http://{host}:{port}"
def start_in_thread(self) -> None:
if self._thread is not None:
return
t = threading.Thread(target=self._http.serve_forever, daemon=True)
t.start()
self._thread = t
def serve(self) -> None:
self._http.serve_forever()
def stop(self) -> None:
# shutdown() blocks until serve_forever() acknowledges, so only call
# it when we actually started a serving thread. server_close() is
# safe in either case — it just releases the socket.
if self._thread is not None:
try:
self._http.shutdown()
except Exception:
pass
try:
self._http.server_close()
except Exception:
pass
self._thread = None
# ------------------------------------------------------------------ handlers
def info(self) -> dict[str, Any]:
conn = connect(self.db_path)
try:
me = load_identity(conn)
epoch = current_epoch(conn)
finally:
conn.close()
if me is None:
return {"error": "mesh not initialized"}
return {
"v": WIRE_VERSION,
"member_id": me.member_id,
"group_name": me.group_name,
"current_epoch": epoch,
"sign_pub_hex": me.sign_pub.hex(),
}
def handle_announce(self, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]:
"""Verify sig, dedup, persist as a `mesh_received` audit event.
If the envelope carries `encrypted_body`, decrypt it under this
peer's recovered epoch secret before audit-logging. A peer with
no slot in the epoch envelope (evicted or never enrolled) returns
401: confidentiality failure is indistinguishable from auth
failure at this layer.
"""
try:
env, sig = WireEnvelope.from_signed(payload)
except ValueError as e:
return 400, {"error": str(e)}
if env.type not in (
TYPE_ANNOUNCE_ROOT,
TYPE_ANNOUNCE_DERIVATION,
TYPE_ANNOUNCE_PROVIDENCE,
TYPE_ANNOUNCE_FALSIFICATION,
):
return 400, {"error": f"announce endpoint rejects {env.type!r}"}
conn = connect(self.db_path)
try:
if not verify_envelope_sig(conn, env, sig):
return 401, {"error": "signature verify failed"}
# Per-peer chain-of-claims tracking. Only enforced when the
# sender attached chain fields. Hand-built legacy envelopes
# (env.event_hash is None) skip enforcement — signature alone.
if env.event_hash is not None:
expected_eh = env.compute_event_hash()
if expected_eh != env.event_hash:
return 400, {
"error": "envelope event_hash mismatch: "
f"computed {expected_eh}, claimed {env.event_hash}"
}
existing = _peer_chain_row(conn, env.sender_id)
if existing is not None:
last_eh, _ = existing
if env.prev_event_hash != last_eh:
return 409, {
"error": (
f"fork detected: peer {env.sender_id} "
f"expected prev_event_hash {last_eh}, "
f"got {env.prev_event_hash}"
)
}
# else: first envelope from this peer; any prev_event_hash
# (None or arbitrary string) is accepted — we have no
# prior to compare against.
_update_peer_chain(
conn,
env.sender_id,
env.event_hash,
now=int(time.time()),
)
# AEAD decrypt happens after sig + chain checks so we don't
# waste a secret recovery on rejected traffic.
effective_body = env.body
if env.encrypted_body is not None:
try:
epoch_secret = recover_epoch_secret(conn, epoch_id=env.epoch_id)
effective_body = decrypt_envelope_body(env, epoch_secret)
except (ValueError, RuntimeError) as e:
return 401, {"error": f"AEAD decrypt failed: {e!s}"}
event_hash = append_audit(
conn,
event_type="mesh_received",
body={
"wire_type": env.type,
"sender_id": env.sender_id,
"epoch_id": env.epoch_id,
"remote_ts": env.ts,
"envelope_body": effective_body,
"encrypted": env.encrypted_body is not None,
# Sender's chain-of-claims hash (if present). Records
# what we accepted so future audits can reconstruct
# the sender's claimed history.
"sender_event_hash": env.event_hash,
"sender_prev_event_hash": env.prev_event_hash,
},
subject_root=str(
effective_body.get("document_root")
or effective_body.get("cache_key")
or ""
),
)
finally:
conn.close()
return 200, {
"ok": True,
"wire_type": env.type,
"audit_event_hash": event_hash,
}
def handle_request(self, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]:
"""REQUEST_BODY -> DELIVER_BODY (or 404 if root not present locally).
v1: returns the document text concatenated from hot chunks. The
Merkle proof is the document_root itself plus per-chunk leaf
hashes — receiver re-derives the root and checks. (Full per-chunk
inclusion proofs are a v2 refinement; root + leaves is enough to
verify what we deliver since the leaves are the deterministic
SHA-256 over canonicalized chunk content.)
"""
try:
env, sig = WireEnvelope.from_signed(payload)
except ValueError as e:
return 400, {"error": str(e)}
if env.type != TYPE_REQUEST_BODY:
return 400, {"error": f"request endpoint rejects {env.type!r}"}
conn = connect(self.db_path)
try:
if not verify_envelope_sig(conn, env, sig):
return 401, {"error": "signature verify failed"}
root = env.body.get("root")
if not isinstance(root, str) or len(root) != 64:
return 400, {"error": "body.root must be a 64-char hex sha256"}
row = conn.execute(
"SELECT document_uri FROM documents WHERE document_root=?", (root,)
).fetchone()
if row is None:
return 404, {"error": f"no such document_root: {root}"}
chunks = conn.execute(
"SELECT idx, leaf_hash, content FROM chunks "
"WHERE document_root=? AND tier='hot' ORDER BY idx",
(root,),
).fetchall()
from aborist.compress import unpack_chunk
leaves_hex = [r["leaf_hash"] for r in chunks]
text = "\n\n".join(unpack_chunk(r["content"]) or "" for r in chunks)
me = load_identity(conn)
epoch = current_epoch(conn)
if me is None or epoch is None:
return 503, {"error": "mesh not initialized on responder"}
prev = latest_event_hash(conn)
deliver = WireEnvelope(
type=TYPE_DELIVER_BODY,
sender_id=me.member_id,
epoch_id=epoch,
ts=int(time.time()),
body={
"root": root,
"document_uri": row["document_uri"],
"leaves_hex": leaves_hex,
"text": text,
},
).with_chain(prev_event_hash=prev)
# Mirror the client's audit-log of every outbound chain-linked
# envelope so DELIVER_BODY also extends the responder's chain.
from dataclasses import asdict as _asdict
audit_body = _asdict(deliver)
audit_body.pop("event_hash", None)
# Mirror canonical_bytes(): drop None encrypted_body so the
# audit hash matches event_hash for cleartext envelopes.
if audit_body.get("encrypted_body") is None:
audit_body.pop("encrypted_body", None)
audit_eh = append_audit(
conn,
event_type="mesh_sent",
body=audit_body,
subject_root=root,
ts=deliver.ts,
)
assert audit_eh == deliver.event_hash
return 200, deliver.sign_with(me.sign_priv)
finally:
conn.close()
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
class MeshWireClient:
"""Thin client for outbound gossip + body pulls.
Holds no long-lived state beyond the db_path; every call opens its
own connection so we never share sqlite handles across threads.
"""
def __init__(self, db_path: Path | str, peer_url: str, *, timeout: float = 10.0):
self.db_path = str(db_path)
self.peer_url = peer_url.rstrip("/")
self._client = httpx.Client(timeout=timeout)
def close(self) -> None:
self._client.close()
def __enter__(self) -> "MeshWireClient":
return self
def __exit__(self, *_a) -> None:
self.close()
# ------------------------------------------------------------------ helpers
def _signed_envelope(
self,
type_: str,
body: dict[str, Any],
*,
encrypt: bool = False,
) -> dict[str, Any]:
"""Build, chain-link, optionally encrypt, sign, and audit-log one
outbound envelope.
Three responsibilities, in order:
1. **Confidentiality (optional).** When ``encrypt=True``, the
body is AEAD-encrypted under this peer's recovered epoch
secret. Sender-side recovery uses ``recover_epoch_secret``
which re-unwraps our own slot — no sender-side cache.
2. **Chain-of-claims.** Populate ``prev_event_hash`` from our
own ``latest_event_hash`` and compute ``event_hash`` so the
receiver can enforce per-peer chain consistency.
3. **Local audit log.** Append a ``mesh_sent`` event whose body
is the envelope's canonical (event-hash-excluded) dict.
By construction its ``audit_event.event_hash`` equals the
envelope's ``event_hash``, unifying the wire chain-of-claims
with the local audit chain. ``latest_event_hash`` then
advances to this envelope's ``event_hash``, so back-to-back
sends extend a single linear chain.
Drop the same dict shape into both wire and audit so a future
forensic replay only needs the audit body to reproduce the
envelope bytes (modulo signature).
"""
from dataclasses import asdict as _asdict
conn = connect(self.db_path)
try:
me = load_identity(conn)
epoch = current_epoch(conn)
if me is None or epoch is None:
raise RuntimeError("mesh not initialized; call init_identity first")
ts = int(time.time())
if encrypt:
secret = recover_epoch_secret(conn, epoch_id=epoch)
encrypted = encrypt_body_for_envelope(
type_=type_,
sender_id=me.member_id,
epoch_id=epoch,
ts=ts,
body=body,
epoch_secret=secret,
)
base_env = WireEnvelope(
type=type_,
sender_id=me.member_id,
epoch_id=epoch,
ts=ts,
body={}, # placeholder; real body lives in encrypted_body
encrypted_body=encrypted,
)
else:
base_env = WireEnvelope(
type=type_,
sender_id=me.member_id,
epoch_id=epoch,
ts=ts,
body=body,
)
prev = latest_event_hash(conn)
env = base_env.with_chain(prev_event_hash=prev)
audit_body = _asdict(env)
audit_body.pop("event_hash", None)
# Drop None-valued encrypted_body so cleartext audit bodies
# stay byte-identical to the v1 audit shape (mirrors
# canonical_bytes' behavior — keeps audit hash == event_hash
# for cleartext envelopes built by older callers).
if audit_body.get("encrypted_body") is None:
audit_body.pop("encrypted_body", None)
audit_event_hash = append_audit(
conn,
event_type="mesh_sent",
body=audit_body,
subject_root=str(body.get("document_root") or body.get("cache_key") or ""),
ts=env.ts,
)
assert audit_event_hash == env.event_hash, (
f"chain unification broke: audit hash {audit_event_hash} "
f"!= envelope event_hash {env.event_hash}"
)
signed = env.sign_with(me.sign_priv)
finally:
conn.close()
return signed
# ------------------------------------------------------------------ ops
def info(self) -> dict[str, Any]:
r = self._client.get(self.peer_url + PATH_INFO)
r.raise_for_status()
return r.json()
def announce_root(
self,
*,
document_root: str,
source_uri: str,
chunking_version: str,
canonicalization_version: str,
schema_version: str,
encrypt: bool = False,
) -> dict[str, Any]:
"""Announce a `document_root`. Default cleartext; pass
`encrypt=True` to AEAD-encrypt the body under this peer's
epoch secret. The signature still binds the ciphertext, so a
MITM cannot swap payloads without invalidating the envelope.
"""
signed = self._signed_envelope(
TYPE_ANNOUNCE_ROOT,
{
"document_root": document_root,
"source_uri": source_uri,
"chunking_version": chunking_version,
"canonicalization_version": canonicalization_version,
"schema_version": schema_version,
},
encrypt=encrypt,
)
r = self._client.post(self.peer_url + PATH_ANNOUNCE, json=signed)
r.raise_for_status()
return r.json()
def announce_falsification(
self,
*,
cache_key: str,
reason: str,
encrypt: bool = False,
) -> dict[str, Any]:
"""Announce a falsification. `encrypt` mirrors `announce_root`."""
signed = self._signed_envelope(
TYPE_ANNOUNCE_FALSIFICATION,
{"cache_key": cache_key, "reason": reason},
encrypt=encrypt,
)
r = self._client.post(self.peer_url + PATH_ANNOUNCE, json=signed)
r.raise_for_status()
return r.json()
def request_body(self, *, root: str) -> dict[str, Any]:
"""Pull `root` from the peer. Verifies the returned signature and
re-derives the Merkle root from delivered leaf hashes. On any
verification failure, raises — the caller never sees an unverified
body.
"""
signed = self._signed_envelope(TYPE_REQUEST_BODY, {"root": root})
r = self._client.post(self.peer_url + PATH_REQUEST, json=signed)
r.raise_for_status()
deliver_payload = r.json()
env, sig = WireEnvelope.from_signed(deliver_payload)
if env.type != TYPE_DELIVER_BODY:
raise ValueError(f"expected DELIVER_BODY, got {env.type!r}")
conn = connect(self.db_path)
try:
if not verify_envelope_sig(conn, env, sig):
raise ValueError("delivered envelope signature failed verification")
finally:
conn.close()
delivered_root = env.body.get("root")
leaves_hex = env.body.get("leaves_hex") or []
if delivered_root != root:
raise ValueError(
f"delivered root {delivered_root!r} does not match requested {root!r}"
)
if not _merkle_root_matches(leaves_hex, root):
raise ValueError(
"delivered leaves do not Merkle-derive to the claimed root"
)
return env.body
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _canonical_json(obj: Any) -> str:
"""Stable JSON: sorted keys, no whitespace, ensure_ascii=False so unicode
bytes survive a round-trip without \\uXXXX expansion. Same convention as
`aborist.store._canonical_json` — kept local to avoid a private import."""
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def _merkle_root_matches(leaves_hex: list[str], expected_root_hex: str) -> bool:
"""Re-derive the Merkle root from `leaves_hex` (already-hashed chunk
leaves) and compare to `expected_root_hex`. Uses aborist.merkle's
convention so single-leaf documents and odd-element trees behave
identically to the local ingest path.
"""
from aborist.merkle import MerkleTree
if not leaves_hex:
return False
try:
leaves = [bytes.fromhex(h) for h in leaves_hex]
except ValueError:
return False
tree = MerkleTree.build(leaves)
return tree.root.hex() == expected_root_hex