diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:50:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 17:50:28 +0200 |
| commit | 36cebf25d0e0f24cf63be4380ccb5d03da726a74 (patch) | |
| tree | 8509ec4cf68a058f7383299e11bdea97ab06cadf /packages/meshbay-node/tests/test_webrtc_transport.py | |
| parent | 8883d60d0afa2ed9dd1ef68bc21fe1b9a65a59ff (diff) | |
| download | meshbay-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.py | 64 |
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() |