aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_webrtc_transport.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_webrtc_transport.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_webrtc_transport.py')
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py64
1 files changed, 52 insertions, 12 deletions
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index dc74752..ea13d96 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -244,6 +244,35 @@ async def _open_channel(transport, peer_id):
return pc, ch, q
+def _sealed_chat(session, text: bytes = b"ciphertext") -> dict:
+ """
+ A chat message in the shape MNP 2.0 requires, on a live session.
+
+ There is no plaintext chat any more, so a test that wants to exercise
+ delivery has to send a real envelope. The bytes need not be a real
+ ciphertext — the node never opens one — but the envelope's shape and the
+ device claim are checked, and the device must be the one this connection
+ identified itself as. Identifying it here is what `device_hello` does over
+ the wire; doing it directly keeps this test about chat rather than about
+ device linking, which `test_device_on_connection.py` covers.
+ """
+ device = hashlib.sha256(session._registry_key.encode()).digest()
+ session._pinned_pk = base64.b64encode(device).decode()
+ session._device_confirmed = True
+ return {
+ "type": MNP.CHAT_MESSAGE, "v": MNP_VERSION,
+ "format": 1, "epoch": 1, "device": device, "ct": text,
+ "nonce": b"\x02" * 12, "sig": b"\x03" * 64,
+ }
+
+
+def _only_session(transport):
+ """The one live peer session on a transport, for tests that made one."""
+ sessions = list(transport._sessions.values())
+ assert len(sessions) == 1, f"expected one session, got {len(sessions)}"
+ return sessions[0]
+
+
async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_user=None,
group_id=TEST_GROUP):
"""Create a peer connection, perform handshake with GEK proof, return (pc, channel, queue)."""
@@ -574,11 +603,8 @@ async def test_webrtc_chat_send_and_history(sk_node, sk_hub, gek, shared_dir, tm
browser_pc, channel, received = await _setup_peer(
transport, sk_hub, gek, "peer-chat")
- channel.send(_pack({
- "type": MNP.CHAT_MESSAGE,
- "v": MNP_VERSION,
- "payload": "hello from browser",
- }))
+ channel.send(_pack(_sealed_chat(_only_session(transport),
+ b"hello from browser")))
chat_ack = await asyncio.wait_for(received.get(), timeout=5.0)
assert chat_ack["type"] == "ack"
@@ -593,7 +619,12 @@ async def test_webrtc_chat_send_and_history(sk_node, sk_hub, gek, shared_dir, tm
hist = await asyncio.wait_for(received.get(), timeout=5.0)
assert hist["type"] == MNP.CHAT_HISTORY_RESPONSE
assert len(hist["messages"]) == 1
- assert hist["messages"][0]["payload"] == "hello from browser"
+ # The ciphertext comes back under `ct`, byte for byte — `payload` is the
+ # plaintext field and stays empty for a sealed row. Decoding a ciphertext
+ # as UTF-8, which the history path used to do, would mangle it.
+ assert hist["messages"][0]["ct"] == b"hello from browser"
+ assert hist["messages"][0]["payload"] == ""
+ assert hist["messages"][0]["format"] == 1
assert hist["messages"][0]["sender_id"] == "user-001"
await chat_store.close()
@@ -650,17 +681,22 @@ async def test_webrtc_chat_broadcast(sk_node, sk_hub, gek, shared_dir, tmp_path)
pc_a, ch_a, q_a = await _setup_peer(transport, sk_hub, gek, "peer-A", "user-A")
pc_b, ch_b, q_b = await _setup_peer(transport, sk_hub, gek, "peer-B", "user-B")
- ch_a.send(_pack({
- "type": MNP.CHAT_MESSAGE, "v": MNP_VERSION, "payload": "hi from A",
- }))
+ session_a = next(s for s in transport._sessions.values()
+ if s._user_id == "user-A")
+ ch_a.send(_pack(_sealed_chat(session_a, b"hi from A")))
ack_a = await asyncio.wait_for(q_a.get(), timeout=5.0)
assert ack_a["type"] == "ack"
broadcast = await asyncio.wait_for(q_b.get(), timeout=5.0)
assert broadcast["type"] == MNP.CHAT_MESSAGE
+ # `sender_id` is still the node's, from the authenticated session (NS6).
+ # What it now carries beside it is the sending device and a signature over
+ # the ciphertext, which is what makes the claim checkable by the receiver
+ # rather than taken on the node's word.
assert broadcast["sender_id"] == "user-A"
- assert broadcast["payload"] == "hi from A"
+ assert broadcast["ct"] == b"hi from A"
+ assert broadcast["device"] == base64.b64decode(session_a._pinned_pk)
await chat_store.close()
await pc_a.close()
@@ -731,12 +767,16 @@ async def test_webrtc_peer_cleanup_on_close(sk_node, sk_hub, gek, shared_dir):
browser_pc, channel, received = await _setup_peer(
transport, sk_hub, gek, "peer-cleanup")
- assert "user-001" in transport._ctx["_peers"]
+ # Keyed per connection, not per account (docs/chat-sender-keys.md F7), so
+ # membership is asserted by the session object rather than by user_id —
+ # one account may hold several entries here.
+ peers = transport._ctx["_peers"]
+ assert [s._user_id for s in peers.values()] == ["user-001"]
assert transport.active_peers == 1
await transport.close_peer("peer-cleanup")
- assert "user-001" not in transport._ctx["_peers"]
+ assert transport._ctx["_peers"] == {}
assert transport.active_peers == 0
await browser_pc.close()