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/protocol.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/protocol.py')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/protocol.py | 29 |
1 files changed, 28 insertions, 1 deletions
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 |