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-common/src/meshbay_common/chatbox.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-common/src/meshbay_common/chatbox.py')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/chatbox.py | 181 |
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 |