aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/chatbox.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-common/src/meshbay_common/chatbox.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-common/src/meshbay_common/chatbox.py')
-rw-r--r--packages/meshbay-common/src/meshbay_common/chatbox.py181
1 files changed, 181 insertions, 0 deletions
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