arborist/tests/test_mesh_aead.py
russell@unturf.com 8d6961fcc1
aborist/arborist
modified:   .gitlab-ci.yml
	modified:   bench/qa_questions.txt
	modified:   bench/qa_sweep.py
	modified:   bench/run.sh
	modified:   docs/TICKETS.md
	modified:   docs/_source/README.md
	modified:   docs/_source/_ext/makefile_targets.py
	modified:   docs/_source/api/cli.rst
	modified:   docs/_source/api/distill.rst
	modified:   docs/_source/api/mesh.rst
	modified:   docs/_source/api/qa.rst
	modified:   docs/_source/api/retrieval.rst
	modified:   docs/_source/api/storage.rst
	modified:   docs/_source/api/substrate.rst
	modified:   docs/_source/concepts.rst
	modified:   docs/_source/conf.py
	modified:   docs/_source/cookbook.rst
	modified:   docs/_source/index.rst
	modified:   docs/_source/license.rst
	modified:   docs/_source/quickstart.rst
	modified:   docs/bench-maxing.md
	modified:   docs/benchmarks.md
	modified:   docs/cti-architecture.md
	modified:   docs/diagrams/aborist-modules.dot
	modified:   docs/diagrams/aborist-modules.svg
	modified:   docs/diagrams/mesh-data-flow.dot
	modified:   docs/diagrams/mesh-epoch-lifecycle.dot
	modified:   docs/diagrams/mesh-epoch-lifecycle.svg
	modified:   docs/diagrams/mesh-group-decisions.dot
	modified:   docs/diagrams/mesh-group-decisions.svg
	modified:   docs/diagrams/mesh-identity-stack.dot
	modified:   docs/diagrams/mesh-secret-envelope.dot
	modified:   docs/mesh.md
	modified:   docs/qa-modes-bench.md
	modified:   docs/seven-point-program.md
	modified:   docs/tickets/ticket-000001-retrieval-keywords-audit-gap.md
	modified:   docs/tickets/ticket-000002-reference-frame-polarity-contract.md
	modified:   docs/tickets/ticket-000003-anchor-class-warrant.md
	modified:   docs/tickets/ticket-000005-label-ladder-migration.md
	modified:   docs/tickets/ticket-000006-bench-emergent-findings.md
	modified:   docs/tickets/ticket-000007-query-layer-hyphen-fold.md
	modified:   docs/tickets/ticket-000008-broad-quantifier-preflight-guard.md
	modified:   docs/tickets/ticket-000009-quantifier-preflight-dag-binding.md
	modified:   docs/tickets/ticket-000010-metacognition-preflight-guard.md
	modified:   docs/tickets/ticket-000011-soft-preflight-hint-sidecar.md
	modified:   scripts/backfill_concepts.py
	modified:   scripts/bench_emergent.py
	modified:   tests/crawler/test_async_web_fetcher.py
	modified:   tests/crawler/test_bridge.py
	modified:   tests/crawler/test_web_fetch.py
	modified:   tests/test_bench_qa_sweep.py
	modified:   tests/test_burn.py
	modified:   tests/test_burn_doc.py
	modified:   tests/test_claim_lattice.py
	modified:   tests/test_cli_render.py
	modified:   tests/test_compress.py
	modified:   tests/test_concepts.py
	modified:   tests/test_dag.py
	modified:   tests/test_directives.py
	modified:   tests/test_distill.py
	modified:   tests/test_distill_recursive.py
	modified:   tests/test_evict.py
	modified:   tests/test_frame.py
	modified:   tests/test_grok_source.py
	modified:   tests/test_html_source.py
	modified:   tests/test_ingest.py
	modified:   tests/test_inspect.py
	modified:   tests/test_journal.py
	modified:   tests/test_keys.py
	modified:   tests/test_llm_context_base.py
	modified:   tests/test_merkle.py
	modified:   tests/test_mesh.py
	modified:   tests/test_mesh_aead.py
	modified:   tests/test_mesh_chain.py
	modified:   tests/test_mesh_cli.py
	modified:   tests/test_mesh_cli_pull.py
	modified:   tests/test_mesh_wire.py
	modified:   tests/test_mesh_wire_e2e.py
	modified:   tests/test_metacognition.py
	modified:   tests/test_migration_audit_mode.py
	modified:   tests/test_providence_source.py
	modified:   tests/test_qa.py
	modified:   tests/test_qa_quality_live.py
	modified:   tests/test_quantifier_caps.py
	modified:   tests/test_quantifier_classifier.py
	modified:   tests/test_quantifier_phase4.py
	modified:   tests/test_quantifier_reminder.py
	modified:   tests/test_query.py
	modified:   tests/test_reclassify.py
	modified:   tests/test_repair.py
	modified:   tests/test_resume.py
	modified:   tests/test_snapshot.py
	modified:   tests/test_soft_preflight.py
	modified:   tests/test_tfidf.py
	modified:   tests/test_vcs_source.py
	modified:   tests/test_verify.py
	modified:   tests/test_verify_json.py
	modified:   tests/test_versioned_ingest.py
	modified:   tests/test_warrant.py
	modified:   tests/test_wikipedia_old.py
	modified:   tests/test_wikipedia_xml.py
	modified:   tests/test_wikitext.py
