# 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. Two *keys* never share a subkey, and — the part that carries the weight — **there is no mutable sending state at all**, so nothing can be advanced twice. That is the hazard §15.0b wanted per-device chains for, removed rather than partitioned. **Corrected 2026-09-07, and the correction matters.** An earlier draft of this section said "two devices never share an AEAD key". That is false in the deployment that exists, and stating it would have hidden the reason the design is safe. Two clients of one account on one node normally hold the **same** identity key: a second browser fetches the keypair bundle from the node and recovers the existing key rather than minting a new one (`transport.js`, `keypair_bundle_fetch`), and so does a fresh Electron install. Device *linking* — a distinct key, countersigned — is the exception, not the rule, which is why an operator adding a second browser is never asked to pin anything. So two clients routinely share a device key and therefore this subkey. What makes that safe is the nonce, not the derivation: **96 random bits, never a counter.** Two independent senders under one key collide only on the birthday bound, which at chat volume is unreachable; two independent senders advancing one *counter* collide immediately, which is precisely what C1 and §15.0b are about. The design degrades correctly into the deployment as it is; a chain-based one would have failed in it, silently, on the day someone opened a second tab. Nonces are 96-bit random per message. The NIST SP 800-38D ceiling of 2^32 invocations under a random 96-bit nonce is a per-key budget now shared by however many clients an account runs at once — still unreachable by people 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** ✅ | `device_add` persists `(sig, nonce_node, ts)` beside the pin — it used to verify the countersignature and throw it away, which is what blocked this. `group_roster_req`/`resp`, sealed under a new groupbox purpose and answered to **any member**, relays each device with the evidence that admitted it. The client walks the chain itself (`_verifyRoster`) and keeps its own pins | §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. Tier 2, and what it does and does not buy **Built 2026-09-07**, after this document had recorded it as the one deliberate omission. What unblocked it was noticing why it was blocked: not effort, but that **the evidence was not being kept.** `_do_device_add` verified the countersignature and stored only `added_by_pk` — *which* key approved, never the proof — and `device_add_transcript` binds `nonce_node`, the approving connection's handshake nonce, so even a stored signature was unverifiable by anyone who had not been on that connection. Three columns fixed that. The property, stated exactly, because the temptation is to round it up: > Once a member's client has seen an account, **a node that later substitutes a > key for it is detected**. Nothing is gained at first sight, where the client > has nothing to compare against. That second sentence is not a caveat to be dropped. It is the same boundary `per-node-identity-v1.md` draws and this does not move it: an operator who is malicious *from the start*, for a member who has never seen the account, can still name whoever they like. **What the node decides: nothing.** It hands over evidence — for each live device of each active member of the group, the key, the key that countersigned it, the signature, the nonce and the timestamp — and the client walks the chain from each account's root outwards. A device the node lists but cannot evidence never enters the verified set, so a fabricated key is not laundered in by being mentioned. That is why the node is not asked to assert trust: it is the party the property holds *against*. Three decisions worth keeping: - **A root is a device that names no countersigner**, not one that fails to produce a signature. Treating "no proof" as "root" would have admitted anything a node chose to write, and the tests caught exactly that while this was being built. - **First sight pins everything the node says**, not the verified subset. Otherwise a legitimate second device whose countersignature predates this change raises "key changed" on every message — and an alarm that fires on normal events stops being read, which was §4.8's whole reason for budgeting exactly one notice. - **A device pinned before 2026-09-07 is unevidenced, and reads as such.** It was countersigned; the proof was not kept. Honest is better than convenient here, and the operator-facing consequence is small because every node is a test node whose devices are trivially re-paired. **The cost, stated because it is a real one:** the roster is member-visible, so every member of a group learns how many devices every other member holds and what their public keys are. It stays inside the group — the hub is not involved — and it is scoped to one group, so a person in two groups on one node is not disclosed to the second by being in the first. That is the price of the property and it is not avoidable: a member who cannot see the keys cannot check them. **Tier 3 stays deferred**, unchanged and with nothing depending on it: the operator signs a roster attestation, which would close first contact. It is worth doing only if a deployment appears where the operator is not the machine.