aboutsummaryrefslogtreecommitdiffstats
path: root/docs/meshbay-draft-v6.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 /docs/meshbay-draft-v6.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 'docs/meshbay-draft-v6.md')
-rw-r--r--docs/meshbay-draft-v6.md52
1 files changed, 34 insertions, 18 deletions
diff --git a/docs/meshbay-draft-v6.md b/docs/meshbay-draft-v6.md
index 28aea0c..eb7e56d 100644
--- a/docs/meshbay-draft-v6.md
+++ b/docs/meshbay-draft-v6.md
@@ -555,29 +555,45 @@ v5 §9's list stands, with these movements:
|---|---|---|
| C4 | Remote keypair bundles | **Partially closed.** Gone for native devices; open for browser-using accounts until the signed `device_policy {allow_bundle: false}` opt-out ships |
| T3 | Hub serves the SPA | **Accepted permanently** for browser users. Removed for native clients, whose value depends on 18.7 |
-| — | Chat encryption (Sender Keys) | Phase 15, unchanged. Pairwise to identity keys, never GEK-derived — and **now to devices**, which multiplies the recipients per person |
+| — | Chat encryption | **Built 2026-09-07, and not as Sender Keys** — see `docs/chat-sender-keys.md`. The row that stood here ("pairwise to identity keys, never GEK-derived") was reversed on 2026-09-03 and then overtaken entirely. Per group, off by default, MNP 1.2 |
| — | Delegation | Designed, deferred, unchanged |
| — | Hub identity pinning | New. `GET /v1/hub/pubkey` exists and nothing pins it; bounded, because a substituted hub can neither read content nor ship the code to a native client |
-**Phase 15 has been re-read against device linking (2026-08-17) and was wrong as written.**
-The correction is recorded in `devel-phases-next.md` §15.0b; the load-bearing part:
+**Phase 15 was re-read twice and abandoned as written.** The first correction
+(2026-08-17, `devel-phases-next.md` §15.0b) said a sender key must be per device, never
+per person, because a shared chain advanced by two devices produces key and nonce reuse —
+`first-review.md` C1, one level down. That is still true, and it is why the design that
+was built has no shared mutable sending state at all.
-- **A sender key is per device, never per person.** A shared per-person chain advanced by
- two devices produces key and nonce reuse — which is exactly why `first-review.md` C1
- rejected a shared Double Ratchet for groups. The same mistake, one level down.
-- `senderkeys.py` already fails this silently: `GroupSenderKeyStore.add_sender` does
- `self._states[dist.sender_id] = ...`, so a second device under the same `sender_id`
- **overwrites the first and drops its chain**. `sender_id` must become a device
- identifier.
-- **Revoking a device must rotate**, like revoking a member.
-- **A newly linked device cannot read history** until every sender redistributes, unless
- the linking device hands over its own state sealed to the new device's key.
-- **Sender attribution stays node-trusted.** A sender key proves a *device*; the mapping
- from device to account comes from the node's roster. Encrypted chat does not make
- senders cryptographically authenticated to each other, and the docs must not imply it.
+The second correction (2026-09-07) ended the protocol choice. **`docs/chat-sender-keys.md`
+is the specification; this records only what changed.** 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, so the ratchet
+bought no confidentiality over one AEAD while adding stateful client code with silent
+failure modes. Three were reproduced in that document; the worst is that under group-key
+distribution **any member could sign as any other**, because `add_sender` accepts any
+distribution and the signing key inside one is bound to nothing.
-Ordering consequence: **device linking (Stage C) lands before Phase 15**, or Phase 15 is
-built against an identity model that is about to change underneath it.
+What was built instead: one key per group, per epoch, per **device**, derived by name from
+an epoch key the node generates and delivers wrapped under the group key. A new epoch
+opens whenever the set of devices that may read future messages shrinks; old epochs are
+kept and still delivered, so the history stays readable to everyone who could already read
+it — and rotating the group key becomes a re-wrap rather than the destruction of the whole
+archive, which is what a group-key-derived archive key would have caused on the first
+`member unpin`. Messages are signed over the ciphertext with the device key the node
+pinned.
+
+Two properties of the old plan survive unchanged:
+
+- **Revoking a device opens a new epoch**, exactly as revoking a member does.
+- **Sender attribution is device-rooted, and the device-to-account mapping comes from the
+ node's roster.** Encryption does not by itself make senders cryptographically
+ authenticated to each other; the *signature* does, and only as far as the reader's
+ roster is honest. The docs must not imply more.
+
+Ordering consequence, unchanged and now satisfied: **device linking (Stage C) lands
+first**, or this is built against an identity model about to change underneath it.
---