2026-05-07 09:31:49 -04:00

542 lines
19 KiB
Python

"""Mesh wire — AEAD-encrypted gossip bodies.
Cleartext gossip stays the default; this module verifies the opt-in
encryption path. A sender flips ``encrypt=True`` on an announce call,
the body ships AEAD-protected under the per-epoch shared secret, and
a current member of the same epoch decrypts via their wrapped slot in
the secret envelope.
The signature still spans the full envelope (including the ciphertext),
so a MITM cannot swap payloads without invalidating sig verification.
Receivers without a slot in the epoch envelope (evicted / never
enrolled) get a clean 401 — confidentiality failure mirrors auth
failure at the wire layer.
"""
from __future__ import annotations
import base64
import time
from pathlib import Path
import pytest
from arborist.mesh import init_identity
from arborist.mesh.members import add_member
from arborist.mesh.state import recover_epoch_secret
from arborist.mesh.wire import (
MeshWireServer,
TYPE_ANNOUNCE_FALSIFICATION,
TYPE_ANNOUNCE_ROOT,
WireEnvelope,
decrypt_envelope_body,
encrypt_body_for_envelope,
)
from arborist.store import connect
def _bootstrap_two_peer_db(tmp_path: Path) -> tuple[Path, Path, dict, dict]:
"""alice + bob in the same group, sharing epoch 1's secret envelope.
Real federation invariant: every peer at the same epoch sees the
SAME `mesh_epochs.secret_envelope`. Without that, sender-side
encryption under one DB's epoch secret cannot decrypt under
another DB's epoch secret. The signing-only fixture in
`test_mesh_wire.py` independently rotates each side, which is fine
for sig checks but breaks AEAD by construction.
Here we let alice rotate (genesis+add_member(bob)), then mirror
her epoch 1 `mesh_epochs` row + `mesh_roster` rows + matching
audit event into bob's DB. Same wrapped secret on both sides;
bob's `recover_epoch_secret` resolves alice as actor and unwraps
bob's slot using alice's dh_pub. The same path the wire receiver
walks at runtime.
"""
import json as _json
import sqlite3 as _sqlite3
from arborist.store import append_audit, latest_event_hash
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()
# Alice rotates to epoch 1, adding bob. Her DB now has epoch-1
# secret_envelope wrapped to {alice, bob} via alice's dh_priv.
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",
)
epoch1_row = a_conn.execute(
"SELECT epoch_id, started_at, started_event_hash, "
"secret_envelope, reason FROM mesh_epochs WHERE epoch_id=1"
).fetchone()
alice_roster_e1 = a_conn.execute(
"SELECT member_id, sign_pub, dh_pub, role FROM mesh_roster "
"WHERE epoch_id=1 ORDER BY member_id"
).fetchall()
# Pull the rotation audit event so we can replay its body on bob's DB.
rot_audit = a_conn.execute(
"SELECT body, ts FROM audit_events WHERE event_hash=?",
(epoch1_row["started_event_hash"],),
).fetchone()
finally:
a_conn.close()
# Mirror epoch-1 onto bob's DB. Bob also needs alice's pubs in his
# roster_at(0) so the roster lookup at epoch 1 (the rotator alice's
# dh_pub) resolves. The rotation audit event must replay exactly so
# the chain links cleanly; we use append_audit which recomputes the
# event_hash deterministically — and since bob's chain is shorter
# we just append a fresh rotate event with alice as actor, then
# backfill epoch row's started_event_hash to that hash.
b_conn = connect(bob_db)
try:
# Replay the rotate event on bob's chain. body is canonical-JSON.
rot_body = _json.loads(rot_audit["body"])
replayed_hash = append_audit(
b_conn,
event_type="mesh_epoch_rotate",
body=rot_body,
ts=int(rot_audit["ts"]),
)
# Insert the full epoch-1 roster (alice + bob) into bob's DB.
for r in alice_roster_e1:
b_conn.execute(
"INSERT OR REPLACE INTO mesh_roster "
"(epoch_id, member_id, sign_pub, dh_pub, role) "
"VALUES (1, ?, ?, ?, ?)",
(r["member_id"], r["sign_pub"], r["dh_pub"], r["role"]),
)
# Insert the SAME secret_envelope row so bob unwraps the same key.
b_conn.execute(
"INSERT OR REPLACE INTO mesh_epochs "
"(epoch_id, started_at, started_event_hash, secret_envelope, reason) "
"VALUES (1, ?, ?, ?, ?)",
(
int(epoch1_row["started_at"]),
replayed_hash,
epoch1_row["secret_envelope"],
epoch1_row["reason"],
),
)
b_conn.commit()
finally:
b_conn.close()
return alice_db, bob_db, alice_id, bob_id
def _ann_root_body() -> dict:
return {
"document_root": "ab" * 32,
"source_uri": "https://en.wikipedia.org/wiki/Confidential",
"chunking_version": "tok-512-v1",
"canonicalization_version": "norm-v1",
"schema_version": "v9.8.0",
}
def _build_encrypted_announce(
db_path: Path,
sign_priv: bytes,
*,
sender_id: str,
epoch_id: int,
ts: int | None = None,
body: dict | None = None,
) -> tuple[WireEnvelope, dict]:
"""Construct + sign an encrypted ANNOUNCE_ROOT envelope from a peer.
Returns (envelope, signed_payload). The envelope dataclass instance
is handy for tests that want to mutate ciphertext before re-signing.
"""
if body is None:
body = _ann_root_body()
if ts is None:
ts = int(time.time())
conn = connect(db_path)
try:
secret = recover_epoch_secret(conn, epoch_id=epoch_id)
finally:
conn.close()
encrypted = encrypt_body_for_envelope(
type_=TYPE_ANNOUNCE_ROOT,
sender_id=sender_id,
epoch_id=epoch_id,
ts=ts,
body=body,
epoch_secret=secret,
)
env = WireEnvelope(
type=TYPE_ANNOUNCE_ROOT,
sender_id=sender_id,
epoch_id=epoch_id,
ts=ts,
body={},
encrypted_body=encrypted,
)
return env, env.sign_with(sign_priv)
# ---------------------------------------------------------------------------
# 1. Round-trip: alice encrypts, bob (current member) decrypts + accepts.
# ---------------------------------------------------------------------------
def test_encrypted_announce_round_trip(tmp_path):
"""Alice sends an encrypted ANNOUNCE_ROOT; bob's server decrypts and
accepts. The audit_event reflects the cleartext body and marks
`encrypted=True` for operator visibility."""
alice_db, bob_db, alice_id, _bob_id = _bootstrap_two_peer_db(tmp_path)
env, signed = _build_encrypted_announce(
alice_db, alice_id.sign_priv, sender_id="alice", epoch_id=1
)
# Ciphertext must NOT contain the plaintext doc_root.
assert env.encrypted_body is not None
ct_b64 = env.encrypted_body["ct_b64"]
assert "document_root" not in base64.b64decode(ct_b64).decode(
"utf-8", errors="ignore"
)
srv = MeshWireServer(bob_db, host="127.0.0.1", port=0)
try:
status, body = srv.handle_announce(signed)
finally:
srv.stop()
assert status == 200, body
assert body["wire_type"] == TYPE_ANNOUNCE_ROOT
b_conn = connect(bob_db)
try:
last = b_conn.execute(
"SELECT body, subject_root FROM audit_events ORDER BY seq DESC LIMIT 1"
).fetchone()
finally:
b_conn.close()
import json
body_json = json.loads(last["body"])
assert body_json["encrypted"] is True
# Decrypted plaintext threaded into the audit body.
assert body_json["envelope_body"]["document_root"] == "ab" * 32
assert last["subject_root"] == "ab" * 32
# ---------------------------------------------------------------------------
# 2. Receiver missing the epoch secret — evicted member returns 401.
# ---------------------------------------------------------------------------
def test_encrypted_announce_evicted_receiver_401(tmp_path):
"""Carol has alice in her roster (so signature verifies) but no slot
in the epoch envelope (she manufactured her own genesis). The
decrypt path therefore fails and `handle_announce` returns 401.
"""
alice_db, _bob_db, alice_id, _bob_id = _bootstrap_two_peer_db(tmp_path)
carol_db = tmp_path / "carol.db"
c_conn = connect(carol_db)
try:
carol_id = init_identity(c_conn, group_name="t", member_id="carol")
# Splice alice into carol's epoch-1 roster directly so the SIG
# check passes — but carol's secret_envelope at epoch 1 has
# carol's slot only (she was its founder), wrapped to carol's
# OWN dh keys, not alice's. So decrypt under that slot yields
# the wrong key for alice's ciphertext → AEAD tag mismatch.
# First bump carol's epoch to 1 by adding alice via add_member,
# which creates a fresh epoch 1 wrapped to carol+alice.
add_member(
c_conn,
member_id="alice",
sign_pub=alice_id.sign_pub,
dh_pub=alice_id.dh_pub,
role="member",
)
finally:
c_conn.close()
# Build an encrypted ANNOUNCE on alice's DB (alice's epoch-1 secret).
_env, signed = _build_encrypted_announce(
alice_db, alice_id.sign_priv, sender_id="alice", epoch_id=1
)
# Send to carol's server. Sig verifies (alice is in carol's roster),
# but carol's epoch-1 secret differs from alice's → AEAD fails.
srv = MeshWireServer(carol_db, host="127.0.0.1", port=0)
try:
status, body = srv.handle_announce(signed)
finally:
srv.stop()
assert status == 401
assert "AEAD decrypt" in body["error"] or "decrypt" in body["error"].lower()
# No audit event was written for the rejected message.
c_conn = connect(carol_db)
try:
last = c_conn.execute(
"SELECT event_type FROM audit_events ORDER BY seq DESC LIMIT 1"
).fetchone()
finally:
c_conn.close()
# The most recent event should NOT be a `mesh_received` for the rejected msg.
assert last is None or last["event_type"] != "mesh_received"
# ---------------------------------------------------------------------------
# 3. Tampered ciphertext: sig stays valid (we re-sign) but AEAD fails.
# ---------------------------------------------------------------------------
def test_encrypted_announce_tampered_ciphertext_401(tmp_path):
"""Flip a byte of the ciphertext and re-sign with a valid key
(alice's). The Ed25519 sig still verifies — the protection here
comes from AEAD's authentication tag, which detects the tamper.
"""
alice_db, bob_db, alice_id, _bob_id = _bootstrap_two_peer_db(tmp_path)
env, _signed = _build_encrypted_announce(
alice_db, alice_id.sign_priv, sender_id="alice", epoch_id=1
)
# Flip a byte inside ct_b64.
assert env.encrypted_body is not None
raw_ct = bytearray(base64.b64decode(env.encrypted_body["ct_b64"]))
raw_ct[0] ^= 0x01
tampered_ct_b64 = base64.b64encode(bytes(raw_ct)).decode("ascii")
tampered_env = WireEnvelope(
type=env.type,
sender_id=env.sender_id,
epoch_id=env.epoch_id,
ts=env.ts,
body=env.body,
encrypted_body={
"nonce_b64": env.encrypted_body["nonce_b64"],
"ct_b64": tampered_ct_b64,
},
)
signed = tampered_env.sign_with(alice_id.sign_priv)
srv = MeshWireServer(bob_db, host="127.0.0.1", port=0)
try:
status, body = srv.handle_announce(signed)
finally:
srv.stop()
assert status == 401
assert "decrypt" in body["error"].lower()
# ---------------------------------------------------------------------------
# 4. Wrong nonce / wrong AAD — both yield AEAD tag mismatch.
# ---------------------------------------------------------------------------
def test_encrypted_announce_swapped_nonce_401(tmp_path):
"""Re-sign with a freshly randomized nonce (key + AAD unchanged).
AEAD won't authenticate.
"""
alice_db, bob_db, alice_id, _bob_id = _bootstrap_two_peer_db(tmp_path)
env, _signed = _build_encrypted_announce(
alice_db, alice_id.sign_priv, sender_id="alice", epoch_id=1
)
assert env.encrypted_body is not None
import os as _os
bad_nonce_b64 = base64.b64encode(_os.urandom(12)).decode("ascii")
tampered_env = WireEnvelope(
type=env.type,
sender_id=env.sender_id,
epoch_id=env.epoch_id,
ts=env.ts,
body=env.body,
encrypted_body={
"nonce_b64": bad_nonce_b64,
"ct_b64": env.encrypted_body["ct_b64"],
},
)
signed = tampered_env.sign_with(alice_id.sign_priv)
srv = MeshWireServer(bob_db, host="127.0.0.1", port=0)
try:
status, body = srv.handle_announce(signed)
finally:
srv.stop()
assert status == 401
def test_encrypted_announce_aad_drift_401(tmp_path):
"""Build a ciphertext with one ts, then sign an envelope with a
different ts. AAD is recomputed receiver-side from the envelope
metadata, so it won't match — AEAD fails.
"""
alice_db, bob_db, alice_id, _bob_id = _bootstrap_two_peer_db(tmp_path)
env, _signed = _build_encrypted_announce(
alice_db, alice_id.sign_priv, sender_id="alice", epoch_id=1, ts=1000
)
assert env.encrypted_body is not None
drifted_env = WireEnvelope(
type=env.type,
sender_id=env.sender_id,
epoch_id=env.epoch_id,
ts=2000, # different ts → AAD changes
body=env.body,
encrypted_body=env.encrypted_body,
)
signed = drifted_env.sign_with(alice_id.sign_priv)
srv = MeshWireServer(bob_db, host="127.0.0.1", port=0)
try:
status, body = srv.handle_announce(signed)
finally:
srv.stop()
assert status == 401
# ---------------------------------------------------------------------------
# 5. Mixed mode: cleartext + encrypted from the same client both flow.
# ---------------------------------------------------------------------------
def test_mixed_cleartext_and_encrypted_round_trip(tmp_path):
"""Alice sends one cleartext ANNOUNCE_ROOT, then one encrypted
ANNOUNCE_FALSIFICATION. Bob accepts both; audit chain logs the
`encrypted` flag accurately on each."""
from arborist.mesh.wire import MeshWireClient
alice_db, bob_db, _alice_id, _bob_id = _bootstrap_two_peer_db(tmp_path)
bob_srv = MeshWireServer(bob_db, host="127.0.0.1", port=0)
bob_srv.start_in_thread()
try:
with MeshWireClient(alice_db, bob_srv.url) as c:
r1 = c.announce_root(
document_root="cd" * 32,
source_uri="https://x/cleartext",
chunking_version="tok-512-v1",
canonicalization_version="norm-v1",
schema_version="v9.8.0",
)
r2 = c.announce_falsification(
cache_key="ef" * 32,
reason="encrypted falsification",
encrypt=True,
)
assert r1["wire_type"] == TYPE_ANNOUNCE_ROOT
assert r2["wire_type"] == TYPE_ANNOUNCE_FALSIFICATION
finally:
bob_srv.stop()
b_conn = connect(bob_db)
try:
rows = b_conn.execute(
"SELECT body FROM audit_events WHERE event_type='mesh_received' "
"ORDER BY seq DESC LIMIT 2"
).fetchall()
finally:
b_conn.close()
import json
bodies = [json.loads(r["body"]) for r in rows]
# rows are descending; second message (encrypted falsification) is newest
enc, plain = bodies[0], bodies[1]
assert enc["wire_type"] == TYPE_ANNOUNCE_FALSIFICATION
assert enc["encrypted"] is True
assert enc["envelope_body"]["cache_key"] == "ef" * 32
assert plain["wire_type"] == TYPE_ANNOUNCE_ROOT
assert plain["encrypted"] is False
assert plain["envelope_body"]["document_root"] == "cd" * 32
# ---------------------------------------------------------------------------
# 6. Cleartext-only mode (current default) — wire format unchanged.
# ---------------------------------------------------------------------------
def test_cleartext_default_envelope_unchanged(tmp_path):
"""An envelope with `encrypt=False` (default) MUST canonicalize
identically to the v1 wire format — no `encrypted_body` field on
the canonical bytes, no signature change, no audit-body change.
"""
from arborist.mesh.wire import MeshWireClient
alice_db, bob_db, _alice_id, _bob_id = _bootstrap_two_peer_db(tmp_path)
bob_srv = MeshWireServer(bob_db, host="127.0.0.1", port=0)
bob_srv.start_in_thread()
try:
with MeshWireClient(alice_db, bob_srv.url) as c:
r = c.announce_root(
document_root="01" * 32,
source_uri="https://x/plain",
chunking_version="tok-512-v1",
canonicalization_version="norm-v1",
schema_version="v9.8.0",
)
assert r["ok"] is True
finally:
bob_srv.stop()
b_conn = connect(bob_db)
try:
last = b_conn.execute(
"SELECT body FROM audit_events WHERE event_type='mesh_received' "
"ORDER BY seq DESC LIMIT 1"
).fetchone()
finally:
b_conn.close()
import json
body_json = json.loads(last["body"])
assert body_json["encrypted"] is False
assert body_json["envelope_body"]["document_root"] == "01" * 32
# ---------------------------------------------------------------------------
# Hardening: round-trip the AEAD primitive directly (no HTTP).
# ---------------------------------------------------------------------------
def test_decrypt_envelope_body_direct(tmp_path):
"""Sanity check: encrypt_body_for_envelope + decrypt_envelope_body
are exact inverses given the same key, AAD, and metadata."""
alice_db, _bob_db, _alice_id, _bob_id = _bootstrap_two_peer_db(tmp_path)
a_conn = connect(alice_db)
try:
secret = recover_epoch_secret(a_conn, epoch_id=1)
finally:
a_conn.close()
body = {"document_root": "11" * 32, "source_uri": "https://x"}
enc = encrypt_body_for_envelope(
type_=TYPE_ANNOUNCE_ROOT,
sender_id="alice",
epoch_id=1,
ts=42,
body=body,
epoch_secret=secret,
)
env = WireEnvelope(
type=TYPE_ANNOUNCE_ROOT,
sender_id="alice",
epoch_id=1,
ts=42,
body={},
encrypted_body=enc,
)
decoded = decrypt_envelope_body(env, secret)
assert decoded == body
def test_decrypt_envelope_body_rejects_no_encrypted_body():
env = WireEnvelope(
type=TYPE_ANNOUNCE_ROOT,
sender_id="alice",
epoch_id=1,
ts=0,
body={"document_root": "00" * 32},
)
with pytest.raises(ValueError, match="no encrypted_body"):
decrypt_envelope_body(env, b"\x00" * 32)