1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
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 <g>`, with the node running, for a
group attached later or a database restored from a backup.
Both copy `chat.db` to `chat.db.bak-<timestamp>` 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 <days>` / `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.
|