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
542 lines
19 KiB
Python
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)
|