R6: multi-house federation — signed roster directory + blind bridge
federation.py adds both federation modes as offline-verifiable logic (no socket, no engine, no external target — this measures a trust model, it provides anonymity to no one). directory-federation: a signature-gated HOUSE-PEER roster (PeerRoster/build_peer_frame/parse_peer_frame, Ed25519-signed, forged/unsigned rejected) merged into a pubkey->house Directory, with select_federated_path drawing a seed-deterministic circuit that spans >=2 houses and refuses to collapse to one — so no single house's logs hold every hop identity (RQ2 split knowledge). bridge-member: BlindBridge holds no room key and relays only opaque SOR tunnel bytes verbatim (chat/consent/unknown frames refused, plaintext never read), emitting metadata-only bridge_forward R3 events. Acceptance check green: roster signature-gated, path spans two houses, bridge is plaintext-blind. Python federation suite 10 passed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""R6 — multi-house federation + bridge acceptance check.
|
||||
|
||||
Acceptance predicate (roadmap R6): a circuit spans two house instances; in
|
||||
bridge mode the bridge process can read neither room's chat plaintext (only
|
||||
tunnel bytes); in directory mode no single node's logs contain all hop identities
|
||||
of a circuit.
|
||||
|
||||
This suite encodes both halves as deterministic offline checks (no sockets, no
|
||||
engine — containment stays law):
|
||||
|
||||
* directory-federation — a HOUSE-PEER roster is signature-gated (forged/unsigned
|
||||
rejected), and a federated path spans >= 2 houses with no single house holding
|
||||
every hop (split knowledge);
|
||||
* bridge-member — a BlindBridge relays only SOR tunnel bytes (opaque, verbatim),
|
||||
holds no room key, and cannot read a room's chat ciphertext.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from cmd_chat.sor.consent import _b64e, persona_sign
|
||||
from cmd_chat.sor.events import EventLog
|
||||
from cmd_chat.sor.federation import (
|
||||
BlindBridge,
|
||||
Directory,
|
||||
PeerRoster,
|
||||
build_peer_frame,
|
||||
build_tunnel_frame,
|
||||
parse_peer_frame,
|
||||
path_span_ok,
|
||||
)
|
||||
|
||||
|
||||
def _persona() -> tuple[bytes, str]:
|
||||
"""A throwaway Ed25519 persona -> (secret_raw, pub_b64)."""
|
||||
sk = Ed25519PrivateKey.generate()
|
||||
secret_raw = sk.private_bytes_raw()
|
||||
pub_b64 = _b64e(sk.public_key().public_bytes_raw())
|
||||
return secret_raw, pub_b64
|
||||
|
||||
|
||||
def _house(house_id: str, n_members: int):
|
||||
"""Build a signed roster for a house with `n_members` member personas."""
|
||||
host_secret, host_pub = _persona()
|
||||
members = [_persona()[1] for _ in range(n_members)]
|
||||
frame = build_peer_frame(host_secret, host_pub, house_id, members)
|
||||
return host_secret, host_pub, members, frame
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# directory-federation — signed roster, split-knowledge path.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_valid_roster_round_trips_and_verifies():
|
||||
_, host_pub, members, frame = _house("house-A", 3)
|
||||
roster = parse_peer_frame(frame)
|
||||
assert roster is not None
|
||||
assert roster.signature_ok()
|
||||
assert roster.house_id == "house-A"
|
||||
assert roster.host_ed_pub == host_pub
|
||||
assert set(roster.member_pubs) == set(members)
|
||||
|
||||
|
||||
def test_unsigned_or_forged_roster_is_rejected():
|
||||
# Forge: valid members but a signature from a *different* persona.
|
||||
_, host_pub, members, _ = _house("house-A", 3)
|
||||
other_secret, _ = _persona()
|
||||
forged = PeerRoster(
|
||||
"house-A", host_pub, tuple(sorted(members)),
|
||||
sig=persona_sign(other_secret, b"not the roster bytes"),
|
||||
)
|
||||
assert forged.signature_ok() is False
|
||||
# And a directory refuses to merge it.
|
||||
d = Directory()
|
||||
assert d.add_roster(forged) is False
|
||||
assert d.houses() == []
|
||||
|
||||
|
||||
def test_garbage_frame_parses_to_none_without_raising():
|
||||
for junk in ["", "{", "not json", "{}", '{"_sor":{}}', '{"_sor":{"op":"peer"}}']:
|
||||
assert parse_peer_frame(junk) is None
|
||||
|
||||
|
||||
def test_federated_path_spans_two_houses_no_single_house_has_all_hops():
|
||||
d = Directory()
|
||||
for hid in ("house-A", "house-B"):
|
||||
_, _, _, frame = _house(hid, 3)
|
||||
assert d.add_roster(parse_peer_frame(frame)) is True
|
||||
|
||||
path = d.select_federated_path(seed=0xF00D, hops=3, min_houses=2)
|
||||
assert len(path) == 3
|
||||
# Distinct nodes.
|
||||
assert len({pub for pub, _ in path}) == 3
|
||||
# Spans >= 2 houses -> no single house's logs hold every hop identity.
|
||||
assert path_span_ok(path, min_houses=2)
|
||||
houses_hit = {house for _, house in path}
|
||||
assert len(houses_hit) >= 2
|
||||
for h in houses_hit:
|
||||
assert sum(1 for _, hh in path if hh == h) < len(path)
|
||||
|
||||
|
||||
def test_federated_path_is_seed_deterministic():
|
||||
d = Directory()
|
||||
for hid in ("house-A", "house-B"):
|
||||
_, _, _, frame = _house(hid, 4)
|
||||
d.add_roster(parse_peer_frame(frame))
|
||||
assert d.select_federated_path(7, 3) == d.select_federated_path(7, 3)
|
||||
|
||||
|
||||
def test_single_house_cannot_satisfy_span():
|
||||
d = Directory()
|
||||
_, _, _, frame = _house("house-A", 5)
|
||||
d.add_roster(parse_peer_frame(frame))
|
||||
# One house can never span two -> refuse rather than collapse.
|
||||
with pytest.raises(ValueError):
|
||||
d.select_federated_path(1, hops=3, min_houses=2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# bridge-member — blind tunnel forward, plaintext-blind.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_bridge_forwards_only_tunnel_bytes_verbatim():
|
||||
bridge = BlindBridge("house-A", "house-B")
|
||||
onion = b"\x00\x01opaque-onion-ciphertext\xff"
|
||||
frame = build_tunnel_frame("c0", 0, onion)
|
||||
out = bridge.forward("house-A", frame)
|
||||
assert out == onion # exact opaque bytes, never altered/decrypted
|
||||
assert len(bridge.forwarded) == 1
|
||||
rec = bridge.forwarded[0]
|
||||
assert rec.circuit_id == "c0" and rec.n_bytes == len(onion)
|
||||
assert rec.src_house == "house-A" and rec.dst_house == "house-B"
|
||||
|
||||
|
||||
def test_bridge_refuses_non_tunnel_frames():
|
||||
bridge = BlindBridge("house-A", "house-B")
|
||||
# A room chat frame (opaque ciphertext) is NOT a tunnel frame -> not forwarded.
|
||||
chat = '{"type":"message","data":{"text":"<room-encrypted-blob>"}}'
|
||||
assert bridge.forward("house-A", chat) is None
|
||||
# Consent frames are not tunnel bytes either.
|
||||
assert bridge.forward("house-A", '{"_sor":{"op":"request"}}') is None
|
||||
for junk in ["", "{", "not json", '{"_sor":{"op":"tunnel"}}']:
|
||||
assert bridge.forward("house-A", junk) is None
|
||||
assert bridge.forwarded == []
|
||||
|
||||
|
||||
def test_bridge_holds_no_room_key_and_cannot_read_chat():
|
||||
bridge = BlindBridge("house-A", "house-B")
|
||||
assert bridge.has_room_key("house-A") is False
|
||||
assert bridge.has_room_key("house-B") is False
|
||||
# Even handed a room's chat ciphertext, it returns no plaintext.
|
||||
assert bridge.try_read_chat("house-A", b"any-room-ciphertext") is None
|
||||
|
||||
|
||||
def test_bridge_emits_bridge_forward_events(tmp_path):
|
||||
log = EventLog(tmp_path, "fed-run", seed=1)
|
||||
bridge = BlindBridge("house-A", "house-B", log=log)
|
||||
for seq in range(3):
|
||||
bridge.forward("house-A", build_tunnel_frame("c0", seq, b"x" * (10 + seq)))
|
||||
sha = log.close()
|
||||
lines = (tmp_path / "events.jsonl").read_text().strip().splitlines()
|
||||
assert len(lines) == 3
|
||||
assert all('"event":"bridge_forward"' in ln for ln in lines)
|
||||
assert len(sha) == 64
|
||||
Reference in New Issue
Block a user