arborist/tests/test_mesh_wire.py
russell@unturf.com f141babde1
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)
2026-04-28 16:57:04 -04:00

447 lines
14 KiB
Python

"""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()