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 --- CLAUDE.md | 37 +- docs/chat-sender-keys.md | 807 +++++++++++++++++++++ docs/devel-phases-next.md | 31 +- docs/meshbay-draft-v6.md | 56 +- .../meshbay-common/src/meshbay_common/__init__.py | 17 +- .../meshbay-common/src/meshbay_common/adminop.py | 8 + .../meshbay-common/src/meshbay_common/chatbox.py | 181 +++++ .../meshbay-common/src/meshbay_common/device.py | 39 + .../meshbay-common/src/meshbay_common/groupbox.py | 5 + .../meshbay-common/src/meshbay_common/handshake.py | 18 +- .../meshbay-common/src/meshbay_common/protocol.py | 29 +- .../src/meshbay_common/senderkeys.py | 55 +- .../meshbay-common/tests/test_js_python_parity.py | 209 ++++++ .../src/meshbay_hub/static/chat-app-settings.js | 16 +- .../meshbay-hub/src/meshbay_hub/static/chat-app.js | 67 +- .../meshbay-hub/src/meshbay_hub/static/crypto.js | 117 ++- .../src/meshbay_hub/static/locales/de.js | 12 + .../src/meshbay_hub/static/locales/en.js | 17 + .../src/meshbay_hub/static/locales/es.js | 12 + .../src/meshbay_hub/static/locales/fr.js | 12 + .../src/meshbay_hub/static/locales/it.js | 12 + .../src/meshbay_hub/static/locales/ja.js | 12 + .../src/meshbay_hub/static/locales/nl.js | 12 + .../src/meshbay_hub/static/locales/pl.js | 12 + .../src/meshbay_hub/static/locales/pt-BR.js | 12 + .../src/meshbay_hub/static/locales/zh-CN.js | 12 + .../meshbay-hub/src/meshbay_hub/static/style.css | 13 + .../src/meshbay_hub/static/transport.js | 344 ++++++++- .../meshbay-hub/tests/harness/chat_send_probe.py | 95 ++- packages/meshbay-hub/tests/test_chat_send.py | 57 +- .../meshbay-node/src/meshbay_node/bundle_store.py | 73 ++ .../meshbay-node/src/meshbay_node/chat/__init__.py | 20 +- .../meshbay-node/src/meshbay_node/chat/store.py | 186 ++++- packages/meshbay-node/src/meshbay_node/daemon.py | 103 ++- packages/meshbay-node/src/meshbay_node/ops.py | 249 ++++++- .../src/meshbay_node/transport/quic_server.py | 14 +- .../src/meshbay_node/transport/webrtc_server.py | 572 +++++++++++++-- packages/meshbay-node/src/meshbay_node/ui/app.py | 18 + .../meshbay-node/tests/test_chat_encryption.py | 517 +++++++++++++ .../meshbay-node/tests/test_chat_history_binary.py | 181 +++++ .../meshbay-node/tests/test_chat_multidevice.py | 160 ++++ packages/meshbay-node/tests/test_cli_dispatch.py | 11 + .../tests/test_device_on_connection.py | 287 ++++++++ .../meshbay-node/tests/test_webrtc_transport.py | 64 +- 44 files changed, 4574 insertions(+), 207 deletions(-) create mode 100644 docs/chat-sender-keys.md create mode 100644 packages/meshbay-common/src/meshbay_common/chatbox.py create mode 100644 packages/meshbay-node/tests/test_chat_encryption.py create mode 100644 packages/meshbay-node/tests/test_chat_history_binary.py create mode 100644 packages/meshbay-node/tests/test_chat_multidevice.py create mode 100644 packages/meshbay-node/tests/test_device_on_connection.py diff --git a/CLAUDE.md b/CLAUDE.md index 4a4175f..808ab47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -681,17 +681,28 @@ anything that assumes one key per person. clients too (via `aiortc` in Python) - Argon2id 256 MB was applied to the **hub only**; `crypto.py` keystore is still 64 MB - ~~Sender keys must be distributed pairwise to identity keys, never GEK-derived.~~ - **Reversed 2026-09-03:** sender keys are distributed **GEK-wrapped**. The GEK is the - group secret; files and chat share the same access boundary. Pairwise distribution - added complexity for a separation (files vs chat) that has no meaning in this - platform's group model. Sender keys remain **per device, never per person** - (2026-08-17). Two devices sharing one sending chain both advance it, producing - key/nonce reuse: that is C1 again, one level down. - `GroupSenderKeyStore.add_sender` currently does `self._states[dist.sender_id] = ...`, - so a second device under the same `sender_id` silently overwrites the first. Revoking a - device must rotate, like revoking a member. See `docs/devel-phases-next.md` §15.0b + ~~Reversed 2026-09-03: sender keys are distributed GEK-wrapped.~~ + **Sender keys are not what group chat uses at all (decided 2026-09-07, built).** + Read `docs/chat-sender-keys.md` before touching chat. The reasoning that ended the + question: once distribution is under the group key *and* the node serves history to + devices that were not present, the node must retain each chain's **earliest** key, and + a chain key at iteration *i* yields every message key from *i* on by pure HKDF. Forward + secrecy is therefore zero either way, and what the ratchet was left buying was a large + amount of stateful client code with silent failure modes — three of them reproduced: + any member could sign as any other (`add_sender` accepts any distribution and the + signing key is bound to nothing), a second device dropped the first's chain, and + `_skipped_keys` grew without bound. `senderkeys.py` joins `ratchet.py` as "kept for a + possible future 1:1 DM"; **nothing in production imports it**, and its green tests are + not evidence that chat is encrypted - ~~Chat is plaintext on the wire and at rest; the index is plaintext on the WebRTC - path.~~ **The index half changed 2026-09-03 (MNP 1.0).** `index_sync`, + path.~~ **Both halves have changed.** Chat: 2026-09-07, **MNP 2.0** — see the row above and + `docs/chat-sender-keys.md`. **There is no switch**: chat is encrypted, the node refuses + any message that is not sealed, and a 1.x peer is refused *at the handshake* with + `version_too_old` rather than admitted and then unable to speak. An opt-in flag was + proposed and refused — every node is a test node, so it would have bought nothing and + left a plaintext branch reachable, which is C6's lesson one feature later. Existing node + data is migrated by `QE/migration/migrate_chat_encryption.py`, node stopped. + **The index half changed 2026-09-03 (MNP 1.0).** `index_sync`, `index_delta` and the `handshake_ack` configuration payload are sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by `sealGroup`/ `openGroup` in `crypto.js`); only `type`, `v`, `group_id` and the ack's own @@ -764,7 +775,9 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: | Key bundle (web) | `meshbay_common.keyderive` | `keyderive.py` + `static/keyderive.js` | | GEK wrap/unwrap (ECIES) | `meshbay_common.crypto` | `crypto.py` | | Double Ratchet (1:1 DM, future) | `meshbay_common.ratchet` | `ratchet.py` | -| Sender Keys (group chat) | `meshbay_common.senderkeys` | `senderkeys.py` (Phase 7.5) | +| Chat encryption (group chat) | `meshbay_common.chatbox` | `chatbox.py` + `sealChat`/`openChat`/`verifyChatSignature` in `static/crypto.js`. One key per group, per epoch, per **device**, derived by name from an epoch key the node generates and delivers wrapped under the GEK — so rotating the GEK is a re-wrap and does not destroy the archive, and two devices can never share an AES key. Messages are signed over the **ciphertext** with the device's pinned Ed25519 key | +| Chat epochs (node) | `meshbay_node.ops` | `open_chat_epoch` / `ensure_chat_epoch` / `chat_epoch_keys`. Epoch 1 is opened at group load (`daemon._ensure_chat_epoch`) — a group with no epoch is a group nobody can speak in. A new epoch on every removal (member, device, unpin, `gek_rotate`); **old epochs are kept and still delivered**, which is what keeps history readable, and nothing anywhere deletes one. Keys are wrapped to the node's own X25519 key in `bundles.db`, never stored raw | +| ~~Sender Keys (group chat)~~ | `meshbay_common.senderkeys` | **Unused.** Kept for a possible future 1:1 DM, like `ratchet.py` — see the corrections above | | AES-GCM (browser) | `meshbay_common.webcrypto` | `webcrypto.py` + `static/crypto.js` | | Node keystore | `meshbay_node.keystore` | `keystore.py` | | QUIC NAT punch (native) | `meshbay_node.transport.quic_server` | `QuicChunkServer.punch_nat()` | @@ -782,6 +795,8 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: | Public group cap (hub) | `meshbay_hub.api.groups` | `_check_public_group_quota` — 10 live public groups per owner, staff exempt. **Checked at creation only, because PATCH refuses to change visibility** | | Uploads on/off (node) | `meshbay_node.roster` + `transport.webrtc_server` | `member_upload_allowed` / `set_member_upload`, gate in `_do_file_upload`. Per group, **operator-signed** (`OP_MEMBER_UPLOAD`), stored in `roster.db`, cached in the group context because the upload path is synchronous. **Absent means allowed** at every layer | | Node presence (hub) | `meshbay_hub.api.groups` | `node_online` on `/v1/groups/mine`, read from the signaling registry — no poll, no timer | +| Chat message handling (node) | `meshbay_node.transport.webrtc_server` | `_do_chat_message` + `_check_chat_envelope`. `sender_id` from the session (NS6); the *device* claim is checked against the connection's own `device_hello`, or a member could sign as anyone. Replay refused by a unique `(device, nonce)` in `chat.db` — a replay is a validly signed copy, so nothing about the signature refuses it | +| Which device is on a connection | `meshbay_node.transport.webrtc_server` | `_do_device_hello` (MNP 1.2, additive). The handshake proves the *account*; this proves the *device*. Before it, `_load_pinned_pk` used the account's oldest key and recorded it as the uploader of every file | | Chat paging (node) | `meshbay_node.chat.store` | `get_recent` / `get_before` / `has_before`. `get_messages` pages *forwards* and is not what a chat opens with | | Liveness (MNP) | `meshbay_common.protocol` | `PING`/`PONG` on an **already-open** channel; never for discovery — a handshake costs 0.6-7 s | | Profile page (browser) | `static/app.js` | `ProfilePage` — identity, node link, pins, account deletion. Settings keeps behaviour | 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. --- diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index b3e24e3..ca4c6eb 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -90,8 +90,19 @@ __version__ = "0.11.0" # makes the *next* breaking change cost a refusal message instead of a second # flag day. `MNP_MIN_SUPPORTED` in `handshake.py` is the other half. # +# **2.0 (2026-09-07): chat is encrypted, and there is no way to turn it off.** +# A MAJOR bump because it is a real break: a 1.x peer cannot produce a sealed +# chat message and cannot read one, so it is refused at the handshake with +# `version_too_old` rather than connecting and then failing to speak. Expressing +# the break in the version is what makes it a stated refusal instead of a +# conversation that silently does not work — `MNP_MIN_SUPPORTED` moves with it. +# +# There is deliberately no per-group switch. Every node in existence is a test +# node, so an opt-in flag would buy nothing and cost a compatibility path to +# maintain; existing node data is migrated by `QE/migrate-chat-encryption.py`. +# # The index at rest, `index_progress` (counters only, never a path — see -# `groupbox.py` and daemon.py `_push_index_progress`), chat, and file content -# on the operator's disk are all deliberately unchanged. -MNP_VERSION = "1.1" +# `groupbox.py` and daemon.py `_push_index_progress`), and file content on the +# operator's disk are all deliberately unchanged. +MNP_VERSION = "2.0" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index 9a56934..ed6e940 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -112,6 +112,14 @@ OP_ROOT_REMOVE = "root_remove" OP_APP_DIRECTORIES = "app_directories" OP_CHAT_DIRECTORY = "chat_directory" OP_CHAT_LINK_PREVIEW = "chat_link_preview" +# Open a new chat epoch for a group, by hand. The removals that matter open one +# by themselves (member revoke/unpin, device revoke, gek_rotate); this is the +# operator saying "do it anyway", which is the same shape as `gek_rotate` and +# signed for the same reason. +# +# There is no op for *enabling* chat encryption. It is not a setting — MNP 2.0 +# has no plaintext chat to fall back to. +OP_CHAT_EPOCH = "chat_epoch" OP_ROOT_UPDATE = "root_update" OP_ROOT_EJECT = "root_eject" OP_ROOT_PLUG = "root_plug" diff --git a/packages/meshbay-common/src/meshbay_common/chatbox.py b/packages/meshbay-common/src/meshbay_common/chatbox.py new file mode 100644 index 0000000..e04bd9b --- /dev/null +++ b/packages/meshbay-common/src/meshbay_common/chatbox.py @@ -0,0 +1,181 @@ +""" +Chat message encryption and sender authentication. + +Design A of `docs/chat-sender-keys.md`, decided 2026-09-07. What it is, and what +it deliberately is not, in the order the decisions were made: + +**Not a ratchet.** `senderkeys.py` implements Signal-style sender keys and is +unused by production. With sender keys distributed under the group key and a +node that serves history to devices which were not present, the node must retain +and hand out each chain's *earliest* key — and a chain key at iteration *i* +yields every message key from *i* onward by pure HKDF. Forward secrecy is then +zero, and the ratchet is computing HKDF over a value every member already holds. +The property is given up on the record rather than inherited by accident. + +**A key per group, per epoch, per device.** The node generates an epoch key and +delivers it to members wrapped under the current group key. Each device derives +its *own* subkey from it, by name, so: + +* two devices never share an AES key, and nonce reuse across devices is + impossible without any coordination — the property per-device ratchet chains + were wanted for, obtained by derivation instead of by mutable state (which is + C1 one level down, and is exactly what `GroupSenderKeyStore` got wrong); +* a receiver derives any sender's subkey from the epoch key it already has, so + nothing is distributed per device and there is no per-device state to + persist, migrate or lose; +* history keeps working, for new members and new devices alike, because the + epoch key does not move as messages are sent. + +**Epochs, not rotation of the archive.** The epoch key is wrapped under the +group key *at delivery*, never stored under it — so rotating the group key +(which is the documented step after removing a member) is a re-wrap and costs +nothing. A new epoch is opened when the set of devices that may read *future* +messages shrinks; old epochs are kept and still delivered to current members, +which is what keeps the history readable to the people who could already read it. + +**Signing is separate from encryption**, and is what actually establishes who +said something. The signature is over the *ciphertext*, so it can be checked +before decryption and by anyone holding the roster, and it names the device key +the node pinned — not a fresh key the sender invented, which is what made the +sender-key distribution format forgeable by any member. + +What none of this protects against, stated per the v5/v6 convention: the node +operator and every current group member hold the group key and therefore the +epoch keys. This is the same boundary as file access, by design. It protects +against someone who obtains the node's storage without the keystore password. +""" + +from __future__ import annotations + +import os + +import msgpack +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +EPOCH_KEY_LEN = 32 +# 96-bit, the WebCrypto AES-GCM standard — and the same reasoning as +# `groupbox.py`: one key per device per epoch puts the NIST SP 800-38D ceiling +# of 2**32 random nonces far out of reach of a person typing. +NONCE_LEN = 12 +SIG_LEN = 64 + +_DEVICE_KEY_INFO = "meshbay:chat:dev:v1" +_SIG_PREFIX = b"meshbay:chat:v1" + + +def new_epoch_key() -> bytes: + """A fresh epoch key. Generated by the node — never by a member (C5b).""" + return os.urandom(EPOCH_KEY_LEN) + + +def device_key(epoch_key: bytes, group_id: str, device_b64: str) -> bytes: + """ + The AES-256 subkey one device encrypts under, in one group, in one epoch. + + Derived by name, so every member can compute every device's subkey from the + epoch key and nobody has to distribute anything per device. `salt=None` here + matches `salt: new Uint8Array(0)` in crypto.js — RFC 5869 extracts with a + zero key either way, which `deriveChunkKey` already relies on in production + and `test_js_python_parity` holds. + """ + if not epoch_key: + raise ValueError("no epoch key") + if not device_b64: + raise ValueError("no device") + info = f"{_DEVICE_KEY_INFO}|{group_id}|{device_b64}".encode() + return HKDF( + algorithm=hashes.SHA256(), length=32, salt=None, info=info, + ).derive(epoch_key) + + +def associated_data(group_id: str, epoch: int) -> bytes: + """ + What a chat ciphertext is bound to. + + The group stops a message being moved between two groups on one node; the + epoch stops one being re-presented as belonging to a later key, which would + otherwise let a member who kept an old epoch key make an old message look + current. Same reasoning as `groupbox.associated_data`, one field longer. + """ + return f"chat_msg|{group_id}|{epoch}".encode() + + +def signing_transcript(group_id: str, epoch: int, device: bytes, nonce: bytes, + ct: bytes) -> bytes: + """ + What the sending device signs — the ciphertext, never the plaintext. + + Over the ciphertext so a receiver can establish authorship *before* it + decrypts, and so anyone holding the roster can verify a stored message + without the epoch key. Length-prefixed and domain-separated, per L4, like + every other transcript in this codebase: without the lengths, a message + could be re-cut into a different one with the same bytes. + + Note what is *not* in here: this connection's nonce. Every other transcript + binds one, and this one cannot — a receiver reading history has no access to + the connection the message arrived on. Replay is therefore refused by + storage instead, on the unique `(device, nonce)` pair. + """ + out = bytearray(_SIG_PREFIX) + for field in (group_id.encode(), str(epoch).encode(), device, nonce, ct): + out += len(field).to_bytes(4, "big") + out += field + return bytes(out) + + +def seal(epoch_key: bytes, group_id: str, epoch: int, device_b64: str, + device_raw: bytes, sk: Ed25519PrivateKey, payload: dict) -> dict: + """ + One message, sealed and signed: `{nonce, ct, sig}` for the caller to merge. + + The routing and authentication fields — `format`, `epoch`, `device` — stay + in clear, because a receiver has to select a key and check a signature + before it can decrypt, and because the node routes on them without being + able to read anything. + """ + key = device_key(epoch_key, group_id, device_b64) + # Random per message. Never derived from the payload: two identical messages + # under one device's key would then reuse it, and AES-GCM's failure under + # nonce reuse is not graceful. + nonce = os.urandom(NONCE_LEN) + ct = AESGCM(key).encrypt( + nonce, msgpack.packb(payload, use_bin_type=True), + associated_data(group_id, epoch)) + sig = sk.sign(signing_transcript(group_id, epoch, device_raw, nonce, ct)) + return {"nonce": nonce, "ct": ct, "sig": sig} + + +def verify(group_id: str, epoch: int, device_raw: bytes, nonce: bytes, + ct: bytes, sig: bytes) -> bool: + """Whether `sig` is this device's signature over this ciphertext.""" + try: + pk = Ed25519PublicKey.from_public_bytes(device_raw) + pk.verify(sig, signing_transcript(group_id, epoch, device_raw, nonce, ct)) + return True + except Exception: + return False + + +def open_message(epoch_key: bytes, group_id: str, epoch: int, device_b64: str, + nonce: bytes, ct: bytes) -> dict: + """ + Open a sealed message. Raises on anything that does not open. + + Never a partial result and never a default — an unopenable message is not an + empty one, and rendering it as blank would make a message nobody can read + indistinguishable from a message nobody wrote. The caller marks it as + unreadable and shows the gap, which is the honest thing for a reader to see. + """ + key = device_key(epoch_key, group_id, device_b64) + plain = AESGCM(key).decrypt(bytes(nonce), bytes(ct), + associated_data(group_id, epoch)) + payload = msgpack.unpackb(plain, raw=False) + if not isinstance(payload, dict): + raise ValueError("chat: sealed payload is not a map") + return payload diff --git a/packages/meshbay-common/src/meshbay_common/device.py b/packages/meshbay-common/src/meshbay_common/device.py index 8951dbb..cfa8dd6 100644 --- a/packages/meshbay-common/src/meshbay_common/device.py +++ b/packages/meshbay-common/src/meshbay_common/device.py @@ -36,6 +36,7 @@ import hashlib DEVICE_REQUEST_PREFIX = b"meshbay:device_req:v1" DEVICE_ADD_PREFIX = b"meshbay:device_add:v1" +DEVICE_HELLO_PREFIX = b"meshbay:device_hello:v1" # Same as the join and admin transcripts: interactive exchanges that complete in # milliseconds, so anything older is a replay. @@ -123,3 +124,41 @@ def device_add_transcript( nonce_node, str(ts).encode(), ]) + + +def device_hello_transcript( + node_pk_b64: str, + group_id: str, + user_id: str, + pk_ed25519_b64: str, + nonce_node: bytes, + ts: int, +) -> bytes: + """ + Signed by the device on an already-authenticated connection, saying **which + of the account's devices this connection is**. + + The handshake proves membership of a group (a GEK-HMAC) and carries an + account from the hub's token; it proves nothing about *which* device is + talking. The node needed that the moment one account could hold several: + `_load_pinned_pk` was resolving "this account's oldest live device" and + recording it as the uploader of every file, so a phone's uploads were + attributed to a laptop. + + Additive and optional. A client that does not send it leaves the node where + it was, which is why this could ship without a breaking protocol change — + but a node that *has* been told refuses a later claim to be a different + device on the same connection. + + `nonce_node` is this connection's handshake nonce, so the signature cannot + be lifted onto another connection, and `node_pk` binds it to one node — + the same rule as every other transcript here. + """ + return _pack(DEVICE_HELLO_PREFIX, [ + node_pk_b64.encode(), + group_id.encode(), + user_id.encode(), + pk_ed25519_b64.encode(), + nonce_node, + str(ts).encode(), + ]) diff --git a/packages/meshbay-common/src/meshbay_common/groupbox.py b/packages/meshbay-common/src/meshbay_common/groupbox.py index f1091c3..ff60bba 100644 --- a/packages/meshbay-common/src/meshbay_common/groupbox.py +++ b/packages/meshbay-common/src/meshbay_common/groupbox.py @@ -41,6 +41,10 @@ from cryptography.hazmat.primitives.kdf.hkdf import HKDF PURPOSE_INDEX = "index" PURPOSE_ACK = "ack" +# The chat epoch keys themselves, on their way to a member. The keys are what +# the chat archive is encrypted under; this is only how they travel, which is +# why rotating the group key costs a re-wrap and not a re-encryption. +PURPOSE_CHAT_KEYS = "chat_keys" # `salt=None` here and `salt: new Uint8Array(0)` in crypto.js agree — RFC 5869 # extracts with a zero key either way. Already proven in production by @@ -48,6 +52,7 @@ PURPOSE_ACK = "ack" _INFO = { PURPOSE_INDEX: b"meshbay:index:v1", PURPOSE_ACK: b"meshbay:ack:v1", + PURPOSE_CHAT_KEYS: b"meshbay:chat_keys:v1", } NONCE_LEN = 12 # 96-bit, the WebCrypto AES-GCM standard diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index f7d4911..188a8aa 100644 --- a/packages/meshbay-common/src/meshbay_common/handshake.py +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -66,11 +66,19 @@ from meshbay_common import MNP_VERSION HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1" -# The oldest peer this build will talk to. MNP 1.0 sealed `index_sync`, -# `index_delta` and the `handshake_ack` payload under the group key, which no -# 0.x peer can open and which a 0.x peer's own messages do not carry — there is -# nothing to be compatible with, which is what makes it a MAJOR bump. -MNP_MIN_SUPPORTED = "1.0" +# The oldest peer this build will talk to. +# +# 2.0 (2026-09-07): chat messages are sealed under a per-device subkey of the +# group's chat epoch key, and the node refuses a plaintext one. A 1.x peer can +# neither produce nor read that, so there is nothing to be compatible with — +# the same reasoning that made 1.0 a MAJOR bump for the sealed index. +# +# Moving the floor with the version is the point: a 1.x client is refused here, +# with `version_too_old` and a sentence saying so, instead of completing a +# handshake and then discovering that every message it sends is rejected and +# every message it receives is unreadable. A stated refusal is a bug report; a +# chat that quietly does not work is a support case. +MNP_MIN_SUPPORTED = "2.0" ROLE_CLIENT = "client" ROLE_NODE = "node" diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 689adbf..dfb5d56 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -38,7 +38,12 @@ class MNP: FILE_REQUEST = "file_req" # request chunk(s) FILE_CHUNK = "file_chunk" # encrypted chunk response STREAM_SEGMENT = "stream_seg" # HLS/DASH segment - CHAT_MESSAGE = "chat_msg" # Double Ratchet message + # Not a Double Ratchet message, and never was — `first-review.md` C1 + # rejected exactly that for groups. Plaintext until a group turns + # encryption on, then AES-256-GCM under a per-device subkey of the group's + # chat epoch key, signed with the sending device's pinned Ed25519 key + # (`chatbox.py`, docs/chat-sender-keys.md). + CHAT_MESSAGE = "chat_msg" # one chat message, plain or sealed CHAT_ATTACHMENT = "chat_attach" # attachment metadata CHAT_HISTORY = "chat_hist" # request message history (newest, or before a cursor) CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages @@ -153,6 +158,14 @@ class MNP: DEVICE_LIST = "device_list" # anyone → node: my devices DEVICE_LIST_RESULT = "device_list_result" DEVICE_REVOKE = "device_revoke" # a device retires another + # "Which of this account's devices am I?" — signed, on an + # already-authenticated connection. The handshake proves the account and the + # group; it never proved the device, so the node attributed uploads to the + # account's oldest key and could not tell one device's chat from another's. + # Additive (MNP 1.2): a client that stays silent leaves the node exactly + # where it was. + DEVICE_HELLO = "device_hello" # device → node: this is me + DEVICE_HELLO_ACK = "device_hello_ack" # Rotation is the half of revocation that revocation cannot do: the node # generates a fresh key itself, so no key material crosses the wire. GEK_ROTATE = "gek_rotate" # operator → node: new group key @@ -176,6 +189,20 @@ class MNP: CHAT_DIRECTORY_ACK = "chat_directory_ack" CHAT_LINK_PREVIEW = "chat_link_preview" CHAT_LINK_PREVIEW_ACK = "chat_link_preview_ack" + # The keys a group's chat archive is encrypted under, on their way to a + # member. Sealed under a group-derived subkey, so the payload carries an + # authentication tag from a key the hub does not hold — and a member who has + # not completed the handshake is served a ciphertext rather than the keys. + # Requested rather than pushed on the ack: a group with no chat should not + # pay for this on every connection. + CHAT_KEYS_REQ = "chat_keys_req" + CHAT_KEYS_RESP = "chat_keys_resp" + # Node → this group: a new chat epoch was opened, because somebody was + # removed. Not a setting — there is no switch; chat is always encrypted + # (MNP 2.0). Pushed so a connected client stops sealing under the retired + # key without having to reconnect. + CHAT_EPOCH = "chat_epoch" + CHAT_EPOCH_ACK = "chat_epoch_ack" ROOT_UPDATE = "root_update" # operator → node: change writable/removable on a root ROOT_UPDATE_ACK = "root_update_ack" ROOT_EJECT = "root_eject" # operator → node: mark removable root as ejected diff --git a/packages/meshbay-common/src/meshbay_common/senderkeys.py b/packages/meshbay-common/src/meshbay_common/senderkeys.py index 932e2e6..9ad5107 100644 --- a/packages/meshbay-common/src/meshbay_common/senderkeys.py +++ b/packages/meshbay-common/src/meshbay_common/senderkeys.py @@ -1,21 +1,40 @@ """ -MeshBay — Sender Keys protocol for group messaging. - -Signal Groups approach: each member maintains their own sending chain. -Advantages over shared Double Ratchet: - - O(N) state per group (one chain per member) vs O(N^2) pairwise - - Single encrypt per message (not N encryptions) - - No key/nonce reuse — each sender has an independent chain - -Key components: - - Chain key ratchet: HKDF per message, provides forward secrecy +MeshBay — Sender Keys protocol. **Not used by group chat. Not used at all.** + +Kept the way `ratchet.py` is kept: a working implementation of a protocol that +may earn a place in a future 1:1 DM, where there is no server-side history to +contradict it. Group chat is `chatbox.py`, and the decision to build that +instead is `docs/chat-sender-keys.md` §4 (operator, 2026-09-07). Do not read a +green test run here as evidence that group chat is encrypted; nothing in +production imports this module. + +**Why it is not what group chat uses.** With sender keys distributed under the +group key, and a node that serves history to devices which were not present when +a message was sent, the node must retain and hand out each chain's *earliest* +key — and a chain key at iteration *i* yields every message key from *i* onward +by pure HKDF. Forward secrecy is then zero, and what is left is a large amount +of stateful client code whose failure modes are silent. Three of them are real +and reproduced in the design document: + + * `GroupSenderKeyStore.add_sender` accepts any distribution for any + `sender_id` and overwrites what is there, and `SenderKeyRecord.create` + invents a signing key bound to nothing — so under group-key distribution any + member can replace another member's chain and sign as them (F1); + * a second device registering under one `sender_id` drops the first device's + chain, and its messages then fail signature verification rather than failing + visibly at registration (F2); + * `SenderKeyState.advance_to` caches every skipped message key and nothing + trims `_skipped_keys` (F3). + +They are findings about a module nothing calls, and are deliberately not fixed +here. Anyone bringing this back for 1:1 DM must fix all three first — and must +bind the distribution to a key the node pinned, which is what F1 is really about. + +Key components, as implemented: + - Chain key ratchet: HKDF per message - Message key derivation: separate HKDF from chain key - Ed25519 signing: each sender signs their ciphertext - AES-256-GCM encryption: browser-compatible symmetric cipher - -Key distribution: - - On join: admin wraps each sender's SenderKeyDistribution with GEK - - On leave: all remaining members rotate their chain keys """ import os @@ -169,7 +188,13 @@ class SenderKeyRecord: # ── Group store ────────────────────────────────────────────────────────────── class GroupSenderKeyStore: - """All sender key states for one group, held by one member.""" + """All sender key states for one group, held by one member. + + One chain per **device**, were this ever used: a shared per-person chain + advanced by two devices produces key and nonce reuse, which is `first- + review.md` C1 one level down. `add_sender` does not enforce that — see the + module docstring, F2. + """ def __init__(self, group_id: str): self.group_id = group_id diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py index 6f7437f..dffee36 100644 --- a/packages/meshbay-common/tests/test_js_python_parity.py +++ b/packages/meshbay-common/tests/test_js_python_parity.py @@ -13,6 +13,7 @@ tests drive the real `crypto.js` under node and compare against the real Python. Skipped when node is unavailable; that is a coverage gap, not a pass. """ +import base64 import json import shutil import subprocess @@ -391,3 +392,211 @@ def test_the_browser_refuses_a_payload_sealed_for_another_message(groupbox_js): sealed["nonce"].hex(), sealed["ct"].hex()], capture_output=True, text=True, timeout=60) assert proc.stdout.strip() == "REFUSED", proc.stdout + proc.stderr + + +# ── chatbox: a chat message, sealed and signed, both directions ────────────── +# +# Same class of invisible disagreement as groupbox above, with one more moving +# part: the per-device subkey is derived from a *string* that carries the group +# id and the device's base64 key, so a mismatch in how either is encoded means +# messages that encrypt fine and never decrypt — and AES-GCM reports that +# exactly the way it reports a wrong key. +# +# The signature is checked in both directions too. It is the half that +# establishes who spoke, and unlike the ciphertext it is verified by clients +# that may never hold the epoch key at all. + +# (group_id, epoch) +CHAT_VECTORS = [ + ("g" * 32, 1), + # Epoch is inside the AAD and the transcript as a decimal string; 0 and a + # large value must not collide with each other or with the empty field. + ("g" * 32, 0), + ("g" * 32, 4294967296), + # Empty group id, the operator-pairing shape. + ("", 1), + # Non-ASCII: TextEncoder and Python's .encode() must agree. + ("groupe-café-日本", 2), + # A '|' inside the group id — the separator used by both the AAD and the + # HKDF info string. + ("a|b", 3), +] + +CHAT_EPOCH_KEY = bytes.fromhex("7c" * 32) + +_CHATBOX_HARNESS = r""" +const fs = require('fs'); + +globalThis.window = {}; +const src = fs.readFileSync(process.argv[2], 'utf8'); +const M = new Function(src + + '\nreturn { sealChat, openChat, chatSigningTranscript, verifyChatSignature };')(); + +const hex = (s) => { + const out = new Uint8Array(s.length / 2); + for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.substr(i, 2), 16); + return out; +}; +const toHex = (u8) => + Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join(''); + +(async () => { + const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); + const epochKey = hex(input.epoch_key); + const out = { opened: [], sealed: [], transcripts: [], verified: [] }; + + for (const v of input.vectors) { + // Python sealed it; open it here. + out.opened.push(toHex(await M.openChat( + epochKey, v.group_id, v.epoch, v.device_b64, hex(v.nonce), hex(v.ct)))); + // Seal the same plaintext here, for Python to open. + const sealed = await M.sealChat( + epochKey, v.group_id, v.epoch, v.device_b64, hex(v.plaintext)); + out.sealed.push({ nonce: toHex(sealed.nonce), ct: toHex(sealed.ct) }); + // The signed bytes, and whether Python's signature verifies here. + out.transcripts.push(toHex(M.chatSigningTranscript( + v.group_id, v.epoch, hex(v.device_raw), hex(v.nonce), hex(v.ct)))); + out.verified.push(await M.verifyChatSignature( + hex(v.device_raw), v.group_id, v.epoch, hex(v.nonce), hex(v.ct), + hex(v.sig))); + } + + process.stdout.write(JSON.stringify(out)); +})().catch((e) => { console.error(e); process.exit(1); }); +""" + + +def _chat_payload(idx: int) -> dict: + """A distinct message per vector, so a crossed result cannot pass.""" + return {"text": f"message {idx} — café", "thread_id": None, + "sender_name": f"member-{idx}", "sent_at": 1_700_000_000 + idx} + + +@pytest.fixture(scope="module") +def chatbox_js(tmp_path_factory): + import msgpack + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + ) + + from meshbay_common.chatbox import seal + + d = tmp_path_factory.mktemp("chatbox-parity") + harness = d / "harness.js" + harness.write_text(_CHATBOX_HARNESS) + + vectors = [] + for i, (group_id, epoch) in enumerate(CHAT_VECTORS): + # A distinct device per vector: the subkey is derived from the device's + # own base64 key, so reusing one would hide a derivation that ignored it. + sk = Ed25519PrivateKey.from_private_bytes(bytes([i + 1]) * 32) + device_raw = sk.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + device_b64 = base64.b64encode(device_raw).decode() + payload = _chat_payload(i) + sealed = seal(CHAT_EPOCH_KEY, group_id, epoch, device_b64, device_raw, + sk, payload) + vectors.append({ + "group_id": group_id, "epoch": epoch, + "device_b64": device_b64, "device_raw": device_raw.hex(), + "nonce": sealed["nonce"].hex(), "ct": sealed["ct"].hex(), + "sig": sealed["sig"].hex(), + "plaintext": msgpack.packb(payload, use_bin_type=True).hex(), + }) + + payload_file = d / "vectors.json" + payload_file.write_text(json.dumps({"epoch_key": CHAT_EPOCH_KEY.hex(), + "vectors": vectors})) + + proc = subprocess.run( + ["node", str(harness), str(CRYPTO_JS), str(payload_file)], + capture_output=True, text=True, timeout=60, + ) + if proc.returncode != 0: + pytest.fail(f"node chatbox harness failed:\n{proc.stderr}") + return json.loads(proc.stdout), vectors + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_browser_opens_a_chat_message_python_sealed(idx, vector, chatbox_js): + import msgpack + + js, _ = chatbox_js + assert msgpack.unpackb(bytes.fromhex(js["opened"][idx]), raw=False) == \ + _chat_payload(idx) + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_python_opens_a_chat_message_the_browser_sealed(idx, vector, chatbox_js): + import msgpack + + from meshbay_common.chatbox import open_message + + js, vectors = chatbox_js + group_id, epoch = vector + sealed = js["sealed"][idx] + opened = open_message( + CHAT_EPOCH_KEY, group_id, epoch, vectors[idx]["device_b64"], + bytes.fromhex(sealed["nonce"]), bytes.fromhex(sealed["ct"])) + assert opened == _chat_payload(idx) + # And the msgpack the browser produced is what Python produces, so the two + # are not merely each self-consistent. + assert msgpack.packb(opened, use_bin_type=True) == \ + bytes.fromhex(vectors[idx]["plaintext"]) + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_the_signing_transcript_is_byte_identical(idx, vector, chatbox_js): + from meshbay_common.chatbox import signing_transcript + + js, vectors = chatbox_js + group_id, epoch = vector + v = vectors[idx] + expected = signing_transcript( + group_id, epoch, bytes.fromhex(v["device_raw"]), + bytes.fromhex(v["nonce"]), bytes.fromhex(v["ct"])) + assert js["transcripts"][idx] == expected.hex() + + +@pytest.mark.parametrize("idx,vector", list(enumerate(CHAT_VECTORS))) +def test_the_browser_verifies_a_signature_python_made(idx, vector, chatbox_js): + js, _ = chatbox_js + assert js["verified"][idx] is True + + +def test_a_message_does_not_open_under_another_epoch(chatbox_js): + """ + The epoch is in the AAD, so a message cannot be re-presented as belonging + to a later key. Without it, a member who kept an old epoch key could make + an old message look current — and an AEAD that ignored `additionalData` + would round-trip against itself and pass every other test here. + """ + import pytest as _pytest + + from meshbay_common.chatbox import open_message + + _js, vectors = chatbox_js + v = vectors[0] + group_id, epoch = CHAT_VECTORS[0] + with _pytest.raises(Exception): + open_message(CHAT_EPOCH_KEY, group_id, epoch + 1, v["device_b64"], + bytes.fromhex(v["nonce"]), bytes.fromhex(v["ct"])) + + +def test_a_message_does_not_open_under_another_devices_key(chatbox_js): + """ + Each device has its own subkey, derived from its own public key. That is + what makes nonce reuse across two devices of one person impossible without + any coordination — the property per-device ratchet chains were wanted for. + """ + import pytest as _pytest + + from meshbay_common.chatbox import open_message + + _js, vectors = chatbox_js + group_id, epoch = CHAT_VECTORS[0] + with _pytest.raises(Exception): + open_message(CHAT_EPOCH_KEY, group_id, epoch, vectors[1]["device_b64"], + bytes.fromhex(vectors[0]["nonce"]), + bytes.fromhex(vectors[0]["ct"])) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js index 96fc52f..1eba59a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js @@ -17,7 +17,8 @@ import { FolderPickerField } from './folder-tree.js'; * has to be on a read-write root. The picker greys out the rest rather than * letting the node's refusal arrive after the fact. */ -function ChatSettings({ roots, dirs, settings, saveDirectories, transport, signFn }) { +function ChatSettings({ roots, dirs, settings, saveDirectories, transport, + signFn }) { const { busy, msg, run } = useSaver(); const [directory, setDirectory] = useState(settings.chatDirectory || ''); const [linkPreview, setLinkPreview] = useState(settings.chatLinkPreview !== false); @@ -59,6 +60,19 @@ function ChatSettings({ roots, dirs, settings, saveDirectories, transport, signF label=${t('settings_app.chat_link_preview_label')} />

