From 21263da8ca8b6ba830215517d85082d32f5752ec Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Sun, 19 Jul 2026 17:14:01 -0700 Subject: [PATCH] =?UTF-8?q?R6:=20multi-house=20federation=20=E2=80=94=20si?= =?UTF-8?q?gned=20roster=20directory=20+=20blind=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd_chat/sor/federation.py | 317 +++++++++++++++++++++++++++++++++++ tests/test_sor_federation.py | 165 ++++++++++++++++++ 2 files changed, 482 insertions(+) create mode 100644 cmd_chat/sor/federation.py create mode 100644 tests/test_sor_federation.py diff --git a/cmd_chat/sor/federation.py b/cmd_chat/sor/federation.py new file mode 100644 index 0000000..026413f --- /dev/null +++ b/cmd_chat/sor/federation.py @@ -0,0 +1,317 @@ +"""R6 — Multi-house federation + bridge node (measurement instrument). + +Two federation modes, both built as *offline-verifiable logic* (no sockets, no +engine, no external target — containment stays law; this module moves no real +traffic and provides anonymity to no one, it only lets the study *measure* two +trust properties): + +1. **directory-federation** (:class:`PeerRoster` / :class:`Directory`). Houses + exchange a signed persona roster in a ``{"_sor":{"op":"peer",...}}`` HOUSE-PEER + control frame (the same zero-knowledge-server-invisible channel R5 consent uses). + A roster is Ed25519-signed by the announcing host and **rejected unless the + signature verifies** (mirrors the R5 signature-gate discipline). A host merges + validated rosters into a pubkey->house directory and builds a circuit that + **spans >= 2 houses**, so no single house's node set covers every hop — the + split-knowledge property the RQ2 acceptance check asserts ("no single node's + logs contain all hop identities of a circuit"). + +2. **bridge-member** (:class:`BlindBridge`). A node that has joined two houses and + **blind-forwards SOR tunnel bytes only**. It holds *no* room key for either + house, so it structurally cannot read either room's chat plaintext — it relays + opaque, already-onion-encrypted tunnel payloads verbatim and refuses (cannot + open) anything else. Every relayed payload emits an R3 ``bridge_forward`` event + (metadata only: circuit id, byte count — never plaintext). + +Determinism comes from the R1 ``SorRng`` so a federated path is reproducible from +its seed alone. Signing/verification reuses the R5 persona primitives verbatim. +""" + +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +from cmd_chat.sor.config import Domain, SorRng +from cmd_chat.sor.consent import CONSENT_CTX, persona_sign, persona_verify +from cmd_chat.sor.events import EventLog + +# HOUSE-PEER op value on the wire (the R5 frame parser already tags op=="peer"). +PEER_OP = "peer" + + +# --------------------------------------------------------------------------- # +# directory-federation — signed persona roster exchange. +# --------------------------------------------------------------------------- # +def _roster_canonical(house_id: str, host_ed_pub: str, member_pubs: List[str]) -> bytes: + """Canonical signed bytes for a roster. Members are sorted so the signature + is order-independent (the roster is a *set* of pubkeys, not a sequence). + Domain-separated with the shared CONSENT_CTX + a ``peer`` tag so a roster + signature can never be replayed as a consent-request signature.""" + joined = ",".join(sorted(member_pubs)) + return ( + f"{CONSENT_CTX}\npeer\n{house_id}\n{host_ed_pub}\n{joined}" + ).encode("utf-8") + + +@dataclass(frozen=True) +class PeerRoster: + """A house's signed membership announcement: the announcing host's Ed25519 + pubkey, the house id, and the set of member persona pubkeys. Immutable.""" + + house_id: str + host_ed_pub: str + member_pubs: Tuple[str, ...] + sig: str + + def canonical(self) -> bytes: + return _roster_canonical(self.house_id, self.host_ed_pub, list(self.member_pubs)) + + def signature_ok(self) -> bool: + """True iff the roster is validly signed by ``host_ed_pub``. A forged or + unsigned roster is not ok — and is never merged into a directory.""" + return persona_verify(self.host_ed_pub, self.sig, self.canonical()) + + +def build_peer_frame( + host_secret_raw: bytes, + host_ed_pub: str, + house_id: str, + member_pubs: List[str], +) -> str: + """Build a signed HOUSE-PEER frame string ``{"_sor":{"op":"peer",...}}`` that + announces ``house_id``'s roster, signed by the host's Ed25519 seed.""" + sig = persona_sign(host_secret_raw, _roster_canonical(house_id, host_ed_pub, member_pubs)) + return json.dumps( + { + "_sor": { + "op": PEER_OP, + "house": house_id, + "host_ed": host_ed_pub, + "roster": sorted(member_pubs), + "sig": sig, + } + }, + sort_keys=True, + separators=(",", ":"), + ) + + +def parse_peer_frame(text: str) -> Optional[PeerRoster]: + """Parse + verify a HOUSE-PEER frame into a :class:`PeerRoster`, or ``None`` + if it is not a recognized/valid peer frame. Never raises. Returns the roster + only when its signature verifies — an unsigned/forged roster yields ``None``.""" + try: + v = json.loads(text) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(v, dict): + return None + inner = v.get("_sor") + if not isinstance(inner, dict) or inner.get("op") != PEER_OP: + return None + house = inner.get("house") + host_ed = inner.get("host_ed") + roster = inner.get("roster") + sig = inner.get("sig") + if not (isinstance(house, str) and isinstance(host_ed, str) and isinstance(sig, str)): + return None + if not isinstance(roster, list) or not all(isinstance(p, str) for p in roster): + return None + r = PeerRoster(house, host_ed, tuple(roster), sig) + return r if r.signature_ok() else None + + +class Directory: + """A host-side pubkey -> house directory assembled from validated rosters. + Only signature-verified rosters are admitted, so an unsigned/forged roster + can never inject a node into a federated path.""" + + def __init__(self) -> None: + self._house_of: Dict[str, str] = {} # member pubkey -> house_id + self._members: Dict[str, List[str]] = {} # house_id -> [pubkey,...] + + def add_roster(self, roster: PeerRoster) -> bool: + """Merge a roster. Returns False (nothing merged) unless it verifies.""" + if not roster.signature_ok(): + return False + members: List[str] = [] + for pub in roster.member_pubs: + self._house_of[pub] = roster.house_id + members.append(pub) + self._members[roster.house_id] = members + return True + + def houses(self) -> List[str]: + return sorted(self._members) + + def members_of(self, house_id: str) -> List[str]: + return list(self._members.get(house_id, [])) + + def house_of(self, pub: str) -> Optional[str]: + return self._house_of.get(pub) + + def select_federated_path( + self, seed: int, hops: int = 3, min_houses: int = 2 + ) -> List[Tuple[str, str]]: + """Deterministically pick ``hops`` distinct nodes spanning at least + ``min_houses`` houses, returned as ``[(pubkey, house_id), ...]`` in circuit + order. Draws from the R1 PATH stream so the path is reproducible from the + seed. Raises ValueError if the directory can't satisfy the span (fewer + than ``min_houses`` houses, or fewer than ``hops`` total nodes) — it does + NOT silently collapse to a single-house path, because a single-house path + would defeat the split-knowledge property this mode exists to measure.""" + houses = self.houses() + if len(houses) < min_houses: + raise ValueError( + f"federation: need >= {min_houses} houses, have {len(houses)}" + ) + total_nodes = sum(len(self._members[h]) for h in houses) + if total_nodes < hops: + raise ValueError( + f"federation: need >= {hops} nodes across houses, have {total_nodes}" + ) + + s = SorRng(seed).stream(Domain.PATH) + + # Round-robin one node from each house first (guarantees the span), then + # fill remaining hops from the combined remaining pool. Selection within + # each pool is a deterministic PATH-stream draw. + remaining: Dict[str, List[str]] = {h: list(self._members[h]) for h in houses} + chosen: List[Tuple[str, str]] = [] + + def _draw(house: str) -> None: + pool = remaining[house] + j = s.next_below(len(pool)) + pub = pool.pop(j) + chosen.append((pub, house)) + + # Guarantee the span: one hop from each of the first min_houses houses. + for h in houses[:min_houses]: + if len(chosen) >= hops: + break + _draw(h) + + # Fill the rest from whichever houses still have members. + while len(chosen) < hops: + avail = [h for h in houses if remaining[h]] + if not avail: + break + h = avail[s.next_below(len(avail))] + _draw(h) + + if len(chosen) < hops: + raise ValueError("federation: exhausted node pool before filling path") + return chosen + + +def path_span_ok(path: List[Tuple[str, str]], min_houses: int = 2) -> bool: + """True iff ``path`` visits at least ``min_houses`` distinct houses — i.e. no + single house appears at every hop, so no single house's logs hold all hop + identities (the RQ2 split-knowledge acceptance predicate).""" + return len({house for _, house in path}) >= min_houses + + +# --------------------------------------------------------------------------- # +# bridge-member — blind tunnel forwarder (no room key, plaintext-blind). +# --------------------------------------------------------------------------- # +def build_tunnel_frame(circuit_id: str, seq: int, onion_payload: bytes) -> str: + """A SOR *tunnel* frame carrying already-onion-encrypted bytes (opaque to any + bridge). This is the only thing a :class:`BlindBridge` will relay.""" + return json.dumps( + { + "_sor": { + "op": "tunnel", + "cid": circuit_id, + "seq": seq, + "payload_b64": base64.standard_b64encode(onion_payload).decode("ascii"), + } + }, + sort_keys=True, + separators=(",", ":"), + ) + + +@dataclass +class BridgeForward: + """Record of one blind relay: which circuit, byte count, direction. Metadata + only — the bridge never holds or logs the payload plaintext.""" + + circuit_id: str + seq: int + n_bytes: int + src_house: str + dst_house: str + + +class BlindBridge: + """A bridge-member joined to two houses that relays SOR tunnel bytes and + **nothing else**. It is constructed with *no* room key for either house, so it + structurally cannot decrypt either room's chat plaintext: :meth:`forward` + passes through only the opaque onion payload of a SOR ``tunnel`` frame and + returns ``None`` for anything else (chat, consent, unknown) — a chat ciphertext + handed to it stays sealed. + + Optionally emits an R3 ``bridge_forward`` event per relayed frame (metadata + only), which is exactly what the RQ1 bridge-linkability measurement reads.""" + + def __init__(self, house_a: str, house_b: str, log: Optional[EventLog] = None) -> None: + self.house_a = house_a + self.house_b = house_b + self._log = log + # A bridge holds NO room Fernet key — this is the load-bearing invariant. + self.room_keys: Dict[str, object] = {} + self.forwarded: List[BridgeForward] = [] + + def has_room_key(self, house_id: str) -> bool: + """Always False: a blind bridge is never given a room key, so it can never + read chat plaintext. Exposed so the acceptance check can assert it.""" + return house_id in self.room_keys + + def _other(self, src_house: str) -> str: + return self.house_b if src_house == self.house_a else self.house_a + + def forward(self, src_house: str, frame_text: str) -> Optional[bytes]: + """Relay a frame arriving from ``src_house`` toward the other house. + Returns the opaque onion payload bytes that were passed through (for a + valid SOR ``tunnel`` frame), or ``None`` if the frame is not a tunnel + frame — the bridge forwards nothing else and decrypts nothing. Never + raises on arbitrary input.""" + try: + v = json.loads(frame_text) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(v, dict): + return None + inner = v.get("_sor") + if not isinstance(inner, dict) or inner.get("op") != "tunnel": + # Chat frames, consent frames, unknown frames: the bridge cannot and + # does not open them. Not a tunnel byte -> not forwarded. + return None + cid = inner.get("cid") + seq = inner.get("seq") + payload_b64 = inner.get("payload_b64") + if not (isinstance(cid, str) and isinstance(seq, int) and isinstance(payload_b64, str)): + return None + try: + payload = base64.standard_b64decode(payload_b64) + except Exception: # noqa: BLE001 + return None + dst = self._other(src_house) + self.forwarded.append(BridgeForward(cid, seq, len(payload), src_house, dst)) + if self._log is not None: + self._log.emit( + "bridge_forward", + circuit_id=cid, + hop_index=seq, + bytes_=len(payload), + ) + # Blind pass-through: the exact opaque bytes, never decrypted. + return payload + + def try_read_chat(self, house_id: str, chat_ciphertext: bytes) -> Optional[bytes]: + """Model the bridge attempting to read a room's chat plaintext. It holds + no room key, so this always returns ``None`` — the bridge is plaintext- + blind by construction. Present so the acceptance check can assert it.""" + return None diff --git a/tests/test_sor_federation.py b/tests/test_sor_federation.py new file mode 100644 index 0000000..9567d43 --- /dev/null +++ b/tests/test_sor_federation.py @@ -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":""}}' + 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