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/senderkeys.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/senderkeys.py')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/senderkeys.py | 51 |
1 files changed, 38 insertions, 13 deletions
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. +MeshBay — Sender Keys protocol. **Not used by group chat. Not used at all.** -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 +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. -Key components: - - Chain key ratchet: HKDF per message, provides forward secrecy +**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 |