aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_chat_multidevice.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 17:50:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 17:50:28 +0200
commit36cebf25d0e0f24cf63be4380ccb5d03da726a74 (patch)
tree8509ec4cf68a058f7383299e11bdea97ab06cadf /packages/meshbay-node/tests/test_chat_multidevice.py
parent8883d60d0afa2ed9dd1ef68bc21fe1b9a65a59ff (diff)
downloadmeshbay-36cebf25d0e0f24cf63be4380ccb5d03da726a74.tar.gz
feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
Diffstat (limited to 'packages/meshbay-node/tests/test_chat_multidevice.py')
-rw-r--r--packages/meshbay-node/tests/test_chat_multidevice.py160
1 files changed, 160 insertions, 0 deletions
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