"""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":""}}' 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