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-common/src/meshbay_common/device.py | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) (limited to 'packages/meshbay-common/src/meshbay_common/device.py') diff --git a/packages/meshbay-common/src/meshbay_common/device.py b/packages/meshbay-common/src/meshbay_common/device.py index 8951dbb..cfa8dd6 100644 --- a/packages/meshbay-common/src/meshbay_common/device.py +++ b/packages/meshbay-common/src/meshbay_common/device.py @@ -36,6 +36,7 @@ import hashlib DEVICE_REQUEST_PREFIX = b"meshbay:device_req:v1" DEVICE_ADD_PREFIX = b"meshbay:device_add:v1" +DEVICE_HELLO_PREFIX = b"meshbay:device_hello:v1" # Same as the join and admin transcripts: interactive exchanges that complete in # milliseconds, so anything older is a replay. @@ -123,3 +124,41 @@ def device_add_transcript( nonce_node, str(ts).encode(), ]) + + +def device_hello_transcript( + node_pk_b64: str, + group_id: str, + user_id: str, + pk_ed25519_b64: str, + nonce_node: bytes, + ts: int, +) -> bytes: + """ + Signed by the device on an already-authenticated connection, saying **which + of the account's devices this connection is**. + + The handshake proves membership of a group (a GEK-HMAC) and carries an + account from the hub's token; it proves nothing about *which* device is + talking. The node needed that the moment one account could hold several: + `_load_pinned_pk` was resolving "this account's oldest live device" and + recording it as the uploader of every file, so a phone's uploads were + attributed to a laptop. + + Additive and optional. A client that does not send it leaves the node where + it was, which is why this could ship without a breaking protocol change — + but a node that *has* been told refuses a later claim to be a different + device on the same connection. + + `nonce_node` is this connection's handshake nonce, so the signature cannot + be lifted onto another connection, and `node_pk` binds it to one node — + the same rule as every other transcript here. + """ + return _pack(DEVICE_HELLO_PREFIX, [ + node_pk_b64.encode(), + group_id.encode(), + user_id.encode(), + pk_ed25519_b64.encode(), + nonce_node, + str(ts).encode(), + ]) -- cgit v1.2.3