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/__init__.py | 17 +- .../meshbay-common/src/meshbay_common/adminop.py | 8 + .../meshbay-common/src/meshbay_common/chatbox.py | 181 +++++++ .../meshbay-common/src/meshbay_common/device.py | 39 ++ .../meshbay-common/src/meshbay_common/groupbox.py | 5 + .../meshbay-common/src/meshbay_common/handshake.py | 18 +- .../meshbay-common/src/meshbay_common/protocol.py | 29 +- .../src/meshbay_common/senderkeys.py | 55 +- .../meshbay-common/tests/test_js_python_parity.py | 209 ++++++++ .../src/meshbay_hub/static/chat-app-settings.js | 16 +- .../meshbay-hub/src/meshbay_hub/static/chat-app.js | 67 ++- .../meshbay-hub/src/meshbay_hub/static/crypto.js | 117 ++++- .../src/meshbay_hub/static/locales/de.js | 12 + .../src/meshbay_hub/static/locales/en.js | 17 + .../src/meshbay_hub/static/locales/es.js | 12 + .../src/meshbay_hub/static/locales/fr.js | 12 + .../src/meshbay_hub/static/locales/it.js | 12 + .../src/meshbay_hub/static/locales/ja.js | 12 + .../src/meshbay_hub/static/locales/nl.js | 12 + .../src/meshbay_hub/static/locales/pl.js | 12 + .../src/meshbay_hub/static/locales/pt-BR.js | 12 + .../src/meshbay_hub/static/locales/zh-CN.js | 12 + .../meshbay-hub/src/meshbay_hub/static/style.css | 13 + .../src/meshbay_hub/static/transport.js | 344 ++++++++++++- .../meshbay-hub/tests/harness/chat_send_probe.py | 95 +++- packages/meshbay-hub/tests/test_chat_send.py | 57 +- .../meshbay-node/src/meshbay_node/bundle_store.py | 73 +++ .../meshbay-node/src/meshbay_node/chat/__init__.py | 20 +- .../meshbay-node/src/meshbay_node/chat/store.py | 186 +++++-- packages/meshbay-node/src/meshbay_node/daemon.py | 103 +++- packages/meshbay-node/src/meshbay_node/ops.py | 249 ++++++++- .../src/meshbay_node/transport/quic_server.py | 14 +- .../src/meshbay_node/transport/webrtc_server.py | 572 +++++++++++++++++++-- packages/meshbay-node/src/meshbay_node/ui/app.py | 18 + .../meshbay-node/tests/test_chat_encryption.py | 517 +++++++++++++++++++ .../meshbay-node/tests/test_chat_history_binary.py | 181 +++++++ .../meshbay-node/tests/test_chat_multidevice.py | 160 ++++++ packages/meshbay-node/tests/test_cli_dispatch.py | 11 + .../tests/test_device_on_connection.py | 287 +++++++++++ .../meshbay-node/tests/test_webrtc_transport.py | 64 ++- 40 files changed, 3683 insertions(+), 167 deletions(-) create mode 100644 packages/meshbay-common/src/meshbay_common/chatbox.py create mode 100644 packages/meshbay-node/tests/test_chat_encryption.py create mode 100644 packages/meshbay-node/tests/test_chat_history_binary.py create mode 100644 packages/meshbay-node/tests/test_chat_multidevice.py create mode 100644 packages/meshbay-node/tests/test_device_on_connection.py (limited to 'packages') diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index b3e24e3..ca4c6eb 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -90,8 +90,19 @@ __version__ = "0.11.0" # makes the *next* breaking change cost a refusal message instead of a second # flag day. `MNP_MIN_SUPPORTED` in `handshake.py` is the other half. # +# **2.0 (2026-09-07): chat is encrypted, and there is no way to turn it off.** +# A MAJOR bump because it is a real break: a 1.x peer cannot produce a sealed +# chat message and cannot read one, so it is refused at the handshake with +# `version_too_old` rather than connecting and then failing to speak. Expressing +# the break in the version is what makes it a stated refusal instead of a +# conversation that silently does not work — `MNP_MIN_SUPPORTED` moves with it. +# +# There is deliberately no per-group switch. Every node in existence is a test +# node, so an opt-in flag would buy nothing and cost a compatibility path to +# maintain; existing node data is migrated by `QE/migrate-chat-encryption.py`. +# # The index at rest, `index_progress` (counters only, never a path — see -# `groupbox.py` and daemon.py `_push_index_progress`), chat, and file content -# on the operator's disk are all deliberately unchanged. -MNP_VERSION = "1.1" +# `groupbox.py` and daemon.py `_push_index_progress`), and file content on the +# operator's disk are all deliberately unchanged. +MNP_VERSION = "2.0" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index 9a56934..ed6e940 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -112,6 +112,14 @@ OP_ROOT_REMOVE = "root_remove" OP_APP_DIRECTORIES = "app_directories" OP_CHAT_DIRECTORY = "chat_directory" OP_CHAT_LINK_PREVIEW = "chat_link_preview" +# Open a new chat epoch for a group, by hand. The removals that matter open one +# by themselves (member revoke/unpin, device revoke, gek_rotate); this is the +# operator saying "do it anyway", which is the same shape as `gek_rotate` and +# signed for the same reason. +# +# There is no op for *enabling* chat encryption. It is not a setting — MNP 2.0 +# has no plaintext chat to fall back to. +OP_CHAT_EPOCH = "chat_epoch" OP_ROOT_UPDATE = "root_update" OP_ROOT_EJECT = "root_eject" OP_ROOT_PLUG = "root_plug" diff --git a/packages/meshbay-common/src/meshbay_common/chatbox.py b/packages/meshbay-common/src/meshbay_common/chatbox.py new file mode 100644 index 0000000..e04bd9b --- /dev/null +++ b/packages/meshbay-common/src/meshbay_common/chatbox.py @@ -0,0 +1,181 @@ +""" +Chat message encryption and sender authentication. + +Design A of `docs/chat-sender-keys.md`, decided 2026-09-07. What it is, and what +it deliberately is not, in the order the decisions were made: + +**Not a ratchet.** `senderkeys.py` implements Signal-style sender keys and is +unused by production. With sender keys distributed under the group key and a +node that serves history to devices which were not present, the node must retain +and hand out each chain's *earliest* key — and a chain key at iteration *i* +yields every message key from *i* onward by pure HKDF. Forward secrecy is then +zero, and the ratchet is computing HKDF over a value every member already holds. +The property is given up on the record rather than inherited by accident. + +**A key per group, per epoch, per device.** The node generates an epoch key and +delivers it to members wrapped under the current group key. Each device derives +its *own* subkey from it, by name, so: + +* two devices never share an AES key, and nonce reuse across devices is + impossible without any coordination — the property per-device ratchet chains + were wanted for, obtained by derivation instead of by mutable state (which is + C1 one level down, and is exactly what `GroupSenderKeyStore` got wrong); +* a receiver derives any sender's subkey from the epoch key it already has, so + nothing is distributed per device and there is no per-device state to + persist, migrate or lose; +* history keeps working, for new members and new devices alike, because the + epoch key does not move as messages are sent. + +**Epochs, not rotation of the archive.** The epoch key is wrapped under the +group key *at delivery*, never stored under it — so rotating the group key +(which is the documented step after removing a member) is a re-wrap and costs +nothing. A new epoch is opened when the set of devices that may read *future* +messages shrinks; old epochs are kept and still delivered to current members, +which is what keeps the history readable to the people who could already read it. + +**Signing is separate from encryption**, and is what actually establishes who +said something. The signature is over the *ciphertext*, so it can be checked +before decryption and by anyone holding the roster, and it names the device key +the node pinned — not a fresh key the sender invented, which is what made the +sender-key distribution format forgeable by any member. + +What none of this protects against, stated per the v5/v6 convention: the node +operator and every current group member hold the group key and therefore the +epoch keys. This is the same boundary as file access, by design. It protects +against someone who obtains the node's storage without the keystore password. +""" + +from __future__ import annotations + +import os + +import msgpack +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +EPOCH_KEY_LEN = 32 +# 96-bit, the WebCrypto AES-GCM standard — and the same reasoning as +# `groupbox.py`: one key per device per epoch puts the NIST SP 800-38D ceiling +# of 2**32 random nonces far out of reach of a person typing. +NONCE_LEN = 12 +SIG_LEN = 64 + +_DEVICE_KEY_INFO = "meshbay:chat:dev:v1" +_SIG_PREFIX = b"meshbay:chat:v1" + + +def new_epoch_key() -> bytes: + """A fresh epoch key. Generated by the node — never by a member (C5b).""" + return os.urandom(EPOCH_KEY_LEN) + + +def device_key(epoch_key: bytes, group_id: str, device_b64: str) -> bytes: + """ + The AES-256 subkey one device encrypts under, in one group, in one epoch. + + Derived by name, so every member can compute every device's subkey from the + epoch key and nobody has to distribute anything per device. `salt=None` here + matches `salt: new Uint8Array(0)` in crypto.js — RFC 5869 extracts with a + zero key either way, which `deriveChunkKey` already relies on in production + and `test_js_python_parity` holds. + """ + if not epoch_key: + raise ValueError("no epoch key") + if not device_b64: + raise ValueError("no device") + info = f"{_DEVICE_KEY_INFO}|{group_id}|{device_b64}".encode() + return HKDF( + algorithm=hashes.SHA256(), length=32, salt=None, info=info, + ).derive(epoch_key) + + +def associated_data(group_id: str, epoch: int) -> bytes: + """ + What a chat ciphertext is bound to. + + The group stops a message being moved between two groups on one node; the + epoch stops one being re-presented as belonging to a later key, which would + otherwise let a member who kept an old epoch key make an old message look + current. Same reasoning as `groupbox.associated_data`, one field longer. + """ + return f"chat_msg|{group_id}|{epoch}".encode() + + +def signing_transcript(group_id: str, epoch: int, device: bytes, nonce: bytes, + ct: bytes) -> bytes: + """ + What the sending device signs — the ciphertext, never the plaintext. + + Over the ciphertext so a receiver can establish authorship *before* it + decrypts, and so anyone holding the roster can verify a stored message + without the epoch key. Length-prefixed and domain-separated, per L4, like + every other transcript in this codebase: without the lengths, a message + could be re-cut into a different one with the same bytes. + + Note what is *not* in here: this connection's nonce. Every other transcript + binds one, and this one cannot — a receiver reading history has no access to + the connection the message arrived on. Replay is therefore refused by + storage instead, on the unique `(device, nonce)` pair. + """ + out = bytearray(_SIG_PREFIX) + for field in (group_id.encode(), str(epoch).encode(), device, nonce, ct): + out += len(field).to_bytes(4, "big") + out += field + return bytes(out) + + +def seal(epoch_key: bytes, group_id: str, epoch: int, device_b64: str, + device_raw: bytes, sk: Ed25519PrivateKey, payload: dict) -> dict: + """ + One message, sealed and signed: `{nonce, ct, sig}` for the caller to merge. + + The routing and authentication fields — `format`, `epoch`, `device` — stay + in clear, because a receiver has to select a key and check a signature + before it can decrypt, and because the node routes on them without being + able to read anything. + """ + key = device_key(epoch_key, group_id, device_b64) + # Random per message. Never derived from the payload: two identical messages + # under one device's key would then reuse it, and AES-GCM's failure under + # nonce reuse is not graceful. + nonce = os.urandom(NONCE_LEN) + ct = AESGCM(key).encrypt( + nonce, msgpack.packb(payload, use_bin_type=True), + associated_data(group_id, epoch)) + sig = sk.sign(signing_transcript(group_id, epoch, device_raw, nonce, ct)) + return {"nonce": nonce, "ct": ct, "sig": sig} + + +def verify(group_id: str, epoch: int, device_raw: bytes, nonce: bytes, + ct: bytes, sig: bytes) -> bool: + """Whether `sig` is this device's signature over this ciphertext.""" + try: + pk = Ed25519PublicKey.from_public_bytes(device_raw) + pk.verify(sig, signing_transcript(group_id, epoch, device_raw, nonce, ct)) + return True + except Exception: + return False + + +def open_message(epoch_key: bytes, group_id: str, epoch: int, device_b64: str, + nonce: bytes, ct: bytes) -> dict: + """ + Open a sealed message. Raises on anything that does not open. + + Never a partial result and never a default — an unopenable message is not an + empty one, and rendering it as blank would make a message nobody can read + indistinguishable from a message nobody wrote. The caller marks it as + unreadable and shows the gap, which is the honest thing for a reader to see. + """ + key = device_key(epoch_key, group_id, device_b64) + plain = AESGCM(key).decrypt(bytes(nonce), bytes(ct), + associated_data(group_id, epoch)) + payload = msgpack.unpackb(plain, raw=False) + if not isinstance(payload, dict): + raise ValueError("chat: sealed payload is not a map") + return payload 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(), + ]) diff --git a/packages/meshbay-common/src/meshbay_common/groupbox.py b/packages/meshbay-common/src/meshbay_common/groupbox.py index f1091c3..ff60bba 100644 --- a/packages/meshbay-common/src/meshbay_common/groupbox.py +++ b/packages/meshbay-common/src/meshbay_common/groupbox.py @@ -41,6 +41,10 @@ from cryptography.hazmat.primitives.kdf.hkdf import HKDF PURPOSE_INDEX = "index" PURPOSE_ACK = "ack" +# The chat epoch keys themselves, on their way to a member. The keys are what +# the chat archive is encrypted under; this is only how they travel, which is +# why rotating the group key costs a re-wrap and not a re-encryption. +PURPOSE_CHAT_KEYS = "chat_keys" # `salt=None` here and `salt: new Uint8Array(0)` in crypto.js agree — RFC 5869 # extracts with a zero key either way. Already proven in production by @@ -48,6 +52,7 @@ PURPOSE_ACK = "ack" _INFO = { PURPOSE_INDEX: b"meshbay:index:v1", PURPOSE_ACK: b"meshbay:ack:v1", + PURPOSE_CHAT_KEYS: b"meshbay:chat_keys:v1", } NONCE_LEN = 12 # 96-bit, the WebCrypto AES-GCM standard diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index f7d4911..188a8aa 100644 --- a/packages/meshbay-common/src/meshbay_common/handshake.py +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -66,11 +66,19 @@ from meshbay_common import MNP_VERSION HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1" -# The oldest peer this build will talk to. MNP 1.0 sealed `index_sync`, -# `index_delta` and the `handshake_ack` payload under the group key, which no -# 0.x peer can open and which a 0.x peer's own messages do not carry — there is -# nothing to be compatible with, which is what makes it a MAJOR bump. -MNP_MIN_SUPPORTED = "1.0" +# The oldest peer this build will talk to. +# +# 2.0 (2026-09-07): chat messages are sealed under a per-device subkey of the +# group's chat epoch key, and the node refuses a plaintext one. A 1.x peer can +# neither produce nor read that, so there is nothing to be compatible with — +# the same reasoning that made 1.0 a MAJOR bump for the sealed index. +# +# Moving the floor with the version is the point: a 1.x client is refused here, +# with `version_too_old` and a sentence saying so, instead of completing a +# handshake and then discovering that every message it sends is rejected and +# every message it receives is unreadable. A stated refusal is a bug report; a +# chat that quietly does not work is a support case. +MNP_MIN_SUPPORTED = "2.0" ROLE_CLIENT = "client" ROLE_NODE = "node" diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 689adbf..dfb5d56 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -38,7 +38,12 @@ class MNP: FILE_REQUEST = "file_req" # request chunk(s) FILE_CHUNK = "file_chunk" # encrypted chunk response STREAM_SEGMENT = "stream_seg" # HLS/DASH segment - CHAT_MESSAGE = "chat_msg" # Double Ratchet message + # Not a Double Ratchet message, and never was — `first-review.md` C1 + # rejected exactly that for groups. Plaintext until a group turns + # encryption on, then AES-256-GCM under a per-device subkey of the group's + # chat epoch key, signed with the sending device's pinned Ed25519 key + # (`chatbox.py`, docs/chat-sender-keys.md). + CHAT_MESSAGE = "chat_msg" # one chat message, plain or sealed CHAT_ATTACHMENT = "chat_attach" # attachment metadata CHAT_HISTORY = "chat_hist" # request message history (newest, or before a cursor) CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages @@ -153,6 +158,14 @@ class MNP: DEVICE_LIST = "device_list" # anyone → node: my devices DEVICE_LIST_RESULT = "device_list_result" DEVICE_REVOKE = "device_revoke" # a device retires another + # "Which of this account's devices am I?" — signed, on an + # already-authenticated connection. The handshake proves the account and the + # group; it never proved the device, so the node attributed uploads to the + # account's oldest key and could not tell one device's chat from another's. + # Additive (MNP 1.2): a client that stays silent leaves the node exactly + # where it was. + DEVICE_HELLO = "device_hello" # device → node: this is me + DEVICE_HELLO_ACK = "device_hello_ack" # Rotation is the half of revocation that revocation cannot do: the node # generates a fresh key itself, so no key material crosses the wire. GEK_ROTATE = "gek_rotate" # operator → node: new group key @@ -176,6 +189,20 @@ class MNP: CHAT_DIRECTORY_ACK = "chat_directory_ack" CHAT_LINK_PREVIEW = "chat_link_preview" CHAT_LINK_PREVIEW_ACK = "chat_link_preview_ack" + # The keys a group's chat archive is encrypted under, on their way to a + # member. Sealed under a group-derived subkey, so the payload carries an + # authentication tag from a key the hub does not hold — and a member who has + # not completed the handshake is served a ciphertext rather than the keys. + # Requested rather than pushed on the ack: a group with no chat should not + # pay for this on every connection. + CHAT_KEYS_REQ = "chat_keys_req" + CHAT_KEYS_RESP = "chat_keys_resp" + # Node → this group: a new chat epoch was opened, because somebody was + # removed. Not a setting — there is no switch; chat is always encrypted + # (MNP 2.0). Pushed so a connected client stops sealing under the retired + # key without having to reconnect. + CHAT_EPOCH = "chat_epoch" + CHAT_EPOCH_ACK = "chat_epoch_ack" ROOT_UPDATE = "root_update" # operator → node: change writable/removable on a root ROOT_UPDATE_ACK = "root_update_ack" ROOT_EJECT = "root_eject" # operator → node: mark removable root as ejected diff --git a/packages/meshbay-common/src/meshbay_common/senderkeys.py b/packages/meshbay-common/src/meshbay_common/senderkeys.py index 932e2e6..9ad5107 100644 --- a/packages/meshbay-common/src/meshbay_common/senderkeys.py +++ b/packages/meshbay-common/src/meshbay_common/senderkeys.py @@ -1,21 +1,40 @@ """ -MeshBay — Sender Keys protocol for group messaging. - -Signal Groups approach: each member maintains their own sending chain. -Advantages over shared Double Ratchet: - - O(N) state per group (one chain per member) vs O(N^2) pairwise - - Single encrypt per message (not N encryptions) - - No key/nonce reuse — each sender has an independent chain - -Key components: - - Chain key ratchet: HKDF per message, provides forward secrecy +MeshBay — Sender Keys protocol. **Not used by group chat. Not used at all.** + +Kept the way `ratchet.py` is kept: a working implementation of a protocol that +may earn a place in a future 1:1 DM, where there is no server-side history to +contradict it. Group chat is `chatbox.py`, and the decision to build that +instead is `docs/chat-sender-keys.md` §4 (operator, 2026-09-07). Do not read a +green test run here as evidence that group chat is encrypted; nothing in +production imports this module. + +**Why it is not what group chat uses.** With sender keys distributed under the +group key, and a node that serves history to devices which were not present when +a message was sent, the node must retain and hand out each chain's *earliest* +key — and a chain key at iteration *i* yields every message key from *i* onward +by pure HKDF. Forward secrecy is then zero, and what is left is a large amount +of stateful client code whose failure modes are silent. Three of them are real +and reproduced in the design document: + + * `GroupSenderKeyStore.add_sender` accepts any distribution for any + `sender_id` and overwrites what is there, and `SenderKeyRecord.create` + invents a signing key bound to nothing — so under group-key distribution any + member can replace another member's chain and sign as them (F1); + * a second device registering under one `sender_id` drops the first device's + chain, and its messages then fail signature verification rather than failing + visibly at registration (F2); + * `SenderKeyState.advance_to` caches every skipped message key and nothing + trims `_skipped_keys` (F3). + +They are findings about a module nothing calls, and are deliberately not fixed +here. Anyone bringing this back for 1:1 DM must fix all three first — and must +bind the distribution to a key the node pinned, which is what F1 is really about. + +Key components, as implemented: + - Chain key ratchet: HKDF per message - Message key derivation: separate HKDF from chain key - Ed25519 signing: each sender signs their ciphertext - AES-256-GCM encryption: browser-compatible symmetric cipher - -Key distribution: - - On join: admin wraps each sender's SenderKeyDistribution with GEK - - On leave: all remaining members rotate their chain keys """ import os @@ -169,7 +188,13 @@ class SenderKeyRecord: # ── Group store ────────────────────────────────────────────────────────────── class GroupSenderKeyStore: - """All sender key states for one group, held by one member.""" + """All sender key states for one group, held by one member. + + One chain per **device**, were this ever used: a shared per-person chain + advanced by two devices produces key and nonce reuse, which is `first- + review.md` C1 one level down. `add_sender` does not enforce that — see the + module docstring, F2. + """ def __init__(self, group_id: str): self.group_id = group_id diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py index 6f7437f..dffee36 100644 --- a/packages/meshbay-common/tests/test_js_python_parity.py +++ b/packages/meshbay-common/tests/test_js_python_parity.py @@ -13,6 +13,7 @@ tests drive the real `crypto.js` under node and compare against the real Python. Skipped when node is unavailable; that is a coverage gap, not a pass. """ +import base64 import json import shutil import subprocess @@ -391,3 +392,211 @@ def test_the_browser_refuses_a_payload_sealed_for_another_message(groupbox_js): sealed["nonce"].hex(), sealed["ct"].hex()], capture_output=True, text=True, timeout=60) assert proc.stdout.strip() == "REFUSED", proc.stdout + proc.stderr + + +# ── chatbox: a chat message, sealed and signed, both directions ────────────── +# +# Same class of invisible disagreement as groupbox above, with one more moving +# part: the per-device subkey is derived from a *string* that carries the group +# id and the device's base64 key, so a mismatch in how either is encoded means +# messages that encrypt fine and never decrypt — and AES-GCM reports that +# exactly the way it reports a wrong key. +# +# The signature is checked in both directions too. It is the half that +# establishes who spoke, and unlike the ciphertext it is verified by clients +# that may never hold the epoch key at all. + +# (group_id, epoch) +CHAT_VECTORS = [ + ("g" * 32, 1), + # Epoch is inside the AAD and the transcript as a decimal string; 0 and a + # large value must not collide with each other or with the empty field. + ("g" * 32, 0), + ("g" * 32, 4294967296), + # Empty group id, the operator-pairing shape. + ("", 1), + # Non-ASCII: TextEncoder and Python's .encode() must agree. + ("groupe-café-日本", 2), + # A '|' inside the group id — the separator used by both the AAD and the + # HKDF info string. + ("a|b", 3), +] + +CHAT_EPOCH_KEY = bytes.fromhex("7c" * 32) + +_CHATBOX_HARNESS = r""" +const fs = require('fs'); + +globalThis.window = {}; +const src = fs.readFileSync(process.argv[2], 'utf8'); +const M = new Function(src + + '\nreturn { sealChat, openChat, chatSigningTranscript, verifyChatSignature };')(); + +const hex = (s) => { + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.substr(i, 2), 16); + return out; +}; +const toHex = (u8) => + Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join(''); + +(async () => { + const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); + const epochKey = hex(input.epoch_key); + const out = { opened: [], sealed: [], transcripts: [], verified: [] }; + + for (const v of input.vectors) { + // Python sealed it; open it here. + out.opened.push(toHex(await M.openChat( + epochKey, v.group_id, v.epoch, v.device_b64, hex(v.nonce), hex(v.ct)))); + // Seal the same plaintext here, for Python to open. + const sealed = await M.sealChat( + epochKey, v.group_id, v.epoch, v.device_b64, hex(v.plaintext)); + out.sealed.push({ nonce: toHex(sealed.nonce), ct: toHex(sealed.ct) }); + // The signed bytes, and whether Python's signature verifies here. + out.transcripts.push(toHex(M.chatSigningTranscript( + v.group_id, v.epoch, hex(v.device_raw), hex(v.nonce), hex(v.ct)))); + out.verified.push(await M.verifyChatSignature( + hex(v.device_raw), v.group_id, v.epoch, hex(v.nonce), hex(v.ct), + hex(v.sig))); + } + + process.stdout.write(JSON.stringify(out)); +})().catch((e) => { console.error(e); process.exit(1); }); +""" + + +def _chat_payload(idx: int) -> dict: + """A distinct message per vector, so a crossed result cannot pass.""" + return {"text": f"message {idx} — café", "thread_id": None, + "sender_name": f"member-{idx}", "sent_at": 1_700_000_000 + idx} + + +@pytest.fixture(scope="module") +def chatbox_js(tmp_path_factory): + import msgpack + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + ) + + from meshbay_common.chatbox import seal + + d = tmp_path_factory.mktemp("chatbox-parity") + harness = d / "harness.js" + harness.write_text(_CHATBOX_HARNESS) + + vectors = [] + for i, (group_id, epoch) in enumerate(CHAT_VECTORS): + # A distinct device per vector: the subkey is derived from the device's + # own base64 key, so reusing one would hide a derivation that ignored it. + sk = Ed25519PrivateKey.from_private_bytes(bytes([i + 1]) * 32) + device_raw = sk.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + device_b64 = base64.b64encode(device_raw).decode() + payload = _chat_payload(i) + sealed = seal(CHAT_EPOCH_KEY, group_id, epoch, device_b64, device_raw, + sk, payload) + vectors.append({ + "group_id": group_id, "epoch": epoch, + "device_b64": device_b64, "device_raw": device_raw.hex(), + "nonce": sealed["nonce"].hex(), "ct": sealed["ct"].hex(), + "sig": sealed["sig"].hex(), + "plaintext": msgpack.packb(payload, use_bin_type=True).hex(), + }) + + payload_file = d / "vectors.json" + payload_file.write_text(json.dumps({"epoch_key": CHAT_EPOCH_KEY.hex(), + "vectors": vectors})) + + proc = subprocess.run( + ["node", str(harness), str(CRYPTO_JS), str(payload_file)], + capture_output=True, text=True, timeout=60, + ) + if proc.returncode != 0: + pytest.fail(f"node chatbox harness failed:\n{proc.stderr}") + return json.loads(proc.stdout), vectors + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_browser_opens_a_chat_message_python_sealed(idx, vector, chatbox_js): + import msgpack + + js, _ = chatbox_js + assert msgpack.unpackb(bytes.fromhex(js["opened"][idx]), raw=False) == \ + _chat_payload(idx) + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_python_opens_a_chat_message_the_browser_sealed(idx, vector, chatbox_js): + import msgpack + + from meshbay_common.chatbox import open_message + + js, vectors = chatbox_js + group_id, epoch = vector + sealed = js["sealed"][idx] + opened = open_message( + CHAT_EPOCH_KEY, group_id, epoch, vectors[idx]["device_b64"], + bytes.fromhex(sealed["nonce"]), bytes.fromhex(sealed["ct"])) + assert opened == _chat_payload(idx) + # And the msgpack the browser produced is what Python produces, so the two + # are not merely each self-consistent. + assert msgpack.packb(opened, use_bin_type=True) == \ + bytes.fromhex(vectors[idx]["plaintext"]) + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_the_signing_transcript_is_byte_identical(idx, vector, chatbox_js): + from meshbay_common.chatbox import signing_transcript + + js, vectors = chatbox_js + group_id, epoch = vector + v = vectors[idx] + expected = signing_transcript( + group_id, epoch, bytes.fromhex(v["device_raw"]), + bytes.fromhex(v["nonce"]), bytes.fromhex(v["ct"])) + assert js["transcripts"][idx] == expected.hex() + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_the_browser_verifies_a_signature_python_made(idx, vector, chatbox_js): + js, _ = chatbox_js + assert js["verified"][idx] is True + + +def test_a_message_does_not_open_under_another_epoch(chatbox_js): + """ + The epoch is in the AAD, so a message cannot be re-presented as belonging + to a later key. Without it, a member who kept an old epoch key could make + an old message look current — and an AEAD that ignored `additionalData` + would round-trip against itself and pass every other test here. + """ + import pytest as _pytest + + from meshbay_common.chatbox import open_message + + _js, vectors = chatbox_js + v = vectors[0] + group_id, epoch = CHAT_VECTORS[0] + with _pytest.raises(Exception): + open_message(CHAT_EPOCH_KEY, group_id, epoch + 1, v["device_b64"], + bytes.fromhex(v["nonce"]), bytes.fromhex(v["ct"])) + + +def test_a_message_does_not_open_under_another_devices_key(chatbox_js): + """ + Each device has its own subkey, derived from its own public key. That is + what makes nonce reuse across two devices of one person impossible without + any coordination — the property per-device ratchet chains were wanted for. + """ + import pytest as _pytest + + from meshbay_common.chatbox import open_message + + _js, vectors = chatbox_js + group_id, epoch = CHAT_VECTORS[0] + with _pytest.raises(Exception): + open_message(CHAT_EPOCH_KEY, group_id, epoch, vectors[1]["device_b64"], + bytes.fromhex(vectors[0]["nonce"]), + bytes.fromhex(vectors[0]["ct"])) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js index 96fc52f..1eba59a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js @@ -17,7 +17,8 @@ import { FolderPickerField } from './folder-tree.js'; * has to be on a read-write root. The picker greys out the rest rather than * letting the node's refusal arrive after the fact. */ -function ChatSettings({ roots, dirs, settings, saveDirectories, transport, signFn }) { +function ChatSettings({ roots, dirs, settings, saveDirectories, transport, + signFn }) { const { busy, msg, run } = useSaver(); const [directory, setDirectory] = useState(settings.chatDirectory || ''); const [linkPreview, setLinkPreview] = useState(settings.chatLinkPreview !== false); @@ -59,6 +60,19 @@ function ChatSettings({ roots, dirs, settings, saveDirectories, transport, signF label=${t('settings_app.chat_link_preview_label')} />