${t('settings_app.chat_link_preview_hint')}

+ ${/* Not a toggle: chat is always encrypted (MNP 2.0), so there is + nothing here to turn on. What an operator may want is to move the + key on deliberately — the removals that matter already do it by + themselves. Stated rather than left invisible, because "is my chat + encrypted?" is a question people ask of a settings pane. */ ''} +
+

${t('settings_app.chat_encrypted_always')}

+ +

${t('settings_app.chat_rotate_epoch_hint')}

+
${msg && html`

${msg}

`} `; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js index 0a7ef26..a7b8fd9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js @@ -203,8 +203,8 @@ function ChatImage({ filename, entries, transportRef, gekRef }) { // read one answer. Empty means the group has no writable root right now — every // root is read-only, or the one drive that was writable is unplugged — and the // paperclip says so rather than producing a refusal from the node. -function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, - onPreview, attachRoot = '', attachDir = '', +function ChatPanel({ transportRef, username, userId, entries, gekRef, + onRefreshIndex, onPreview, attachRoot = '', attachDir = '', onActivity, status }) { const [messages, setMessages] = useState([]); const [hasMore, setHasMore] = useState(false); @@ -257,13 +257,14 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, transport.onChat = (msg) => { const id = msg.id || `live-${Date.now()}-${Math.random().toString(36).slice(2)}`; + // Spread rather than rebuilt field by field: the transport is the one + // place that decides how a message is read, and copying a subset of its + // result here is how the live path and the history path come to disagree + // — which would show up only for messages that cannot be opened. setMessages(prev => [...prev, { + ...msg, id, - sender_id: msg.sender_id, - sender_name: msg.sender_name || '', - payload: msg.payload, timestamp: msg.timestamp || Date.now() / 1000, - thread_id: msg.thread_id, }]); if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id); }; @@ -459,7 +460,8 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, await transport.sendChat(text, 0, null, username); setMessages(prev => [...prev, { id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, - sender_id: username, + own: true, + sender_id: userId || username, sender_name: username, payload: text, timestamp: Date.now() / 1000, @@ -473,7 +475,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, setSending(false); setTimeout(() => { if (inputRef.current) inputRef.current.focus(); }); } - }, [input, username, jumpToBottom]); + }, [input, username, userId, jumpToBottom]); const attachFile = useCallback(async (e) => { const file = e.target.files?.[0]; @@ -501,7 +503,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, await transport.sendChat(structured, 0, null, username); setMessages(prev => [...prev, { id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`, - sender_id: username, sender_name: username, + own: true, sender_id: userId || username, sender_name: username, payload: structured, timestamp: Date.now() / 1000, thread_id: null, }]); jumpToBottom(); @@ -510,7 +512,16 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, } finally { setAttaching(false); } - }, [username, onRefreshIndex, jumpToBottom, attachRoot, attachDir]); + }, [username, userId, onRefreshIndex, jumpToBottom, attachRoot, attachDir]); + + // Chat is always encrypted, and sealing needs this device to have identified + // itself to the node (`device_hello`) — which is also what lets the node + // refuse a member claiming somebody else's key. Without it there is nothing + // to send with, so the composer says so before anything is typed rather than + // producing a refusal the reader cannot act on. + const transportNow = transportRef.current; + const cannotSend = !!(transportNow && transportNow.connected + && !transportNow.devicePk); const onKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -543,7 +554,14 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, `} ${messages.map((m, i) => { - const isOwn = m.sender_name === username || m.sender_id === username; + // By account, and by an explicit flag on our own optimistic echo. + // Comparing a display name against a sender id happened to work + // while the echo invented `sender_id: username`, and would have + // started rendering other people's messages as the reader's own the + // moment two members shared a display name. + const isOwn = m.own === true + || (!!userId && m.sender_id === userId) + || (!userId && m.sender_name === username); const displayName = m.sender_name || '?'; const prev = messages[i - 1]; const showSender = !isOwn && (i === 0 || @@ -551,6 +569,26 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, // A conversation read over several days is unreadable without them. const daySep = i === 0 || !_sameDay(prev.timestamp, m.timestamp) ? _dayLabel(m.timestamp) : null; + // A message the transport could not open is shown as a gap, with + // what went wrong. Dropping it would leave a conversation quietly + // missing messages, which is worse than a visible hole: nobody can + // notice what they were never shown. + if (m.unreadable) { + return html` + ${daySep && html` +
${daySep}
+ `} +
+ ${showSender && html`
${displayName}
`} +
+ + ${t('chat.unreadable_' + m.unreadable) || t('chat.unreadable')} + + ${formatTime(m.timestamp)} +
+
+ `; + } const parsed = _parsePayload(m.payload); const att = parsed && parsed.attachment; const msgText = parsed && typeof parsed.text === 'string' ? parsed.text : m.payload; @@ -613,13 +651,14 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, `}