mesh: HTTP gossip wire — signed envelopes + 5 message types
Implements the protocol contract pinned in docs/mesh.md. Each peer runs a stdlib ThreadingHTTPServer; clients send Ed25519-signed envelopes carrying one of: ANNOUNCE_ROOT document_root + source_uri + versions ANNOUNCE_DERIVATION core_root <- surface_roots (proof-blob hash) ANNOUNCE_PROVIDENCE cache_key + audit_mode + answer_hash ANNOUNCE_FALSIFICATION cache_key + reason REQUEST_BODY/DELIVER_BODY pull-on-miss + Merkle-root verify WireEnvelope canonicalizes via sorted-key separator-tight JSON; the canonical bytes are what get signed. Receivers verify the signature against sender_id's sign_pub looked up in mesh_roster at the envelope's epoch_id — non-members of that epoch produce no valid signature, so they're silently rejected (401, no audit event). Each accepted ANNOUNCE_* writes one 'mesh_received' event into the local audit chain whose body is the full signed envelope (the remote's signature stays attached for non-repudiation). The receiver's chain stays internally consistent because we append in receive-order. REQUEST_BODY/DELIVER_BODY: the responder ships the document text plus per-chunk leaf hashes; the client re-derives the Merkle root from those leaves and refuses to return any body whose leaves don't reconstruct the requested root. Tests: 17 unit (envelope canonicalization, sig verify against roster, type validation, tampered-body rejection, audit-event write) + 5 end-to-end (two real HTTP peers on ephemeral ports exchanging announces and bodies). 22/22 green. Deferred to later commits, per docs/mesh.md: - CLI verbs (mesh serve, mesh sync) — pending downstream merge - per-peer chain-of-claims tracking (catchup, replay, fork detection) - optional AEAD encryption of message bodies (contract says optional)
This commit is contained in:
parent
22a8071936
commit
f141babde1
4 changed files with 1177 additions and 0 deletions
538
aborist/mesh/wire.py
Normal file
538
aborist/mesh/wire.py
Normal file
|
|
@ -0,0 +1,538 @@
|
||||||
|
"""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. The
|
||||||
|
sender's chain integrity is verified by signature alone at this layer
|
||||||
|
— per-peer chain-of-claims tracking (catchup, replay, fork detection)
|
||||||
|
is a v2 concern (see ``docs/mesh.md``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
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 sign, verify
|
||||||
|
from aborist.mesh.state import (
|
||||||
|
current_epoch,
|
||||||
|
load_identity,
|
||||||
|
roster_at,
|
||||||
|
)
|
||||||
|
from aborist.store import append_audit, connect
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
sender_id: str
|
||||||
|
epoch_id: int
|
||||||
|
ts: int
|
||||||
|
body: dict[str, Any]
|
||||||
|
v: int = WIRE_VERSION
|
||||||
|
|
||||||
|
def canonical_bytes(self) -> bytes:
|
||||||
|
"""Bytes that get signed. Stable, reproducible."""
|
||||||
|
return _canonical_json(asdict(self)).encode("utf-8")
|
||||||
|
|
||||||
|
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())
|
||||||
|
return {
|
||||||
|
"envelope": asdict(self),
|
||||||
|
"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`.
|
||||||
|
"""
|
||||||
|
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}")
|
||||||
|
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"]),
|
||||||
|
)
|
||||||
|
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 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."""
|
||||||
|
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"}
|
||||||
|
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": env.body,
|
||||||
|
},
|
||||||
|
subject_root=str(env.body.get("document_root") or env.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"}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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]) -> dict[str, Any]:
|
||||||
|
conn = connect(self.db_path)
|
||||||
|
try:
|
||||||
|
me = load_identity(conn)
|
||||||
|
epoch = current_epoch(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if me is None or epoch is None:
|
||||||
|
raise RuntimeError("mesh not initialized; call init_identity first")
|
||||||
|
env = WireEnvelope(
|
||||||
|
type=type_,
|
||||||
|
sender_id=me.member_id,
|
||||||
|
epoch_id=epoch,
|
||||||
|
ts=int(time.time()),
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
return env.sign_with(me.sign_priv)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 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,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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) -> dict[str, Any]:
|
||||||
|
signed = self._signed_envelope(
|
||||||
|
TYPE_ANNOUNCE_FALSIFICATION,
|
||||||
|
{"cache_key": cache_key, "reason": reason},
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
@ -27,10 +27,16 @@ html = [
|
||||||
wikitext = [
|
wikitext = [
|
||||||
"mwparserfromhell>=0.6",
|
"mwparserfromhell>=0.6",
|
||||||
]
|
]
|
||||||
|
mesh = [
|
||||||
|
# httpx is already in core deps; mesh wire only depends on stdlib +
|
||||||
|
# cryptography (also core). This extras block exists as the documented
|
||||||
|
# opt-in surface even though no extra packages are required today.
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8",
|
"pytest>=8",
|
||||||
"aborist[html]",
|
"aborist[html]",
|
||||||
"aborist[wikitext]",
|
"aborist[wikitext]",
|
||||||
|
"aborist[mesh]",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
||||||
447
tests/test_mesh_wire.py
Normal file
447
tests/test_mesh_wire.py
Normal file
|
|
@ -0,0 +1,447 @@
|
||||||
|
"""Mesh wire — unit tests for envelopes + signature verification.
|
||||||
|
|
||||||
|
These tests don't bind a network port. End-to-end HTTP exchange is
|
||||||
|
exercised in tests/test_mesh_wire_e2e.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aborist.mesh import init_identity
|
||||||
|
from aborist.mesh.members import add_member
|
||||||
|
from aborist.mesh.crypto import (
|
||||||
|
generate_dh_keypair,
|
||||||
|
generate_signing_keypair,
|
||||||
|
)
|
||||||
|
from aborist.mesh.wire import (
|
||||||
|
ALL_TYPES,
|
||||||
|
PATH_ANNOUNCE,
|
||||||
|
PATH_INFO,
|
||||||
|
PATH_REQUEST,
|
||||||
|
TYPE_ANNOUNCE_FALSIFICATION,
|
||||||
|
TYPE_ANNOUNCE_ROOT,
|
||||||
|
TYPE_DELIVER_BODY,
|
||||||
|
TYPE_REQUEST_BODY,
|
||||||
|
WIRE_VERSION,
|
||||||
|
WireEnvelope,
|
||||||
|
verify_envelope_sig,
|
||||||
|
)
|
||||||
|
from aborist.store import connect
|
||||||
|
|
||||||
|
|
||||||
|
def _bootstrap_two_peer_db(tmp_path: Path) -> tuple[Path, Path, dict, dict]:
|
||||||
|
"""Initialise two peers (alice admin + bob member) on separate DBs in
|
||||||
|
the same group with bob enrolled at epoch 1 on BOTH peers.
|
||||||
|
|
||||||
|
Returns (alice_db, bob_db, alice_identity, bob_identity).
|
||||||
|
"""
|
||||||
|
alice_db = tmp_path / "alice.db"
|
||||||
|
bob_db = tmp_path / "bob.db"
|
||||||
|
|
||||||
|
a_conn = connect(alice_db)
|
||||||
|
try:
|
||||||
|
alice_id = init_identity(a_conn, group_name="t", member_id="alice")
|
||||||
|
finally:
|
||||||
|
a_conn.close()
|
||||||
|
|
||||||
|
b_conn = connect(bob_db)
|
||||||
|
try:
|
||||||
|
bob_id = init_identity(b_conn, group_name="t", member_id="bob")
|
||||||
|
finally:
|
||||||
|
b_conn.close()
|
||||||
|
|
||||||
|
# Each peer's roster needs the other peer at the same epoch_id so
|
||||||
|
# signature verification can find sender_id.sign_pub. Add bob at
|
||||||
|
# alice's DB; add alice at bob's DB. Same epoch (1) on both sides.
|
||||||
|
a_conn = connect(alice_db)
|
||||||
|
try:
|
||||||
|
add_member(
|
||||||
|
a_conn,
|
||||||
|
member_id="bob",
|
||||||
|
sign_pub=bob_id.sign_pub,
|
||||||
|
dh_pub=bob_id.dh_pub,
|
||||||
|
role="member",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
a_conn.close()
|
||||||
|
|
||||||
|
b_conn = connect(bob_db)
|
||||||
|
try:
|
||||||
|
add_member(
|
||||||
|
b_conn,
|
||||||
|
member_id="alice",
|
||||||
|
sign_pub=alice_id.sign_pub,
|
||||||
|
dh_pub=alice_id.dh_pub,
|
||||||
|
role="member",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
b_conn.close()
|
||||||
|
|
||||||
|
return alice_db, bob_db, alice_id, bob_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Envelope canonicalization + sign / verify
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_bytes_stable_across_field_order():
|
||||||
|
"""Same data, different in-memory dict order → same canonical bytes."""
|
||||||
|
a = WireEnvelope(
|
||||||
|
type=TYPE_ANNOUNCE_ROOT,
|
||||||
|
sender_id="alice",
|
||||||
|
epoch_id=1,
|
||||||
|
ts=100,
|
||||||
|
body={"document_root": "ab" * 32, "source_uri": "https://x"},
|
||||||
|
)
|
||||||
|
b = WireEnvelope(
|
||||||
|
type=TYPE_ANNOUNCE_ROOT,
|
||||||
|
sender_id="alice",
|
||||||
|
epoch_id=1,
|
||||||
|
ts=100,
|
||||||
|
body={"source_uri": "https://x", "document_root": "ab" * 32},
|
||||||
|
)
|
||||||
|
assert a.canonical_bytes() == b.canonical_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_round_trip():
|
||||||
|
"""sign_with -> from_signed reconstructs the same envelope and sig."""
|
||||||
|
sign_priv, _sign_pub = generate_signing_keypair()
|
||||||
|
env = WireEnvelope(
|
||||||
|
type=TYPE_ANNOUNCE_ROOT,
|
||||||
|
sender_id="alice",
|
||||||
|
epoch_id=0,
|
||||||
|
ts=int(time.time()),
|
||||||
|
body={"document_root": "00" * 32, "source_uri": "https://x"},
|
||||||
|
)
|
||||||
|
signed = env.sign_with(sign_priv)
|
||||||
|
env2, sig2 = WireEnvelope.from_signed(signed)
|
||||||
|
assert env == env2
|
||||||
|
assert env.canonical_bytes() == env2.canonical_bytes()
|
||||||
|
assert isinstance(sig2, bytes)
|
||||||
|
assert len(sig2) == 64 # Ed25519 signature length
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_signed_rejects_unknown_type():
|
||||||
|
sign_priv, _ = generate_signing_keypair()
|
||||||
|
env = WireEnvelope(
|
||||||
|
type="ANNOUNCE_ROOT", # valid
|
||||||
|
sender_id="alice",
|
||||||
|
epoch_id=0,
|
||||||
|
ts=0,
|
||||||
|
body={},
|
||||||
|
)
|
||||||
|
signed = env.sign_with(sign_priv)
|
||||||
|
signed["envelope"]["type"] = "GARBAGE"
|
||||||
|
with pytest.raises(ValueError, match="unknown envelope type"):
|
||||||
|
WireEnvelope.from_signed(signed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_signed_rejects_wrong_version():
|
||||||
|
sign_priv, _ = generate_signing_keypair()
|
||||||
|
env = WireEnvelope(
|
||||||
|
type=TYPE_ANNOUNCE_ROOT,
|
||||||
|
sender_id="alice",
|
||||||
|
epoch_id=0,
|
||||||
|
ts=0,
|
||||||
|
body={},
|
||||||
|
)
|
||||||
|
signed = env.sign_with(sign_priv)
|
||||||
|
signed["envelope"]["v"] = 99
|
||||||
|
with pytest.raises(ValueError, match="unsupported wire version"):
|
||||||
|
WireEnvelope.from_signed(signed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_signed_rejects_missing_envelope():
|
||||||
|
with pytest.raises(ValueError, match="missing envelope"):
|
||||||
|
WireEnvelope.from_signed({"sig_b64": "AA=="})
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_signed_rejects_bad_base64_sig():
|
||||||
|
sign_priv, _ = generate_signing_keypair()
|
||||||
|
env = WireEnvelope(type=TYPE_ANNOUNCE_ROOT, sender_id="x", epoch_id=0, ts=0, body={})
|
||||||
|
signed = env.sign_with(sign_priv)
|
||||||
|
signed["sig_b64"] = "not-base64!!"
|
||||||
|
with pytest.raises(ValueError, match="not valid base64"):
|
||||||
|
WireEnvelope.from_signed(signed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_types_constant_completeness():
|
||||||
|
"""If someone adds a new TYPE_* constant they must add it to ALL_TYPES."""
|
||||||
|
expected = {
|
||||||
|
"ANNOUNCE_ROOT",
|
||||||
|
"ANNOUNCE_DERIVATION",
|
||||||
|
"ANNOUNCE_PROVIDENCE",
|
||||||
|
"ANNOUNCE_FALSIFICATION",
|
||||||
|
"REQUEST_BODY",
|
||||||
|
"DELIVER_BODY",
|
||||||
|
}
|
||||||
|
assert ALL_TYPES == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_paths_are_namespaced_under_mesh():
|
||||||
|
"""Defends against accidental collisions with future top-level paths."""
|
||||||
|
for p in (PATH_ANNOUNCE, PATH_INFO, PATH_REQUEST):
|
||||||
|
assert p.startswith("/mesh/")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# verify_envelope_sig — roster-bound check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_envelope_sig_accepts_known_member(tmp_path):
|
||||||
|
alice_db, _bob_db, alice_id, _bob_id = _bootstrap_two_peer_db(tmp_path)
|
||||||
|
|
||||||
|
env = WireEnvelope(
|
||||||
|
type=TYPE_ANNOUNCE_ROOT,
|
||||||
|
sender_id="alice",
|
||||||
|
epoch_id=1,
|
||||||
|
ts=int(time.time()),
|
||||||
|
body={"document_root": "11" * 32, "source_uri": "https://x"},
|
||||||
|
)
|
||||||
|
sig_payload = env.canonical_bytes()
|
||||||
|
from aborist.mesh.crypto import sign as _sign
|
||||||
|
sig = _sign(alice_id.sign_priv, sig_payload)
|
||||||
|
|
||||||
|
a_conn = connect(alice_db)
|
||||||
|
try:
|
||||||
|
assert verify_envelope_sig(a_conn, env, sig) is True
|
||||||
|
finally:
|
||||||
|
a_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_envelope_sig_rejects_tampered_body(tmp_path):
|
||||||
|
alice_db, _bob_db, alice_id, _bob_id = _bootstrap_two_peer_db(tmp_path)
|
||||||
|
|
||||||
|
env_orig = WireEnvelope(
|
||||||
|
type=TYPE_ANNOUNCE_ROOT,
|
||||||
|
sender_id="alice",
|
||||||
|
epoch_id=1,
|
||||||
|
ts=100,
|
||||||
|
body={"document_root": "aa" * 32, "source_uri": "https://x"},
|
||||||
|
)
|
||||||
|
from aborist.mesh.crypto import sign as _sign
|
||||||
|
sig = _sign(alice_id.sign_priv, env_orig.canonical_bytes())
|
||||||
|
|
||||||
|
env_tampered = WireEnvelope(
|
||||||
|
type=TYPE_ANNOUNCE_ROOT,
|
||||||
|
sender_id="alice",
|
||||||
|
epoch_id=1,
|
||||||
|
ts=100,
|
||||||
|
body={"document_root": "bb" * 32, "source_uri": "https://x"}, # changed
|
||||||
|
)
|
||||||
|
a_conn = connect(alice_db)
|
||||||
|
try:
|
||||||
|
assert verify_envelope_sig(a_conn, env_tampered, sig) is False
|
||||||
|
finally:
|
||||||
|
a_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_envelope_sig_rejects_unknown_sender(tmp_path):
|
||||||
|
alice_db, _bob_db, _alice_id, _bob_id = _bootstrap_two_peer_db(tmp_path)
|
||||||
|
sign_priv, _sign_pub = generate_signing_keypair()
|
||||||
|
env = WireEnvelope(
|
||||||
|
type=TYPE_ANNOUNCE_ROOT,
|
||||||
|
sender_id="mallory", # not in roster
|
||||||
|
epoch_id=1,
|
||||||
|
ts=0,
|
||||||
|
body={"document_root": "00" * 32, "source_uri": "https://x"},
|
||||||
|
)
|
||||||
|
from aborist.mesh.crypto import sign as _sign
|
||||||
|
sig = _sign(sign_priv, env.canonical_bytes())
|
||||||
|
a_conn = connect(alice_db)
|
||||||
|
try:
|
||||||
|
assert verify_envelope_sig(a_conn, env, sig) is False
|
||||||
|
finally:
|
||||||
|
a_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_envelope_sig_rejects_wrong_key_for_known_member(tmp_path):
|
||||||
|
"""Mallory steals alice's member_id but signs with her own key."""
|
||||||
|
alice_db, _bob_db, _alice_id, _bob_id = _bootstrap_two_peer_db(tmp_path)
|
||||||
|
rogue_priv, _rogue_pub = generate_signing_keypair()
|
||||||
|
env = WireEnvelope(
|
||||||
|
type=TYPE_ANNOUNCE_ROOT,
|
||||||
|
sender_id="alice", # claims to be alice
|
||||||
|
epoch_id=1,
|
||||||
|
ts=0,
|
||||||
|
body={"document_root": "00" * 32, "source_uri": "https://x"},
|
||||||
|
)
|
||||||
|
from aborist.mesh.crypto import sign as _sign
|
||||||
|
sig = _sign(rogue_priv, env.canonical_bytes())
|
||||||
|
a_conn = connect(alice_db)
|
||||||
|
try:
|
||||||
|
assert verify_envelope_sig(a_conn, env, sig) is False
|
||||||
|
finally:
|
||||||
|
a_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server.handle_announce — direct (no HTTP)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_announce_writes_audit_event(tmp_path):
|
||||||
|
"""Bob sends ANNOUNCE_ROOT to Alice's server; Alice's chain gains an event."""
|
||||||
|
from aborist.mesh.wire import MeshWireServer
|
||||||
|
|
||||||
|
alice_db, _bob_db, _alice_id, bob_id = _bootstrap_two_peer_db(tmp_path)
|
||||||
|
|
||||||
|
a_conn = connect(alice_db)
|
||||||
|
try:
|
||||||
|
before = a_conn.execute("SELECT COUNT(*) FROM audit_events").fetchone()[0]
|
||||||
|
finally:
|
||||||
|
a_conn.close()
|
||||||
|
|
||||||
|
env = WireEnvelope(
|
||||||
|
type=TYPE_ANNOUNCE_ROOT,
|
||||||
|
sender_id="bob",
|
||||||
|
epoch_id=1,
|
||||||
|
ts=int(time.time()),
|
||||||
|
body={
|
||||||
|
"document_root": "ab" * 32,
|
||||||
|
"source_uri": "https://en.wikipedia.org/wiki/Cloud_Strife",
|
||||||
|
"chunking_version": "tok-512-v1",
|
||||||
|
"canonicalization_version": "norm-v1",
|
||||||
|
"schema_version": "v9.8.0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
signed = env.sign_with(bob_id.sign_priv)
|
||||||
|
|
||||||
|
srv = MeshWireServer(alice_db, host="127.0.0.1", port=0)
|
||||||
|
try:
|
||||||
|
status, body = srv.handle_announce(signed)
|
||||||
|
assert status == 200, body
|
||||||
|
assert body["wire_type"] == TYPE_ANNOUNCE_ROOT
|
||||||
|
assert "audit_event_hash" in body
|
||||||
|
finally:
|
||||||
|
srv.stop()
|
||||||
|
|
||||||
|
a_conn = connect(alice_db)
|
||||||
|
try:
|
||||||
|
after = a_conn.execute("SELECT COUNT(*) FROM audit_events").fetchone()[0]
|
||||||
|
last = a_conn.execute(
|
||||||
|
"SELECT event_type, body FROM audit_events ORDER BY seq DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
a_conn.close()
|
||||||
|
assert after == before + 1
|
||||||
|
assert last["event_type"] == "mesh_received"
|
||||||
|
import json as _json
|
||||||
|
body_json = _json.loads(last["body"])
|
||||||
|
assert body_json["wire_type"] == TYPE_ANNOUNCE_ROOT
|
||||||
|
assert body_json["sender_id"] == "bob"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_announce_rejects_bad_sig(tmp_path):
|
||||||
|
"""Sig from wrong key → 401, no audit event."""
|
||||||
|
from aborist.mesh.wire import MeshWireServer
|
||||||
|
|
||||||
|
alice_db, _bob_db, _alice_id, _bob_id = _bootstrap_two_peer_db(tmp_path)
|
||||||
|
rogue_priv, _ = generate_signing_keypair()
|
||||||
|
|
||||||
|
env = WireEnvelope(
|
||||||
|
type=TYPE_ANNOUNCE_ROOT,
|
||||||
|
sender_id="bob",
|
||||||
|
epoch_id=1,
|
||||||
|
ts=0,
|
||||||
|
body={"document_root": "00" * 32, "source_uri": "x"},
|
||||||
|
)
|
||||||
|
from aborist.mesh.crypto import sign as _sign
|
||||||
|
sig = _sign(rogue_priv, env.canonical_bytes())
|
||||||
|
import base64 as _b64
|
||||||
|
signed = {"envelope": env.__dict__, "sig_b64": _b64.b64encode(sig).decode()}
|
||||||
|
|
||||||
|
a_conn = connect(alice_db)
|
||||||
|
try:
|
||||||
|
before = a_conn.execute("SELECT COUNT(*) FROM audit_events").fetchone()[0]
|
||||||
|
finally:
|
||||||
|
a_conn.close()
|
||||||
|
|
||||||
|
srv = MeshWireServer(alice_db, host="127.0.0.1", port=0)
|
||||||
|
try:
|
||||||
|
status, body = srv.handle_announce(signed)
|
||||||
|
assert status == 401
|
||||||
|
assert "signature" in body["error"].lower()
|
||||||
|
finally:
|
||||||
|
srv.stop()
|
||||||
|
|
||||||
|
a_conn = connect(alice_db)
|
||||||
|
try:
|
||||||
|
after = a_conn.execute("SELECT COUNT(*) FROM audit_events").fetchone()[0]
|
||||||
|
finally:
|
||||||
|
a_conn.close()
|
||||||
|
assert after == before # rejected: no event written
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_announce_rejects_request_body_type(tmp_path):
|
||||||
|
"""The /announce endpoint refuses REQUEST_BODY / DELIVER_BODY."""
|
||||||
|
from aborist.mesh.wire import MeshWireServer
|
||||||
|
|
||||||
|
alice_db, _bob_db, _alice_id, bob_id = _bootstrap_two_peer_db(tmp_path)
|
||||||
|
env = WireEnvelope(
|
||||||
|
type=TYPE_REQUEST_BODY,
|
||||||
|
sender_id="bob",
|
||||||
|
epoch_id=1,
|
||||||
|
ts=0,
|
||||||
|
body={"root": "00" * 32},
|
||||||
|
)
|
||||||
|
signed = env.sign_with(bob_id.sign_priv)
|
||||||
|
|
||||||
|
srv = MeshWireServer(alice_db, host="127.0.0.1", port=0)
|
||||||
|
try:
|
||||||
|
status, body = srv.handle_announce(signed)
|
||||||
|
assert status == 400
|
||||||
|
assert "rejects" in body["error"]
|
||||||
|
finally:
|
||||||
|
srv.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_request_404_on_unknown_root(tmp_path):
|
||||||
|
"""Asking for a doc the responder doesn't have returns 404 cleanly."""
|
||||||
|
from aborist.mesh.wire import MeshWireServer
|
||||||
|
|
||||||
|
alice_db, _bob_db, _alice_id, bob_id = _bootstrap_two_peer_db(tmp_path)
|
||||||
|
env = WireEnvelope(
|
||||||
|
type=TYPE_REQUEST_BODY,
|
||||||
|
sender_id="bob",
|
||||||
|
epoch_id=1,
|
||||||
|
ts=0,
|
||||||
|
body={"root": "ff" * 32},
|
||||||
|
)
|
||||||
|
signed = env.sign_with(bob_id.sign_priv)
|
||||||
|
srv = MeshWireServer(alice_db, host="127.0.0.1", port=0)
|
||||||
|
try:
|
||||||
|
status, body = srv.handle_announce(signed) # wrong endpoint type → 400
|
||||||
|
assert status == 400
|
||||||
|
status, body = srv.handle_request(signed)
|
||||||
|
assert status == 404
|
||||||
|
finally:
|
||||||
|
srv.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_announce_falsification_round_trips(tmp_path):
|
||||||
|
"""Falsifications propagate as ANNOUNCE_FALSIFICATION."""
|
||||||
|
from aborist.mesh.wire import MeshWireServer
|
||||||
|
|
||||||
|
alice_db, _bob_db, _alice_id, bob_id = _bootstrap_two_peer_db(tmp_path)
|
||||||
|
env = WireEnvelope(
|
||||||
|
type=TYPE_ANNOUNCE_FALSIFICATION,
|
||||||
|
sender_id="bob",
|
||||||
|
epoch_id=1,
|
||||||
|
ts=0,
|
||||||
|
body={"cache_key": "ab" * 32, "reason": "answer was wrong: cited X but X is fictional"},
|
||||||
|
)
|
||||||
|
signed = env.sign_with(bob_id.sign_priv)
|
||||||
|
srv = MeshWireServer(alice_db, host="127.0.0.1", port=0)
|
||||||
|
try:
|
||||||
|
status, body = srv.handle_announce(signed)
|
||||||
|
assert status == 200
|
||||||
|
assert body["wire_type"] == TYPE_ANNOUNCE_FALSIFICATION
|
||||||
|
finally:
|
||||||
|
srv.stop()
|
||||||
186
tests/test_mesh_wire_e2e.py
Normal file
186
tests/test_mesh_wire_e2e.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""Mesh wire end-to-end: two real HTTP peers exchange gossip.
|
||||||
|
|
||||||
|
Each peer binds an ephemeral port via host=127.0.0.1, port=0 and runs
|
||||||
|
ThreadingHTTPServer in a daemon thread. The other peer's MeshWireClient
|
||||||
|
talks to it over real httpx + TCP. No mocking — this exercises the
|
||||||
|
full request/response path including JSON serialization, HTTP framing,
|
||||||
|
and threaded handler dispatch.
|
||||||
|
|
||||||
|
Tests run under timeout via daemon threads; if a test hangs the worst
|
||||||
|
case is the test process exits and pytest times out the test, leaving
|
||||||
|
no zombie threads (daemon=True).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from aborist.document import Document
|
||||||
|
from aborist.ingest import ingest_source
|
||||||
|
from aborist.mesh import init_identity
|
||||||
|
from aborist.mesh.members import add_member
|
||||||
|
from aborist.mesh.wire import (
|
||||||
|
MeshWireClient,
|
||||||
|
MeshWireServer,
|
||||||
|
TYPE_ANNOUNCE_ROOT,
|
||||||
|
)
|
||||||
|
from aborist.store import connect
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def two_peers(tmp_path):
|
||||||
|
"""Spin up alice + bob with mutual rosters and HTTP servers running."""
|
||||||
|
alice_db = tmp_path / "alice.db"
|
||||||
|
bob_db = tmp_path / "bob.db"
|
||||||
|
|
||||||
|
a_conn = connect(alice_db)
|
||||||
|
try:
|
||||||
|
alice = init_identity(a_conn, group_name="t", member_id="alice")
|
||||||
|
finally:
|
||||||
|
a_conn.close()
|
||||||
|
|
||||||
|
b_conn = connect(bob_db)
|
||||||
|
try:
|
||||||
|
bob = init_identity(b_conn, group_name="t", member_id="bob")
|
||||||
|
finally:
|
||||||
|
b_conn.close()
|
||||||
|
|
||||||
|
a_conn = connect(alice_db)
|
||||||
|
try:
|
||||||
|
add_member(a_conn, member_id="bob", sign_pub=bob.sign_pub, dh_pub=bob.dh_pub)
|
||||||
|
finally:
|
||||||
|
a_conn.close()
|
||||||
|
|
||||||
|
b_conn = connect(bob_db)
|
||||||
|
try:
|
||||||
|
add_member(b_conn, member_id="alice", sign_pub=alice.sign_pub, dh_pub=alice.dh_pub)
|
||||||
|
finally:
|
||||||
|
b_conn.close()
|
||||||
|
|
||||||
|
alice_srv = MeshWireServer(alice_db, host="127.0.0.1", port=0)
|
||||||
|
bob_srv = MeshWireServer(bob_db, host="127.0.0.1", port=0)
|
||||||
|
alice_srv.start_in_thread()
|
||||||
|
bob_srv.start_in_thread()
|
||||||
|
try:
|
||||||
|
yield {
|
||||||
|
"alice": {"db": alice_db, "srv": alice_srv, "id": alice},
|
||||||
|
"bob": {"db": bob_db, "srv": bob_srv, "id": bob},
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
alice_srv.stop()
|
||||||
|
bob_srv.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_info_endpoint_returns_member_id(two_peers):
|
||||||
|
"""GET /mesh/info returns the responder's identity (no signature required)."""
|
||||||
|
with MeshWireClient(two_peers["bob"]["db"], two_peers["alice"]["srv"].url) as c:
|
||||||
|
info = c.info()
|
||||||
|
assert info["member_id"] == "alice"
|
||||||
|
assert info["group_name"] == "t"
|
||||||
|
assert info["current_epoch"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_announce_root_round_trip(two_peers):
|
||||||
|
"""Bob announces a doc_root to Alice; Alice's audit chain gains a
|
||||||
|
`mesh_received` event with the announce body."""
|
||||||
|
with MeshWireClient(two_peers["bob"]["db"], two_peers["alice"]["srv"].url) as c:
|
||||||
|
resp = c.announce_root(
|
||||||
|
document_root="ab" * 32,
|
||||||
|
source_uri="https://en.wikipedia.org/wiki/Cloud_Strife",
|
||||||
|
chunking_version="tok-512-v1",
|
||||||
|
canonicalization_version="norm-v1",
|
||||||
|
schema_version="v9.8.0",
|
||||||
|
)
|
||||||
|
assert resp["ok"] is True
|
||||||
|
assert resp["wire_type"] == TYPE_ANNOUNCE_ROOT
|
||||||
|
assert isinstance(resp["audit_event_hash"], str)
|
||||||
|
assert len(resp["audit_event_hash"]) == 64
|
||||||
|
|
||||||
|
a_conn = connect(two_peers["alice"]["db"])
|
||||||
|
try:
|
||||||
|
last = a_conn.execute(
|
||||||
|
"SELECT event_type, body, subject_root FROM audit_events "
|
||||||
|
"ORDER BY seq DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
a_conn.close()
|
||||||
|
assert last["event_type"] == "mesh_received"
|
||||||
|
assert last["subject_root"] == "ab" * 32
|
||||||
|
import json
|
||||||
|
body = json.loads(last["body"])
|
||||||
|
assert body["wire_type"] == TYPE_ANNOUNCE_ROOT
|
||||||
|
assert body["sender_id"] == "bob"
|
||||||
|
assert body["envelope_body"]["source_uri"] == "https://en.wikipedia.org/wiki/Cloud_Strife"
|
||||||
|
|
||||||
|
|
||||||
|
class _OneDocSource:
|
||||||
|
"""One-shot source for tests — yields a single Document."""
|
||||||
|
|
||||||
|
source_type = "wire_e2e"
|
||||||
|
|
||||||
|
def __init__(self, uri: str, content: str, title: str):
|
||||||
|
self._doc = Document(
|
||||||
|
uri=uri, content=content, source_type=self.source_type, title=title
|
||||||
|
)
|
||||||
|
|
||||||
|
def iter_documents(self):
|
||||||
|
yield self._doc
|
||||||
|
|
||||||
|
|
||||||
|
def _ingest_one(db_path, *, uri: str, content: str, title: str) -> str:
|
||||||
|
"""Ingest one document, return its document_root."""
|
||||||
|
conn = connect(db_path)
|
||||||
|
try:
|
||||||
|
ingest_source(conn, _OneDocSource(uri, content, title))
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT document_root FROM documents WHERE document_uri=?", (uri,)
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
assert row is not None, f"document not found post-ingest for {uri!r}"
|
||||||
|
return row["document_root"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_body_pulls_doc_with_verified_merkle(two_peers):
|
||||||
|
"""Alice has a doc; Bob requests it. Client verifies the delivered
|
||||||
|
Merkle root against the requested root before returning."""
|
||||||
|
document_root = _ingest_one(
|
||||||
|
two_peers["alice"]["db"],
|
||||||
|
uri="https://en.wikipedia.org/wiki/test",
|
||||||
|
content="Hello world. The quick brown fox jumps over the lazy dog.",
|
||||||
|
title="Test",
|
||||||
|
)
|
||||||
|
with MeshWireClient(two_peers["bob"]["db"], two_peers["alice"]["srv"].url) as c:
|
||||||
|
delivered = c.request_body(root=document_root)
|
||||||
|
|
||||||
|
assert delivered["root"] == document_root
|
||||||
|
assert delivered["document_uri"] == "https://en.wikipedia.org/wiki/test"
|
||||||
|
assert "Hello world" in delivered["text"]
|
||||||
|
assert isinstance(delivered["leaves_hex"], list)
|
||||||
|
assert len(delivered["leaves_hex"]) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_body_404_on_unknown_root(two_peers):
|
||||||
|
"""Asking for a root the responder doesn't have raises HTTPStatusError."""
|
||||||
|
import httpx
|
||||||
|
with MeshWireClient(two_peers["bob"]["db"], two_peers["alice"]["srv"].url) as c:
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
c.request_body(root="ff" * 32)
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_body_rejects_tampered_response(two_peers, monkeypatch):
|
||||||
|
"""If a peer returns a DELIVER_BODY whose leaves don't Merkle-derive
|
||||||
|
to the claimed root, the client raises before returning."""
|
||||||
|
document_root = _ingest_one(
|
||||||
|
two_peers["alice"]["db"],
|
||||||
|
uri="https://en.wikipedia.org/wiki/x",
|
||||||
|
content="real content of the document",
|
||||||
|
title="X",
|
||||||
|
)
|
||||||
|
with MeshWireClient(two_peers["bob"]["db"], two_peers["alice"]["srv"].url) as c:
|
||||||
|
from aborist.mesh import wire as _wire
|
||||||
|
monkeypatch.setattr(_wire, "_merkle_root_matches", lambda *a, **k: False)
|
||||||
|
with pytest.raises(ValueError, match="Merkle-derive"):
|
||||||
|
c.request_body(root=document_root)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue