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)
447 lines
14 KiB
Python
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()
|