${t('settings_app.chat_link_preview_hint')}

+ ${/* Not a toggle: chat is always encrypted (MNP 2.0), so there is + nothing here to turn on. What an operator may want is to move the + key on deliberately — the removals that matter already do it by + themselves. Stated rather than left invisible, because "is my chat + encrypted?" is a question people ask of a settings pane. */ ''} +
+

${t('settings_app.chat_encrypted_always')}

+ +

${t('settings_app.chat_rotate_epoch_hint')}

+
${msg && html`

${msg}

`} `; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js index 0a7ef26..a7b8fd9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js @@ -203,8 +203,8 @@ function ChatImage({ filename, entries, transportRef, gekRef }) { // read one answer. Empty means the group has no writable root right now — every // root is read-only, or the one drive that was writable is unplugged — and the // paperclip says so rather than producing a refusal from the node. -function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, - onPreview, attachRoot = '', attachDir = '', +function ChatPanel({ transportRef, username, userId, entries, gekRef, + onRefreshIndex, onPreview, attachRoot = '', attachDir = '', onActivity, status }) { const [messages, setMessages] = useState([]); const [hasMore, setHasMore] = useState(false); @@ -257,13 +257,14 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, transport.onChat = (msg) => { const id = msg.id || `live-${Date.now()}-${Math.random().toString(36).slice(2)}`; + // Spread rather than rebuilt field by field: the transport is the one + // place that decides how a message is read, and copying a subset of its + // result here is how the live path and the history path come to disagree + // — which would show up only for messages that cannot be opened. setMessages(prev => [...prev, { + ...msg, id, - sender_id: msg.sender_id, - sender_name: msg.sender_name || '', - payload: msg.payload, timestamp: msg.timestamp || Date.now() / 1000, - thread_id: msg.thread_id, }]); if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id); }; @@ -459,7 +460,8 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, await transport.sendChat(text, 0, null, username); setMessages(prev => [...prev, { id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, - sender_id: username, + own: true, + sender_id: userId || username, sender_name: username, payload: text, timestamp: Date.now() / 1000, @@ -473,7 +475,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, setSending(false); setTimeout(() => { if (inputRef.current) inputRef.current.focus(); }); } - }, [input, username, jumpToBottom]); + }, [input, username, userId, jumpToBottom]); const attachFile = useCallback(async (e) => { const file = e.target.files?.[0]; @@ -501,7 +503,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, await transport.sendChat(structured, 0, null, username); setMessages(prev => [...prev, { id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, - sender_id: username, sender_name: username, + own: true, sender_id: userId || username, sender_name: username, payload: structured, timestamp: Date.now() / 1000, thread_id: null, }]); jumpToBottom(); @@ -510,7 +512,16 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, } finally { setAttaching(false); } - }, [username, onRefreshIndex, jumpToBottom, attachRoot, attachDir]); + }, [username, userId, onRefreshIndex, jumpToBottom, attachRoot, attachDir]); + + // Chat is always encrypted, and sealing needs this device to have identified + // itself to the node (`device_hello`) — which is also what lets the node + // refuse a member claiming somebody else's key. Without it there is nothing + // to send with, so the composer says so before anything is typed rather than + // producing a refusal the reader cannot act on. + const transportNow = transportRef.current; + const cannotSend = !!(transportNow && transportNow.connected + && !transportNow.devicePk); const onKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -543,7 +554,14 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, `} ${messages.map((m, i) => { - const isOwn = m.sender_name === username || m.sender_id === username; + // By account, and by an explicit flag on our own optimistic echo. + // Comparing a display name against a sender id happened to work + // while the echo invented `sender_id: username`, and would have + // started rendering other people's messages as the reader's own the + // moment two members shared a display name. + const isOwn = m.own === true + || (!!userId && m.sender_id === userId) + || (!userId && m.sender_name === username); const displayName = m.sender_name || '?'; const prev = messages[i - 1]; const showSender = !isOwn && (i === 0 || @@ -551,6 +569,26 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, // A conversation read over several days is unreadable without them. const daySep = i === 0 || !_sameDay(prev.timestamp, m.timestamp) ? _dayLabel(m.timestamp) : null; + // A message the transport could not open is shown as a gap, with + // what went wrong. Dropping it would leave a conversation quietly + // missing messages, which is worse than a visible hole: nobody can + // notice what they were never shown. + if (m.unreadable) { + return html` + ${daySep && html` +
${daySep}
+ `} +
+ ${showSender && html`
${displayName}
`} +
+ + ${t('chat.unreadable_' + m.unreadable) || t('chat.unreadable')} + + ${formatTime(m.timestamp)} +
+
+ `; + } const parsed = _parsePayload(m.payload); const att = parsed && parsed.attachment; const msgText = parsed && typeof parsed.text === 'string' ? parsed.text : m.payload; @@ -613,13 +651,14 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, `}