From 36cebf25d0e0f24cf63be4380ccb5d03da726a74 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 7 Sep 2026 17:50:28 +0200 Subject: feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr --- docs/chat-sender-keys.md | 807 ++++++++++++++++++++++++++++++++++++++++++++++ docs/devel-phases-next.md | 31 +- docs/meshbay-draft-v6.md | 56 ++-- 3 files changed, 865 insertions(+), 29 deletions(-) create mode 100644 docs/chat-sender-keys.md (limited to 'docs') diff --git a/docs/chat-sender-keys.md b/docs/chat-sender-keys.md new file mode 100644 index 0000000..fefd327 --- /dev/null +++ b/docs/chat-sender-keys.md @@ -0,0 +1,807 @@ +# Chat encryption — review of Phase 15 and an implementation plan + +> Status: **Design A decided and BUILT (2026-09-07). MNP 2.0 — a break, and +> deliberately not an option.** There is no switch: chat is encrypted, and a 1.x +> peer is refused at the handshake with `version_too_old` rather than connecting +> and then failing to speak. Existing node data is migrated by +> `QE/migration/migrate_chat_encryption.py`. +> +> §10 records what each stage actually shipped and the three places the plan +> below was wrong. §13 records the one part deliberately not built. +> Written 2026-09-07 against the tree at `8883d60`. Supersedes the Phase 15 text +> in `devel-phases-next.md` where the two disagree; that document's milestone +> table is wrong in three places and this says why. +> +> The decision is recorded in §4. It reverses the "Sender Keys" framing of Phase +> 15 and of `meshbay-draft-v6.md` §5 — §9 lists every document that must be +> corrected, and §12 lists the four policy questions that are still open and that +> Stage 3 needs answered. +> +> Written under the v5/v6 convention that is not negotiable: **a claim here must +> name the adversary it holds against.** Everything below that reads as a +> security property is written that way, and the sections that say "this buys +> nothing" are as load-bearing as the ones that say it buys something. +> +> **Placed in `~/meshbay/docs/` rather than `~/docs/`**, which does not exist — +> every other design document lives here. + +--- + +## 0. Reading order + +| Read | For | +|---|---| +| **this document** | what is actually true of the chat path today, and what to build | +| `devel-phases-next.md` §15 | the roadmap entry this replaces | +| `meshbay-draft-v6.md` §5 | the "still open" row for chat — **now stale**, see §9 | +| `desktop-client-v1.md` §4.8 | authorship: Tiers 1–3, the no-new-code-exchanges constraint | +| `per-node-identity-v1.md` | why there is no key directory, and what a device key is | +| `first-review.md` C1 | why a shared chain in a group is the original sin here | + +--- + +## 1. What exists today, verified + +Not read from the roadmap — read from the tree, and where a claim below is +behavioural it was produced by running the code. + +**The protocol module.** `packages/meshbay-common/src/meshbay_common/senderkeys.py` +implements a Signal-style sender-key ratchet: `SenderKeyRecord` (own state), +`SenderKeyDistribution` (chain key + iteration + a **freshly generated** Ed25519 +public key), `GroupSenderKeyStore` (received states), `encrypt_message`, +`decrypt_message`. 211 lines of tests, all green +(`.venv/bin/pytest packages/meshbay-common/tests/test_senderkeys.py` → 13 passed). +**Nothing in production imports it.** `grep` finds it in its own test file and +nowhere else. + +**The chat path is plaintext, end to end.** + +- Browser: `static/chat-app.js` calls `transport.sendChat(text, 0, null, username)` + (`static/transport.js:1423`), which puts the message string on the wire in + `payload`. Attachments are the same call with a JSON string. +- Node, WebRTC: `webrtc_server.py:3819 _do_chat_message` — stores `payload` as + received, broadcasts it to the group's peers, notifies the hub, acks. +- Node, QUIC: `quic_server.py:477 _do_chat_message_sync` — same shape, no history. +- Storage: `chat/store.py`, one SQLite database per group at + `data_dir/{group_id}/chat.db`, columns `sender_id, iteration, payload, + timestamp, thread_id, sender_name`. **`iteration` is already there and is + always 0.** +- History: `webrtc_server.py:3902 _send_chat_history` → `get_recent` / + `get_before` / `has_before`, with `has_more` driving the "load older" control. + +**What the node decides today.** `sender_id` is taken from the authenticated +session, never the wire (NS6). The peer registry and the chat store are per group +(H1). Both are held by `test_security_regressions.py`. + +**What the client already has to work with.** The renderer holds the per-node +identity private keys (`transport.js:_sessionKeys.skEdB64` / `skXB64`, recovered +from the keypair bundle) and can sign arbitrary bytes through +`window.MeshBayKeys.signBytes`; `_pkEdFromSk` derives the matching public key. +The GEK is in memory for the life of the connection. `groupbox.py` + +`sealGroup`/`openGroup` in `crypto.js` are a working, parity-tested AEAD envelope +under a GEK-derived subkey. IndexedDB and `sessionStorage` helpers exist in +`hub-client.js`. + +**What the node holds.** The node is a group member. Its copy of the GEK is +ECIES-wrapped to its own X25519 key in `bundles.db`; that private key lives in +the Argon2id-encrypted keystore (`keystore.py`). That is the only reason "someone +who images the disk" is a different adversary from "the operator" — and it is the +whole basis of the threat model below. + +--- + +## 2. Seven findings + +The first four were reproduced by running the module; the script is quoted so the +result can be re-derived rather than believed. + +### F1 — Any group member can impersonate any other member, silently + +`GroupSenderKeyStore.add_sender` (`senderkeys.py:178`) is +`self._states[dist.sender_id] = SenderKeyState.from_distribution(dist)`. It +accepts any distribution, for any `sender_id`, at any time, and **overwrites** +what is there. The `signing_pk` inside a distribution is generated fresh in +`SenderKeyRecord.create` — it is bound to nothing: not to a pinned device key, +not to the roster, not to the account. + +Under the 2026-09-03 decision that distribution is GEK-wrapped, *every member can +produce a valid distribution*. So every member can replace another member's chain +with one they hold the signing key for, and every subsequent forged message +verifies: + +``` +real: b'real alice' +forged: b'forged as alice' # sent by a second record created as "alice" +``` + +This is not a subtlety of the ratchet; it is the direct consequence of pairing an +unauthenticated distribution format with a distribution channel every member can +write to. **Encrypting chat this way would make impersonation *worse* than today**, +where the node at least enforces `sender_id` from the session (NS6). Any design +that ships must bind the distribution — or the message — to a key the node pinned +for that account. + +### F2 — A second device silently destroys the first device's chain + +Already recorded in `devel-phases-next.md` §15.0b and in v6 §5. Confirmed: + +``` +senders after two devices: 1 +device1 FAILED: InvalidSignature +``` + +Two `SenderKeyRecord.create("alice")` registered in one store leave one state, and +the earlier device's messages then fail signature verification rather than failing +visibly at registration. The fix recorded in the roadmap — make `sender_id` a +device identifier — is necessary and **not sufficient**: it does nothing about F1, +which is the finding that decides the design. + +### F3 — The skipped-key cache is unbounded + +`SenderKeyState.advance_to` caches every skipped message key and nothing ever +trims `_skipped_keys`. `MAX_SKIP` bounds one jump at 256, not the total: + +``` +cached skipped keys after 10 jumps: 2000 +``` + +A member who can write distributions (F1) or simply send messages with a high +`iteration` grows every other member's memory 32 bytes at a time, for free, and +those keys are exactly the material forward secrecy is supposed to have destroyed. +Any receiver state that ships needs a cap and an eviction rule. + +### F4 — GEK rotation would destroy the entire chat history + +`ops.set_gek(..., rotate=True)` (`ops.py:267`) generates a fresh key, wraps it for +the node, and drops the old one. Nothing keeps it. That is correct for everything +encrypted **on the fly** — files are plaintext on disk and chunk-encrypted per +transfer, the index is sealed at send time — but chat would be the first thing in +the system encrypted **at rest** under something derived from the GEK. + +Rotation is not an edge case: it is the documented, required step after removing a +member ("still rotate the GEK, the ex-member holds the current one"). So the +sequence "remove a member → rotate → every message anyone ever sent is +permanently unreadable, for everybody, including the operator" is the normal +operating procedure. Nothing in Phase 15 mentions it. + +**This alone rules out "seal chat under a GEK subkey and be done".** Whatever key +protects the archive must survive rotation as a re-wrap, not as a re-encryption. + +### F5 — History is served to devices that were not there, which is incompatible with a ratchet + +`chat_hist` serves any authorized member the newest page and pages backwards with +no filter on when they joined. That is the behaviour today and users rely on it — +`hasMore`, `loadOlder`, the day separators, the unread marker. + +A ratchet cannot serve that. A device that arrives at iteration 900 can derive +message keys for 900 onwards and nothing before. §15.0b's answer is that "the node +replays the latest distribution message for each active chain" — which gives that +device the *newest* page and nothing older, so "load older" returns rows that +render as garbage. To keep history working, the node must retain and hand out the +**earliest** distribution for every chain, at which point see §3. + +### F6 — Ciphertext would be corrupted on the history path + +`webrtc_server.py:3920`: + +```python +"payload": m.payload.decode("utf-8", errors="replace") if isinstance(...) +``` + +`errors="replace"` silently substitutes U+FFFD for every byte that is not valid +UTF-8, which is most of a ciphertext. History would come back mangled while live +messages worked, i.e. it would look like an intermittent decryption bug. The wire +must carry msgpack `bin` (both codecs support it — Python's `use_bin_type=True`, +and `transport.js` encodes `Uint8Array` as 0xc4/0xc5/0xc6 and decodes the same at +`transport.js:2884`), and this line must go. + +### F7 — One account cannot hold two connected devices + +`webrtc_server.py:746` is `self._peer_registry()[self._user_id] = self`, and +teardown pops the same key. So a person's second device **evicts the first from +the registry**, and when either disconnects the other stops receiving broadcasts. +The broadcast loop then excludes the sender by account +(`webrtc_server.py:3849`, `quic_server.py:511`), so a person's own other devices +would never see their own messages live even if the registry held them. + +This is the same shape as `pin_identity`'s old `INSERT OR REPLACE` and as F2 — the +third instance of "keyed by account where it should be keyed by device". It is a +**live defect today**, independent of encryption, and it must be fixed first: a +per-device chat design built on a registry that cannot hold two devices of one +account is untestable. + +Related, same class: `_load_pinned_pk` (`webrtc_server.py:4286`) calls +`roster.get_identity()`, documented as "this account's **oldest** live device", and +assigns it to `self._pinned_pk` — which is then recorded as `entry.uploader_pk` on +every upload (`webrtc_server.py:4213`). With device linking live, uploads are +attributed to the wrong device, and `desktop-client-v1.md` §4.8 A (authorize +deletion against any non-revoked device of `uploader_id`) is **still not done**. +Out of scope here, but it is the same root cause and should be fixed in the same +sweep. + +--- + +## 3. The load-bearing analysis: what the ratchet is actually buying + +The 2026-09-03 decision (GEK-wrapped distribution) and the requirement that +history keep working (F5) interact, and the interaction is not recorded anywhere. +It is worth stating in full because it decides everything downstream. + +Take the design exactly as §15.0/§15.0b specify it: + +1. Distribution is GEK-wrapped, so every member can unwrap every sender key. +2. History must be readable by devices that were not present, so the node must + retain distributions and hand them out. +3. To make *all* history readable — which is what "load older" means — the + retained distribution must be the one at the chain's **earliest** iteration. +4. A chain key at iteration *i* deterministically yields every message key from + *i* onward: `_ratchet_chain` is pure HKDF. + +Therefore: **anyone who obtains the GEK at any moment can decrypt the entire chat +archive, past and future.** There is no forward secrecy and no post-compromise +security. The ratchet is computing `HKDF^n` over a value every member already +holds and the node stores forever. + +Which means the honest comparison is: + +| | Sender keys as specified | One AEAD under a GEK-delivered archive key | +|---|---|---| +| Readable by a disk image without the keystore password | no | no | +| Readable by any member / the operator | yes | yes | +| Readable by an ex-member who kept a GEK | yes, for the epoch they had | yes, for the epoch they had | +| Forward secrecy | **none** (see above) | none | +| Post-compromise security | **none** | none | +| Per-device state to persist, migrate, lose | one chain per device per group, in IndexedDB | none | +| Silent failure modes | F1, F2, F3, F5 | — | +| Lines of new client-side crypto | a ratchet, in JavaScript, from scratch | one `seal`/`open` pair that already exists | + +The two columns deliver the *same* security property against the *same* +adversary. That is not an argument that sender keys are a bad protocol; it is that +**GEK-wrapped distribution plus server-side history removes every property that +distinguishes them**, and what is left is a large amount of stateful client code +whose failure modes are silent. + +The property that is genuinely wanted and that neither column provides is **sender +authentication** — F1 is the acute version of it, and `desktop-client-v1.md` §4.8 +already decided the answer: sign each message with the sender's device key +(Tier 1), pin `account → device keys` client-side (Tier 2). That is independent of +encryption and can land first. + +One clarification, because the roadmap gets it backwards: §15.0b says a per-device +chain is required because a shared per-person chain reuses keys and nonces. True, +and it is C1 one level down. But the reuse hazard comes from *shared mutable +sending state*, and a design with no sending state at all does not have it — which +is a stronger guarantee than per-device chains, not a weaker one. + +--- + +## 4. The decision — Design A, settled 2026-09-07 + +Two coherent designs were put to the operator. They are not a spectrum; picking +"sender keys, but history works" is picking A with extra machinery. **Design A is +the decision.** Design B is kept below as the road not taken, because the reason +it was refused is the same reason it must not be reintroduced later by someone +reading the phase title. + +### Design A — sealed chat archive, per-device keys, signed messages ✅ **DECIDED** + +Chat is encrypted at rest and on the wire with AES-256-GCM under a key derived +from a **group chat epoch key** the node generates and delivers to members wrapped +under the current GEK. Each message is signed by the sending device's pinned +Ed25519 identity key. No ratchet, no per-device chain state, no client-side +persistence. + +- Keeps history for everyone, including new members and new devices (F5). +- Survives GEK rotation, because the epoch key is delivered wrapped, not stored + wrapped (F4). +- Has no mutable sending state, so C1-class reuse is impossible by construction + (F2, F3 cannot exist). +- Fixes impersonation properly, with roster-rooted keys rather than a distribution + format anyone can write (F1). +- Reuses `groupbox.py` / `crypto.js`, which are already parity-tested. + +Costs: no forward secrecy — which, per §3, is what the specified design delivers +anyway. It must be *stated*, not quietly inherited. + +### Design B — real sender keys, with the forward secrecy that justifies them ❌ **refused** + +Keep the ratchet, and accept its consequences honestly: distributions are **not** +retained by the node, a device reads only what was sent after it joined, and +"load older" stops at the device's own horizon. Distribution must then be pairwise +to device X25519 keys (the pre-2026-09-03 position), because GEK-wrapping a +distribution the node stores is what destroys the property. + +Costs: the chat history feature is materially reduced — a new phone shows an empty +conversation, and a member who reinstalls loses everything. Under "I don't want +regressions", this is a regression, and a visible one. It also reinstates the +O(devices × members) fan-out that the 2026-09-03 decision removed for good +reasons. + +### Why A, recorded so it does not have to be re-argued + +**Design A delivers the stated threat model (§8) exactly**, keeps every current +chat behaviour, removes four classes of silent failure (F1–F3, F5) and is +substantially less code. Design B was refused because losing the history — a new +phone opening on an empty conversation, a reinstall losing everything — is a +visible regression, and because the forward secrecy that would justify paying for +it is not obtainable while the node serves history to devices that were not +present. + +Forward secrecy is therefore **given up deliberately and on the record**, not +inherited by accident. Per §3, the design as specified in Phase 15 did not provide +it either; the difference is that this says so. If it ever becomes a real +requirement, it belongs in 1:1 DM with `ratchet.py`, where there is no +server-side history to contradict it — not in group chat. + +The consequence of the decision is that **`senderkeys.py` is not the module this +feature is built on.** It joins `ratchet.py` as "kept for a possible future 1:1 +DM"; see §9. The phase is renamed *Chat encryption*, because "Sender Keys" is the +name that led the plan to a protocol that does not fit the deployment. + +--- + +## 5. Design A in detail + +### 5.1 Keys + +``` +epoch_key 32 random bytes, generated BY THE NODE, per group, per epoch + (C5b: no key material arrives from outside) + +device_key(d) = HKDF-SHA256(epoch_key, + info = "meshbay:chat:dev:v1|" + group_id + "|" + d, + salt = none, len = 32) + where d = base64(device pk_ed25519), the roster's own identifier +``` + +Every member derives `device_key(d)` for every device from the epoch key, so there +is nothing to distribute per device and nothing to store. A device encrypts only +under its own subkey, so **two devices never share an AEAD key** and nonce reuse +across devices is impossible without any coordination — the property §15.0b wanted +per-device chains for, obtained by derivation instead of state. + +Nonces are 96-bit random per message. At one key per device per epoch, the NIST +SP 800-38D ceiling of 2^32 invocations under a random 96-bit nonce is unreachable +by a human typing; `groupbox.py` already makes and documents this argument. + +### 5.2 The message + +Plaintext, msgpack: + +``` +{ text, thread_id, sender_name, attachment?, sent_at } +``` + +`sender_name` moves **inside** the envelope. Today it is a wire field any peer can +set to anything and the node caches it in `_user_names()` for rendering history — +i.e. display-name spoofing is free today. Inside the sealed, signed payload it is +authenticated. + +Wire (`chat_msg`, and each row of `chat_hist_resp`): + +``` +epoch uint which epoch key this is under +device bin sender's pinned pk_ed25519, raw +nonce bin(12) +ct bin AES-256-GCM(device_key(device), nonce, msgpack(plaintext), + aad = "chat_msg|" + group_id + "|" + epoch) +sig bin(64) Ed25519 over "meshbay:chat:v1" ‖ group_id ‖ epoch + ‖ device ‖ nonce ‖ ct +``` + +The AAD binds the group and the epoch, as `groupbox.associated_data` binds type +and group — a ciphertext cannot be replayed into another group or attributed to +another epoch. The signature covers the ciphertext, not the plaintext, so it is +verifiable before decryption and by anyone holding the roster. + +`sender_id` stays a clear field **set by the node from the authenticated session**, +exactly as NS6 requires. It is what the store keys on and what the UI groups by; +it is not what authenticates the message. + +### 5.3 Storage on the node + +`chat.db` gains three columns, all with defaults so an existing database opens +unchanged (`store.py` already does additive `ALTER TABLE` this way): + +``` +format INTEGER NOT NULL DEFAULT 0 -- 0 = legacy plaintext, 1 = sealed v1 +epoch INTEGER NOT NULL DEFAULT 0 +device BLOB DEFAULT NULL +``` + +`payload` holds the ciphertext; `nonce` and `sig` join it (either two more BLOB +columns or one msgpack envelope in `payload` — prefer columns, they are greppable +and the migration is the same). + +**Epoch keys are never stored in the clear.** They go where the node's own GEK +copy goes: ECIES-wrapped to the node's X25519 key via `wrap_gek_aes`, in +`bundles.db` under a new table, unlockable only through the Argon2id keystore. A +plaintext `chat_keys.db` sitting next to `chat.db` would collapse the entire +threat model into nothing, silently, and it is the obvious thing to write. It is +worth a test that reads the file and asserts no 32-byte value from the live epoch +appears in it. + +### 5.4 Delivery + +New MNP pair, additive (MNP → **1.2**): + +``` +chat_keys_req client → node { group_id } +chat_keys_resp node → client sealed under groupbox purpose "chat_keys": + { epochs: [ {epoch, key}, ... ], current: n } +``` + +Sealed with the existing `groupbox` envelope under a new purpose (`PURPOSE_CHAT`, +info `b"meshbay:chat:keys:v1"`) — one more entry in `_INFO` and in +`GROUPBOX_INFO`, held by the existing parity test. Delivered on request after the +handshake rather than on the ack, so the ack does not grow for groups that do not +use chat. + +Which epochs a member receives is the node's decision, from the roster: + +- **all live epochs** — the default, and what preserves today's behaviour: a new + member sees the history, exactly as they do now; +- **current epoch only** — an operator setting for groups where joining should not + hand over the back catalogue. Not built in the first pass; the shape must be + there so it can be, without a wire change. + +A member whose access was revoked gets nothing, because the connection does not +complete. + +### 5.5 Epochs and rotation — the part that must not be got wrong + +A new epoch is opened when, and only when, the set of devices that may read +*future* messages shrinks: + +- `member unpin`, `member revoke`, `device revoke` +- `gek_rotate` (the operator is rotating precisely because someone left) +- an explicit `chat rotate` + +Opening an epoch is: generate 32 bytes, store wrapped, increment `current`, push +`chat_keys_resp` to connected members. Old epochs are **kept and still delivered** +to current members, so history stays readable. That is the whole answer to F4: +the archive is not re-encrypted, and the GEK is not what the archive is encrypted +under — it is only what the epoch keys are wrapped with in transit, so a rotation +is a re-wrap on the next connection and costs nothing. + +Note the property this gives and the one it does not: after an epoch change, an +ex-member holding the old GEK **and** an old `chat_keys_resp` still reads the +history they could already read, and reads nothing new. That is the same boundary +as files, which is the point. + +### 5.6 There is no switch — MNP 2.0, and a flag day + +**Revised 2026-09-07, operator decision.** The plan above proposed a per-group +`chat_encrypted` setting, off by default, so a node upgraded into a running group +would refuse nobody. That was refused, and the reasoning is worth keeping: + +> **Every node in existence is a test node.** There is no installed base to +> protect, so an opt-in flag buys nothing and costs a compatibility path that has +> to be written, tested, and eventually removed. What it *would* buy is a +> plaintext branch that stays reachable — which is the bypass C6 is the standing +> lesson about. + +So: **chat is encrypted, and there is nothing to turn off.** The break is +expressed where it belongs, in the protocol version: + +- `MNP_VERSION` is **2.0** and `MNP_MIN_SUPPORTED` moves with it. A 1.x peer is + refused **at the handshake**, with `version_too_old` and a sentence saying so — + not admitted and then left unable to send or read anything. A stated refusal is + a bug report; a chat that quietly does not work is a support case. +- The node refuses any `chat_msg` that is not sealed. `FORMAT_PLAIN` still exists + as a *storage* state, because rows written before 2.0 are still in `chat.db` + and still served; it is never accepted from the wire. +- Nothing reads a `chat_encrypted` setting anywhere, and a test asserts that by + reading the source. A default that can be wrong is a bypass with a name. + +The cost is a coordinated deployment: hub, nodes and clients move together. That +is what MNP 1.0 already paid for once (the sealed index), and `check_version` is +what makes it cost a refusal message rather than a mystery. + +**Existing node data** is migrated by `QE/migration/migrate_chat_encryption.py`, +run with the node stopped. It opens epoch 1 for every group that has none and +re-encrypts what is already in `chat.db` — the one step nothing can do later, +because after it the node holds no plaintext chat to convert. It backs the +database up first, commits once per group, and is idempotent. + +### 5.7 Existing plaintext history + +Rows written before 2.0 keep `format = 0` and render as they always did; the +client reads both. **Nothing is rewritten automatically**, at start-up or +otherwise: a one-shot rewrite of the only copy of a conversation is not something +a daemon should do to a machine while nobody is looking. + +Two ways to convert them, the same operation behind both: + +- `QE/migration/migrate_chat_encryption.py`, with the node **stopped** — the + upgrade path, and the one to use on an existing test node. +- `meshbay-node chat encrypt-history --group `, with the node running, for a + group attached later or a database restored from a backup. + +Both copy `chat.db` to `chat.db.bak-` before touching a row and commit +once, so an interrupted run leaves the database exactly as it was. Both seal the +converted messages under a **synthetic device belonging to the node**, marked +`migrated: true` in the payload: the node holds nobody's signing key and must not +pretend to, and those messages only ever carried its word for who wrote them. + +Until one of them has run, `meshbay-node chat status` reports the plaintext +count — those rows are the ones still readable off a stolen disk, and an operator +who thinks the feature is finished needs to be told otherwise. + +### 5.8 What is deliberately not encrypted + +Stated here so nobody later reads more into the feature than it does: + +- **`sender_id`, `timestamp`, message sizes, and the fact of a message** are in + the clear to the node. It is the relay; it cannot route otherwise. +- **The hub learns, per message, the group, the time, and today the sender's name + and account id** (`chat_notify` → `revocation.py:136`, which renders + "*Name* posted in *Group*"). Encrypting the body while shipping that is worth + being explicit about: the hub keeps a full social graph with timings. Dropping + `sender_name` from the notification costs one string ("New message in *Group*") + and should be done in the same change. `sender_user_id` is needed to skip the + sender and stays. +- **Link previews still send the URL to the node** (`link_preview_req`), by + design — the client asks, the node fetches. The node therefore learns the links + posted in an encrypted chat. This is already true and already documented as an + SSRF surface; it now also belongs in the user-facing text. +- **Attachments are ordinary files on a root and stay plaintext on disk** (15.7). + The *reference* to an attachment is inside the sealed payload, but the file and + its name are in the index. Encrypting them is a different feature with a + different blast radius; the asymmetry gets documented, not hidden. + +--- + +## 6. Sender authentication — common to both designs, ships first + +`desktop-client-v1.md` §4.8 decided this; none of it is built. It is independent +of encryption, it is what actually closes F1, and it is worth shipping on its own. + +**Tier 1 — sign every message with the sender's device key.** The signature field +in §5.2, over the plaintext bytes while chat is still plaintext, over the +ciphertext once it is not. The client already holds `skEdB64` and can sign. The +node stores the signature and serves it back with history; it does not need to +verify (it can, cheaply, and refusing an unverifiable message is a good cheap +gate — but the authority is the receiver's check, not the node's). + +**Tier 2 — clients pin `account → device keys`.** Needs something that does not +exist: a member-visible roster. `roster_read` is operator-only. Add +`group_roster_req` / `group_roster_resp` (sealed under the group key), returning +for each member of *this group*: `user_id`, `username`, and their live devices' +`pk_ed25519` with the `added_by_pk` countersignature that admitted each one. The +client pins on first sight, verifies later devices against the countersignature +chain, and shows the one notice §4.8 budgets for: "this account's key changed". + +Two things to get right: + +- This publishes each member's device count and device keys to every other member + of the group. That is a metadata change and it is the price of Tier 2; it stays + inside the group, and the hub is not involved. Say it in the doc. +- **The countersignature is verified and thrown away.** `_do_device_add` + (`webrtc_server.py:1320`) checks the signature and stores only `added_by_pk` — + *which* key approved, not the proof. Worse, `device_add_transcript` + (`device.py:102`) binds `nonce_node`, a per-connection nonce, and `ts`, so even + a stored signature is unverifiable by a third party unless the nonce and + timestamp are stored with it. Tier 2 therefore needs `device_add` to persist + `(sig, nonce_node, ts)` alongside the pin — a small additive change, but it must + land *before* Tier 2 is useful. Devices pinned before that change carry no + evidence and are trust-on-first-use only; that is acceptable and must be visible + in the UI, not papered over. + +**Tier 3 (operator roster attestation) stays deferred**, per §4.8. Nothing here +depends on it. + +--- + +## 7. Regression register + +The instruction driving this document is "no regressions". Each row is a concrete +way this feature breaks something that works today, with what stops it. + +| # | Regression | Guard | +|---|---|---| +| R1 | GEK rotation makes all history unreadable (F4) | epoch keys wrapped at delivery, never at rest; test: rotate the GEK, reconnect, read the oldest page | +| R2 | A new member or new device sees a wall of garbage instead of history (F5) | all live epochs delivered by default (§5.4); test with a device pinned after the messages were sent | +| R3 | Ciphertext mangled by `errors="replace"` (F6) | payload becomes msgpack `bin` on both codecs; delete `webrtc_server.py:3920`'s decode; test asserts a non-UTF-8 payload survives a round trip through `chat_hist` | +| R4 | Old plaintext rows stop rendering | `format` column, both paths in the client, `chat.db` opens unchanged; test opens a pre-change database fixture | +| R5 | An older client shows gibberish or fails silently | per-group switch + explicit refusal with a stated reason (§5.6); test drives an MNP 1.1 client at a switched-on group and asserts the refusal, not a crash | +| R6 | Two devices of one account cannot both be connected (F7) | peer registry keyed by device, broadcast excludes the sending *session* rather than the account; test connects two sessions for one `user_id` and asserts both receive | +| R7 | A person's own second device does not see their messages | same fix; the test above asserts the sending account's *other* session receives | +| R8 | QUIC path corrupts or leaks payloads | it is a relay; assert it stores and forwards bytes unchanged and never decodes them | +| R9 | Chat attachments stop working | the attachment reference moves inside the envelope; `attachRoot`/`attachDir` and the upload path are untouched; existing attachment tests must stay green | +| R10 | Link previews stop working | `firstUrl` runs on decrypted text client-side, before the request; unchanged. Test the panel end to end | +| R11 | `isOwn` breaks — `chat-app.js` compares `m.sender_name === username` today | own-ness comes from the account id, decided in one place; local echo stops inventing `sender_id: username` | +| R12 | Display names spoofable (already true) | `sender_name` inside the signed envelope; a message whose signature does not verify is rendered as unverified, never as someone | +| R13 | Hub notifications break | `chat_notify` is unchanged apart from dropping `sender_name`; test asserts a notification is still created | +| R14 | Unbounded receiver state (F3) | Not reachable: Design A holds no per-sender receiver state at all. The guard is that no such state is introduced | +| R15 | Epoch keys land in a plaintext SQLite beside `chat.db` | test reads the file and asserts the live epoch key's bytes do not appear in it | +| R16 | Chat retention (15.5) deletes rows an epoch still needs | retention deletes messages, never epoch keys; an epoch with no messages is harmless | + +--- + +## 8. The threat delta, in the words the user-facing docs should use + +Chat encryption protects a conversation against **someone who obtains the node's +storage without the keystore password** — a hosting provider imaging the machine, +a leaked backup, a seizure where the passphrase is not surrendered. Before it, +that person reads every message; after it, they read ciphertext. + +It does **not** protect chat from: + +- **the node operator, or any current member** — they hold the group key, and the + chat key is delivered under it. This is the same boundary as file access, by + design: the group key is the group secret. +- **anyone holding any one device of any member.** With several devices per + person, that surface is larger than it was. +- **a former member**, for the messages sent before the epoch changed. Rotation + stops them reading what comes next; it cannot unsend what they already had. +- **the hub**, as regards *metadata*: it learns which group has a conversation and + when, from the notification path. +- **the node**, as regards *links posted*, which it fetches to unfurl them. +- **an account that also signs in from a browser**, to the extent C4 is open: the + keypair bundle on the node yields the identity key, hence the group key, hence + the chat key. Chat encryption is worth measurably less to a browser-using + account than to a native one — the same asymmetry as everywhere else. + +And under Design A it does **not** provide forward secrecy or post-compromise +security. Under the design as specified in Phase 15 it would not have provided +them either (§3); the difference is that this says so. + +What sender *authentication* adds is separate and real: once a member's client has +pinned a device key for an account, no other member — and no operator who turns +malicious later — can forge a message from that account to that member. Forgery +is limited to accounts the reader has never seen. + +--- + +## 9. Documents that are stale and must be corrected + +- `meshbay-draft-v6.md` §5, the "Chat encryption (Sender Keys)" row, still says + "**Pairwise to identity keys, never GEK-derived**". That was reversed on + 2026-09-03 and CLAUDE.md records the reversal; v6 does not. It is wrong as it + stands, whichever design is chosen. +- `devel-phases-next.md` §15 milestones 15.1–15.3 say "**Node**: sender key init / + encrypt on send / decrypt on receive". The node is a relay and a store; messages + are composed and read in the browser and the desktop client. Encryption belongs + in `static/` and `senderkeys`-equivalent JS, with the node handling only key + delivery, storage and refusal. Built as written, the node would hold the + plaintext and the feature would protect nothing it claims to. +- `senderkeys.py`'s module docstring says "one chain per member" and "on join: + admin wraps each sender's SenderKeyDistribution with GEK". Both are wrong per + §15.0b and §3. +- `protocol.py:41` labels `CHAT_MESSAGE` "Double Ratchet message". It is not, has + never been, and the comment predates the C1 finding that rejected exactly that. + +Per the §4 decision, `senderkeys.py` joins `ratchet.py` as "kept for a possible +future 1:1 DM". The CLAUDE.md key-modules row must be amended to say so — today it +reads "Sender Keys (group chat) | `senderkeys.py` (Phase 7.5)", which will send +the next reader to the wrong module. The phase is renamed "Chat encryption", not +"Sender Keys". + +--- + +## 10. Milestones + +Design A is built. This was the build order, and the "What shipped" column is +what each stage turned out to be. **Stages 0–2 +carry no chat crypto at all**: they fix live defects, move the payload to bytes +and add message signing, each shippable and verifiable on its own with chat still +in plaintext. Stage 3 is the encryption itself and is the first stage that can +break a running group, which is why the switch (3.3) exists. Do not reorder 3.1 +before 0.1 — a per-device design on a registry that cannot hold two devices of one +account is untestable. + +| # | Stage | What shipped | Notes | +|---|---|---|---| +| 0.1 | **Per-connection peer registry** ✅ | `_registry_key` (a uuid per connection), `_register_peer` / `_unregister_peer` / `_sessions_of`; the chat broadcast excludes the sending *session*, on both transports | F7. `test_chat_multidevice.py` — three of its four tests fail with the old keying | +| 0.2 | **`device_hello`** ✅ | Additive MNP 1.2 message, signed over a transcript naming the node, the group and this connection's nonce. Refused unless the key is a live device *of this account in the node's own roster*; refused again if the connection later claims a different one; `_load_pinned_pk` no longer overwrites a confirmed device | Chosen over changing the handshake, which would have been a breaking protocol change for something additive. `test_device_on_connection.py` | +| 0.3 | **Delete by account** ✅ | `_verify_uploader_sig` tries every non-revoked device of `entry.uploader_id`. `uploader_pk` stops being the authorization key and becomes the audit record; it is still the fallback for an index written before `uploader_id` existed | `desktop-client-v1.md` §4.8 A — a regression device linking had already introduced | +| 1.1 | **Payload shape** ✅ | `store.py` gains `format` / `epoch` / `device` / `nonce` / `sig`, all with defaults, plus a unique `(device, nonce)`. Ciphertext travels in its own `ct` field | **The plan said "payload becomes bytes end to end", and that was wrong.** `payload` reaches older clients, and the UI ships inside the desktop package now, so they would have rendered bytes where they expect text. Plaintext keeps exactly the shape it has always had | +| 1.2 | **Message envelope** ✅ | `sender_name` moves inside the sealed payload; own-ness decided from `userId`, with an explicit `own` flag on the optimistic echo; `sender_name` dropped from the hub notification | R11, R12, and §5.8's metadata point | +| 2.1 | **Signatures** ✅ | The client signs over the *ciphertext* with its pinned device key; the node checks the `device` claim against this connection before storing; receivers verify before decrypting | Shipped with 3.2 rather than before it, because one envelope carries both | +| 2.2 | Tier 2 — group roster | **Not built** — the one deliberate omission. It needs `group_roster_req`/`resp` and, first, `device_add` to persist `(sig, nonce_node, ts)`: today the countersignature is verified and thrown away, and its transcript binds a per-connection nonce, so even a stored signature would be unverifiable by a third party | §6, §13 | +| 3.1 | **Epoch keys** ✅ | `ops.open_chat_epoch` / `ensure_chat_epoch` / `chat_epoch_keys`; wrapped to the node's own X25519 key in `bundles.db`; `chat_keys_req`/`resp` sealed under the new groupbox purpose `chat_keys` | §5.1, §5.4, R15 | +| 3.2 | **Encrypt / decrypt** ✅ | `chatbox.py`, mirrored by `sealChat` / `openChat` / `verifyChatSignature` in `crypto.js` and held byte-identical by six vectors in `test_js_python_parity.py` — including a `\|` inside a group id, which is the separator both the AAD and the HKDF info string use | §5.2 | +| 3.3 | **No switch** ✅ | **The plan's third mistake.** It proposed `chat_encrypted` per group, off by default. Refused by the operator: every node is a test node, so an opt-in flag buys nothing and leaves a plaintext branch reachable. MNP is **2.0**, `MNP_MIN_SUPPORTED` moves with it, a 1.x peer is refused at the handshake, and a test reads the source to assert nothing consults a `chat_encrypted` setting | §5.6, R5 | +| 3.4 | **Epoch rotation** ✅ | A new epoch on member revoke, member unpin, device revoke and `gek_rotate`. Old epochs kept and still delivered; pushed to everyone connected | §5.5, R1 | +| 3.5 | **History migration** ✅ | `QE/migration/migrate_chat_encryption.py` (node stopped — the upgrade path) and `meshbay-node chat encrypt-history` (node running). Both back `chat.db` up first and commit once; migrated messages are sealed under a synthetic device of the node's and carry `migrated: true`, because the node holds nobody's signing key and must not pretend to. Verified end to end against a seeded pre-2.0 data directory: 3 messages converted, decrypted back, plaintext absent from `chat.db` and present in the backup, second run a no-op | §5.7 | +| 4.1 | **Retention** ✅ | `meshbay-node chat prune ` / `ops.prune_chat`. Messages only — never an epoch key | R16 | +| 4.2 | **Docs** ✅ | v6 §5, `devel-phases-next.md` §15, CLAUDE.md's corrections and key-modules table, `senderkeys.py`'s docstring, and `protocol.py`'s "Double Ratchet message" comment | §9 | + +MNP goes to **1.2** at 3.1 — additive, so 1.1 peers keep working in groups where +the switch is off, and are refused with a stated reason where it is on. + +--- + +## 11. Tests + +The standing rule here is that a test which models the fix agrees with it by +construction, and that source-reading tests are weak evidence. So: + +- `test_senderkeys.py` — the module is now unused by production. Leave the suite + green and add a one-line note at the top of the file saying it covers a + protocol deferred to a possible 1:1 DM, so nobody reads its green tick as + evidence that group chat is encrypted. Do **not** delete it, and do not spend + effort fixing F1–F3 there: they are findings about a module nothing calls. +- `test_chat_encryption.py` (new, common) — round trip a message the node cannot + read; assert the node's stored bytes contain neither the plaintext nor the + sender's display name. +- `test_chat_history_binary.py` (new) — a payload that is not valid UTF-8 survives + `chat_hist`. Fails today against `webrtc_server.py:3920`. +- `test_chat_multidevice.py` (new) — two sessions, one `user_id`, both in the + registry, both receive a third party's message, and the second receives the + first's. Fails today against `webrtc_server.py:746`. +- `test_chat_rotation.py` (new) — send, `gek_rotate`, reconnect, read the oldest + page. This is R1 and it is the test most likely to be missing. +- `test_chat_key_storage.py` (new) — the live epoch key's bytes appear in no file + under `data_dir` that is not the keystore-protected store. R15. +- `test_chat_downgrade.py` (new) — with the switch on, a plaintext `chat_msg` is + refused, and an MNP 1.1 handshake gets a stated refusal rather than a silent + empty panel. R5. +- `test_js_python_parity.py` — extend for the new groupbox purpose and for + `sealChat`/`openChat`, the way the index seal is already held. +- `tests/harness/chat_send_probe.py` — already mounts the real `ChatPanel` over + the real transport. Extend it to drive an encrypted send. The freeze this + harness was written for lived in the seam between the panel and the transport, + and this change adds two more layers to that seam. +- `tests/harness/chat_scroll_probe.py` — must stay green: "load older" across an + epoch boundary is exactly where a decryption gap would surface as a rendering + bug. +- `QE/deploy/e2e.py` cannot cover the client here — it is a second + implementation, and this feature lives in `chat-app.js`. Plan for a person to + send a message from two devices, rotate the key, and scroll up. + +--- + +## 12. Open questions for the operator + +Question 1 was **answered on 2026-09-07: Design A** (§4). The four below are +policy rather than architecture: none of them blocks Stages 0–2, and each is +needed by the stage named beside it. + +1. **Do new members see old history?** (Stage 3.1.) Design A can deliver all + epochs — today's behaviour, and the recommended default — or only the current + one. A per-group setting either way, but the default is a policy choice. +2. **`chat encrypt-history`** (Stage 3.5) — build it, or leave pre-switch + messages plaintext for ever? The second is simpler and honest; the first is what someone will ask + for the day after they turn the switch on. +3. **Drop `sender_name` from the hub notification?** (Stage 1.2.) It costs one string in the + notification text and removes a per-message name from the hub's records. +4. **Attachments** (15.7, Stage 4.2): document the asymmetry now, or encrypt them later? The + plan above assumes documented. + + +--- + +## 13. What was deliberately not built + +**Tier 2 — clients pinning `account → device keys` (§6).** Everything else in +§10 shipped. This did not, and it is worth being exact about what that costs and +why it stopped here rather than being forgotten. + +What is in place: every message is signed by the sending device's key, the node +refuses a message whose `device` is not the one that proved itself on that +connection, and every receiver verifies the signature before it decrypts. So +**no member can forge another member**, which is the property `desktop-client-v1.md` +§4.8 Tier 1 asks for, and it is the finding (F1) that would have made encrypted +chat worse than plaintext chat. + +What is missing: a reader has no independent evidence that a given device key +belongs to the account the node says it does. Against a **node that turns +malicious later**, Tier 2 would make substitution detectable rather than merely +improbable. Today that boundary is where `per-node-identity-v1.md` leaves it — +the node runs admission, and a member trusts its roster. + +Why it stopped here, and it is not effort: **the evidence Tier 2 needs is not +being kept.** `_do_device_add` verifies the countersignature and throws it away, +storing only `added_by_pk` — *which* key approved, never the proof. Worse, +`device_add_transcript` binds `nonce_node`, a per-connection nonce, so even a +stored signature is unverifiable by anyone who was not on that connection. Tier 2 +therefore needs `device_add` to persist `(sig, nonce_node, ts)` **before** it is +useful, and devices pinned before that change carry no evidence at all and would +be trust-on-first-use only — which has to be visible in the UI rather than +papered over. + +That is a self-contained change with its own decisions, and folding it into this +one would have meant shipping a roster message whose contents are, for every +existing device, "no evidence". It is the next thing to build here. \ No newline at end of file diff --git a/docs/devel-phases-next.md b/docs/devel-phases-next.md index e842b62..cbee283 100644 --- a/docs/devel-phases-next.md +++ b/docs/devel-phases-next.md @@ -986,7 +986,18 @@ to know why. --- -## Phase 15 — Chat encryption (Sender Keys) + retention +## Phase 15 — Chat encryption + retention + +> **Superseded 2026-09-07 by `docs/chat-sender-keys.md`, which is the +> specification and the decision record. Built.** Read that document before this +> section: the milestone table below is kept for the history of the decision and +> is wrong in three places, each marked. In particular 15.1–15.3 put encryption +> *in the node* — the node is a relay and an archive, and messages are composed +> and read in the client, so built as written the feature would have protected +> nothing it claimed. +> +> The protocol is no longer Sender Keys. `senderkeys.py` is unused and kept for a +> possible future 1:1 DM, alongside `ratchet.py`. > Was Phase 13 before the 2026-08-13 renumbering. @@ -1102,15 +1113,17 @@ flags. Additions that belong in the user-facing docs: | # | Component | Description | |---|---|---| -| 15.0 | **Distribution decision** | ✅ **DECIDED 2026-09-03** — GEK-wrapped. The GEK is the group secret; files and chat share the same access boundary | -| 15.0b | **Per-device chains** | `sender_id` becomes a device identifier; fix `GroupSenderKeyStore`'s silent overwrite. **Blocking, and depends on device linking (Stage C) landing first** | -| 15.1 | Node: sender key init | Generate a sender key **per device** on group join, distribute GEK-wrapped to the group | -| 15.2 | Node: encrypt chat on send | Encrypt payload with that device's chain key before broadcast | -| 15.3 | Node: decrypt chat on receive | Decrypt incoming chat messages, handle out-of-order | -| 15.4 | Key rotation on removal | Member removed **or device revoked or unpinned** → all remaining devices rotate | -| 15.5 | Chat retention config | Per-group `max_age_days` setting, periodic cleanup in ChatStore | +| 15.0 | **Distribution decision** | ✅ DECIDED 2026-09-03 (GEK-wrapped), then **overtaken 2026-09-07**: the epoch key is delivered wrapped under the group key, but nothing is *stored* under it — which is what makes a group-key rotation a re-wrap instead of the destruction of the archive | +| 15.0b | **Per-device chains** | ✅ **Obtained without chains.** One key per device, derived by name from the epoch key, so there is no shared mutable sending state to reuse a nonce and nothing to persist per device | +| 15.1 | ~~Node: sender key init~~ | ❌ **Wrong as written** — the node is a relay and an archive. It generates and delivers the epoch key (`ops.open_chat_epoch`, `chat_keys_req`); the client seals | +| 15.2 | ~~Node: encrypt chat on send~~ | ❌ **Wrong as written.** Encryption is in `static/crypto.js`; the node stores what it cannot read | +| 15.3 | ~~Node: decrypt chat on receive~~ | ❌ **Wrong as written.** Only clients decrypt. Out-of-order does not arise: there is no chain to advance | +| 15.4 | Key rotation on removal | ✅ Member revoked, unpinned, device revoked, or `gek_rotate` → a new epoch, pushed to everyone connected. Old epochs kept, or the removal would take the history with it | +| 15.5 | Chat retention config | ✅ `meshbay-node chat prune ` / `ops.prune_chat`. Deletes messages, never epoch keys | | 15.6 | MNP version negotiation | ✅ **DONE 2026-09-03**, and not here: it shipped with **MNP 1.0** (the sealed index and ack), which forced a coordinated deployment anyway. `handshake` and `handshake_challenge` each carry `v` and `v_min`; `check_version` refuses with `version_too_old` / `version_too_new` / `version_unreadable`, shaped like `not_a_member`. The flag day was already being paid for, so the next breaking change costs a refusal message instead of a second one. See `MESHBAY_NODE_PROTOCOL.md` §13.1 | -| 15.7 | Chat attachments | Attachments are ordinary files on the node and remain plaintext at rest. Either encrypt them under the sender key, or document the asymmetry explicitly. Note they now land in the **operator-designated upload root** (§6.7 of the desktop-client doc) | +| 15.7 | Chat attachments | **Documented, not encrypted.** Attachments are ordinary files on a shared root and stay plaintext on disk; the *reference* to one is inside the sealed payload, but the file and its name are in the index. Encrypting them is a different feature with a different blast radius — `docs/chat-sender-keys.md` §5.8 states the asymmetry rather than hiding it | +| 15.8 | **The switch** | ✅ Per group, operator-signed (`OP_CHAT_ENCRYPTED`), reported inside the sealed handshake ack. Off by default — a node upgraded into a running group must refuse nobody. On, the node refuses plaintext outright | +| 15.9 | **`chat encrypt-history`** | ✅ Explicit CLI command, backs `chat.db` up first, one transaction. Deliberately not done by the switch: it rewrites the only copy of a conversation, and a toggle that does that is one somebody flips twice | --- 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: - -- **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. - -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. +**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. + +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. + +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. --- -- cgit v1.2.3