aboutsummaryrefslogtreecommitdiffstats
path: root/CLAUDE.md
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 /CLAUDE.md
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 'CLAUDE.md')
-rw-r--r--CLAUDE.md37
1 files changed, 26 insertions, 11 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 4a4175f..808ab47 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -681,17 +681,28 @@ anything that assumes one key per person.
clients too (via `aiortc` in Python)
- Argon2id 256 MB was applied to the **hub only**; `crypto.py` keystore is still 64 MB
- ~~Sender keys must be distributed pairwise to identity keys, never GEK-derived.~~
- **Reversed 2026-09-03:** sender keys are distributed **GEK-wrapped**. The GEK is the
- group secret; files and chat share the same access boundary. Pairwise distribution
- added complexity for a separation (files vs chat) that has no meaning in this
- platform's group model. Sender keys remain **per device, never per person**
- (2026-08-17). Two devices sharing one sending chain both advance it, producing
- key/nonce reuse: that is C1 again, one level down.
- `GroupSenderKeyStore.add_sender` currently does `self._states[dist.sender_id] = ...`,
- so a second device under the same `sender_id` silently overwrites the first. Revoking a
- device must rotate, like revoking a member. See `docs/devel-phases-next.md` §15.0b
+ ~~Reversed 2026-09-03: sender keys are distributed GEK-wrapped.~~
+ **Sender keys are not what group chat uses at all (decided 2026-09-07, built).**
+ Read `docs/chat-sender-keys.md` before touching chat. The reasoning that ended the
+ question: once distribution is under the group key *and* the node serves history to
+ devices that 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 therefore zero either way, and what the ratchet was left buying was a large
+ amount of stateful client code with silent failure modes — three of them reproduced:
+ any member could sign as any other (`add_sender` accepts any distribution and the
+ signing key is bound to nothing), a second device dropped the first's chain, and
+ `_skipped_keys` grew without bound. `senderkeys.py` joins `ratchet.py` as "kept for a
+ possible future 1:1 DM"; **nothing in production imports it**, and its green tests are
+ not evidence that chat is encrypted
- ~~Chat is plaintext on the wire and at rest; the index is plaintext on the WebRTC
- path.~~ **The index half changed 2026-09-03 (MNP 1.0).** `index_sync`,
+ path.~~ **Both halves have changed.** Chat: 2026-09-07, **MNP 2.0** — see the row above and
+ `docs/chat-sender-keys.md`. **There is no switch**: chat is encrypted, the node refuses
+ any message that is not sealed, and 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
+ proposed and refused — every node is a test node, so it would have bought nothing and
+ left a plaintext branch reachable, which is C6's lesson one feature later. Existing node
+ data is migrated by `QE/migration/migrate_chat_encryption.py`, node stopped.
+ **The index half changed 2026-09-03 (MNP 1.0).** `index_sync`,
`index_delta` and the `handshake_ack` configuration payload are sealed under a
GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/
`openGroup` in `crypto.js`); only `type`, `v`, `group_id` and the ack's own
@@ -764,7 +775,9 @@ SFR residential Fedora 44 → meshbay.org OVH VPS:
| Key bundle (web) | `meshbay_common.keyderive` | `keyderive.py` + `static/keyderive.js` |
| GEK wrap/unwrap (ECIES) | `meshbay_common.crypto` | `crypto.py` |
| Double Ratchet (1:1 DM, future) | `meshbay_common.ratchet` | `ratchet.py` |
-| Sender Keys (group chat) | `meshbay_common.senderkeys` | `senderkeys.py` (Phase 7.5) |
+| Chat encryption (group chat) | `meshbay_common.chatbox` | `chatbox.py` + `sealChat`/`openChat`/`verifyChatSignature` in `static/crypto.js`. One key per group, per epoch, per **device**, derived by name from an epoch key the node generates and delivers wrapped under the GEK — so rotating the GEK is a re-wrap and does not destroy the archive, and two devices can never share an AES key. Messages are signed over the **ciphertext** with the device's pinned Ed25519 key |
+| Chat epochs (node) | `meshbay_node.ops` | `open_chat_epoch` / `ensure_chat_epoch` / `chat_epoch_keys`. Epoch 1 is opened at group load (`daemon._ensure_chat_epoch`) — a group with no epoch is a group nobody can speak in. A new epoch on every removal (member, device, unpin, `gek_rotate`); **old epochs are kept and still delivered**, which is what keeps history readable, and nothing anywhere deletes one. Keys are wrapped to the node's own X25519 key in `bundles.db`, never stored raw |
+| ~~Sender Keys (group chat)~~ | `meshbay_common.senderkeys` | **Unused.** Kept for a possible future 1:1 DM, like `ratchet.py` — see the corrections above |
| AES-GCM (browser) | `meshbay_common.webcrypto` | `webcrypto.py` + `static/crypto.js` |
| Node keystore | `meshbay_node.keystore` | `keystore.py` |
| QUIC NAT punch (native) | `meshbay_node.transport.quic_server` | `QuicChunkServer.punch_nat()` |
@@ -782,6 +795,8 @@ SFR residential Fedora 44 → meshbay.org OVH VPS:
| Public group cap (hub) | `meshbay_hub.api.groups` | `_check_public_group_quota` — 10 live public groups per owner, staff exempt. **Checked at creation only, because PATCH refuses to change visibility** |
| Uploads on/off (node) | `meshbay_node.roster` + `transport.webrtc_server` | `member_upload_allowed` / `set_member_upload`, gate in `_do_file_upload`. Per group, **operator-signed** (`OP_MEMBER_UPLOAD`), stored in `roster.db`, cached in the group context because the upload path is synchronous. **Absent means allowed** at every layer |
| Node presence (hub) | `meshbay_hub.api.groups` | `node_online` on `/v1/groups/mine`, read from the signaling registry — no poll, no timer |
+| Chat message handling (node) | `meshbay_node.transport.webrtc_server` | `_do_chat_message` + `_check_chat_envelope`. `sender_id` from the session (NS6); the *device* claim is checked against the connection's own `device_hello`, or a member could sign as anyone. Replay refused by a unique `(device, nonce)` in `chat.db` — a replay is a validly signed copy, so nothing about the signature refuses it |
+| Which device is on a connection | `meshbay_node.transport.webrtc_server` | `_do_device_hello` (MNP 1.2, additive). The handshake proves the *account*; this proves the *device*. Before it, `_load_pinned_pk` used the account's oldest key and recorded it as the uploader of every file |
| Chat paging (node) | `meshbay_node.chat.store` | `get_recent` / `get_before` / `has_before`. `get_messages` pages *forwards* and is not what a chat opens with |
| Liveness (MNP) | `meshbay_common.protocol` | `PING`/`PONG` on an **already-open** channel; never for discovery — a handshake costs 0.6-7 s |
| Profile page (browser) | `static/app.js` | `ProfilePage` — identity, node link, pins, account deletion. Settings keeps behaviour |