From 36cebf25d0e0f24cf63be4380ccb5d03da726a74 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 7 Sep 2026 17:50:28 +0200 Subject: feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat messages are sealed with AES-256-GCM under a key derived per group, per epoch, per *device*, and signed over the ciphertext with the device key the node pinned. The node relays and archives; it cannot read a message. There is no switch. MNP goes to 2.0 and MNP_MIN_SUPPORTED moves with it, so a 1.x peer is refused at the handshake with `version_too_old` rather than admitted and then unable to speak. An opt-in flag was designed and rejected: every node is a test node, so it would have bought nothing and left a plaintext branch reachable — C6's lesson one feature later. A test reads the source and refuses any code that consults a `chat_encrypted` setting. Not Sender Keys, and `senderkeys.py` is now documented as unused. With distribution under the group key and a node that serves history to devices which were not present, the node must retain each chain's earliest key, and a chain key at iteration i yields every message key from i on by pure HKDF — forward secrecy is zero either way. What the ratchet was left buying was stateful client code with silent failure modes, three of them reproduced: any member could sign as any other, a second device dropped the first's chain, and the skipped-key cache grew without bound. The reasoning is in docs/chat-sender-keys.md, which is the specification and the decision record. Epochs, not rotation: the epoch key is wrapped under the group key at delivery and never stored under it, so `gek_rotate` is a re-wrap. A group-key-derived archive key would have made every message ever sent unreadable on the first `member unpin`, which is the documented step after removing a member. A new epoch opens on member revoke/unpin, device revoke and `gek_rotate`; old epochs are kept and still delivered, so history stays readable to everyone who could already read it, and nothing anywhere deletes one. Three prerequisites this needed, each a live defect on its own: * The peer registry was keyed by user_id, so one account's second device evicted the first and the broadcast skipped recipients by account — a person's phone never saw what they typed on their laptop. * The handshake authenticated an account, never a device. `device_hello` (additive, signed, refused unless the key is a live device of this account in the node's own roster) is what lets the node refuse a member claiming somebody else's key. * `_admin_exec_file_delete` authorized against the exact uploading key, so device linking had already broken deleting your own file from your other device. It now authorizes against any non-revoked device of `uploader_id`. Found by driving the real panel over the real transport, not by reading source: `chat_keys_resp` was routed by arrival order and handed to an unanswered `media_meta_req` — the original frozen-tab defect in a message type that did not exist when that probe was written. And `_asText` had been deleted with an unrelated helper beside it; its only caller sits inside a promise the panel catches, so every conversation rendered empty with nothing in the console. Existing node data is migrated by QE/migration/migrate_chat_encryption.py (not versioned, per the QE rule), run with the node stopped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr --- .../meshbay-node/tests/test_chat_multidevice.py | 160 +++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 packages/meshbay-node/tests/test_chat_multidevice.py (limited to 'packages/meshbay-node/tests/test_chat_multidevice.py') diff --git a/packages/meshbay-node/tests/test_chat_multidevice.py b/packages/meshbay-node/tests/test_chat_multidevice.py new file mode 100644 index 0000000..d718b2a --- /dev/null +++ b/packages/meshbay-node/tests/test_chat_multidevice.py @@ -0,0 +1,160 @@ +""" +One account, several devices, on one node. + +Device linking (2026-08-18) made `identities` a table keyed by +`(user_id, pk_ed25519)`, so a person legitimately holds several keys here. The +chat path never followed: the peer registry was keyed by `user_id`, so the +second connection of one account **evicted the first**, and the broadcast loop +skipped recipients by account, so a person's own other devices never received +what they said. + +Neither shows up as an error anywhere. The first is a message that silently +reaches nobody after a second device connects and disconnects; the second is a +phone that never shows what was typed on the laptop. Both are +`docs/chat-sender-keys.md` F7, and both are the same "keyed by account where it +should be keyed by connection" mistake as `pin_identity`'s old INSERT OR REPLACE. +""" + +from pathlib import Path + +import base64 +import hashlib + +from aiortc import RTCPeerConnection +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +from conftest import one_root + + +def _sealed(device_raw: bytes, text: bytes = b"ciphertext") -> dict: + """A well-formed sealed envelope. + + The bytes are not a real ciphertext and do not need to be: the node never + opens one. What it *does* check is the envelope's shape and that the device + is the connection's own, and going through the real `_do_chat_message` + rather than around it is the point — these tests are about delivery, and + delivery now runs after that check. + """ + return {"format": 1, "epoch": 1, "device": device_raw, "ct": text, + "nonce": b"\x02" * 12, "sig": b"\x03" * 64} + + +def _session(ctx: dict, user_id: str, group_id: str) -> WebRTCPeerSession: + """A peer session with only what the chat path touches wired up. + + Built through the real `__init__` — with a bare RTCPeerConnection, which + costs under a millisecond and opens no socket — so `_registry_key` is the + one production assigns. Constructing it in the test instead would make + these tests agree with the fix by construction, which is precisely the + trap the repo's own notes record. + """ + session = WebRTCPeerSession(pc=RTCPeerConnection(), node_ctx=ctx) + session._group_id = group_id + session._user_id = user_id + session._username = user_id + # Each connection is a distinct device of that account — which is the whole + # subject here, and what `_check_chat_envelope` compares a message against. + session._pinned_pk = base64.b64encode(_device_raw(session)).decode() + session._device_confirmed = True + session.sent = [] + session._send = session.sent.append + session._audit = lambda *a, **k: None + session._spawn = lambda coro: coro.close() + return session + + +def _ctx(tmp_path: Path, group_id: str) -> dict: + index = GroupIndex(group_id=group_id, sk_node=Ed25519PrivateKey.generate()) + root = tmp_path / "shared" + root.mkdir(exist_ok=True) + return {"groups": {group_id: { + "index": index, "roots": one_root(root), "chat_store": None, + }}} + + +GROUP = "g" * 32 + + +def _device_raw(session) -> bytes: + """A stable 32-byte stand-in for this connection's device key. + + Derived from the registry key, so two sessions of one account get two + devices — which is exactly the situation being tested, and a shared one + would make the envelope check pass for the wrong reason. + """ + return hashlib.sha256(session._registry_key.encode()).digest() + + +def test_two_devices_of_one_account_both_stay_registered(tmp_path): + """ + F7: keyed by `user_id`, the second device overwrote the first, and closing + either then removed the other's entry — so one person's two devices could + never both be reachable. + """ + ctx = _ctx(tmp_path, GROUP) + laptop = _session(ctx, "alice", GROUP) + phone = _session(ctx, "alice", GROUP) + + laptop._register_peer() + phone._register_peer() + + registry = laptop._peer_registry() + assert len(registry) == 2, ( + "one account's two devices must both be in the registry; keyed by " + "user_id the second silently replaced the first") + assert set(registry.values()) == {laptop, phone} + + +def test_a_message_reaches_the_senders_other_device(tmp_path): + """ + F7, the visible half: the broadcast excluded recipients whose `user_id` + matched the sender's, so everything typed on the laptop was missing from + the phone — with no error and no way to notice but to hold both. + """ + ctx = _ctx(tmp_path, GROUP) + laptop = _session(ctx, "alice", GROUP) + phone = _session(ctx, "alice", GROUP) + bob = _session(ctx, "bob", GROUP) + for s in (laptop, phone, bob): + s._register_peer() + laptop._user_names = lambda: ctx["groups"][GROUP].setdefault("_names", {}) + + laptop._do_chat_message(_sealed(_device_raw(laptop), b"hello-ciphertext")) + + def chats(session): + return [m for m in session.sent if m.get("type") == "chat_msg"] + + assert len(chats(phone)) == 1, ( + "the sender's other device is an ordinary recipient — it composed " + "nothing and has no local echo to fall back on") + assert chats(phone)[0]["ct"] == b"hello-ciphertext" + assert len(chats(bob)) == 1 + assert chats(laptop) == [], "the composing connection must not echo to itself" + + +def test_closing_one_device_leaves_the_other_connected(tmp_path): + """ + The teardown half. `close()` popped `self._user_id`, so the phone + disconnecting unregistered the laptop, which then received nothing for the + rest of its session while still reading as connected. + """ + ctx = _ctx(tmp_path, GROUP) + laptop = _session(ctx, "alice", GROUP) + phone = _session(ctx, "alice", GROUP) + for s in (laptop, phone): + s._register_peer() + + phone._unregister_peer() + + assert laptop._registry_key in laptop._peer_registry() + assert len(laptop._peer_registry()) == 1 + + +def test_registry_key_is_per_connection_not_per_account(tmp_path): + """The property the two tests above depend on, asserted directly.""" + ctx = _ctx(tmp_path, GROUP) + a = _session(ctx, "alice", GROUP) + b = _session(ctx, "alice", GROUP) + assert a._registry_key != b._registry_key -- cgit v1.2.3