ciphermesh 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,486 @@
1
+ # CipherMesh wire protocol
2
+
3
+ Version **2**. This document describes the protocol as implemented, so that an
4
+ audit has something to check against and a second implementation has something
5
+ to build against.
6
+
7
+ Where behaviour is pinned by test vectors, that is said explicitly — the vectors
8
+ in `test/vectors/` are the normative artefact and this document describes them,
9
+ not the other way round. Where the relay is *forbidden* to do something, the
10
+ reason is given, because those are the places where an innocent-looking change
11
+ silently removes a guarantee.
12
+
13
+ Reference implementation: `src/protocol/` (framing and validation),
14
+ `src/crypto/` (everything else), `src/server/WebSocketServer.js` (relay).
15
+
16
+ ---
17
+
18
+ ## 1. Transport
19
+
20
+ WebSocket. TLS is expected in deployment and self-signed by default — **no
21
+ security property in this document depends on the TLS certificate.** End-to-end
22
+ trust comes from TOFU pinning and SAS verification of identity keys.
23
+
24
+ | Property | Value | Source |
25
+ |---|---|---|
26
+ | Default port | 3600 | `SERVER_PORT` |
27
+ | Max frame | 65 536 bytes | `MAX_PAYLOAD_SIZE` |
28
+ | Heartbeat | 30 s | `HEARTBEAT_INTERVAL_MS` |
29
+ | Idle session timeout | 300 s | `SESSION_TIMEOUT_MS` |
30
+ | Must JOIN within | 15 s | `JOIN_TIMEOUT_MS` |
31
+
32
+ Every frame is a single JSON object, UTF-8. Binary frames are not used; all
33
+ byte strings are base64 in JSON fields.
34
+
35
+ ### Rate and resource limits
36
+
37
+ A conforming relay may refuse service; a conforming client must cope with being
38
+ refused. These are the reference values.
39
+
40
+ | Limit | Value | Scope |
41
+ |---|---|---|
42
+ | Messages per second | 60 | per connection, **all** message types |
43
+ | Routed messages per second | 30 | per session, `encrypted_message` and `group_message` |
44
+ | Bytes per second | 1 MiB sustained, 4 MiB burst | per connection, charged on inbound frames before parsing |
45
+ | Connections | 500 total, 20 per IP | per relay |
46
+ | New connections | 60 per minute per IP | escalating bans on repeat |
47
+
48
+ The byte budget is charged **before** parsing: the bytes have already been
49
+ received by then, so refusing to spend further effort on them is the only saving
50
+ left. A connection that exhausts it is closed with code 1008.
51
+
52
+ ---
53
+
54
+ ## 2. Framing
55
+
56
+ Every message carries three fields:
57
+
58
+ ```json
59
+ { "type": "<string>", "version": 2, "timestamp": 1739800000000 }
60
+ ```
61
+
62
+ `version` is checked for **exact equality** and a mismatch is fatal
63
+ (`src/protocol/validators.js`). It is not a negotiation mechanism — see §3.
64
+
65
+ `timestamp` is the sender's clock in milliseconds. The relay does not trust it
66
+ and does not correct it; it exists for the recipient.
67
+
68
+ Unknown fields are ignored. This is load-bearing: it is what lets an optional
69
+ field like `pqPublicKey`, `caps` or `room` be added without a version bump, and
70
+ what lets an older relay pass through a message it does not fully understand.
71
+
72
+ ---
73
+
74
+ ## 3. Capability negotiation
75
+
76
+ `version` can only say "same" or "refuse to talk". It cannot express *newer, but
77
+ still willing to speak the old way*, which is what a protocol change rolled
78
+ through a public hub needs. Capabilities carry that.
79
+
80
+ 1. A client lists what it can do in `JOIN` (`caps: ["sk1"]`).
81
+ 2. The relay validates the list, stores it, and hands it on **verbatim** in
82
+ `join_ack` (per peer) and `peer_joined`. It never acts on a capability.
83
+ 3. The relay advertises **its own** abilities in `join_ack.serverCaps`. No client
84
+ can promise these on the relay's behalf.
85
+ 4. A feature turns on only when **every member of the room** advertises it *and*
86
+ the relay does.
87
+
88
+ Absent or empty means an older participant, which is a fallback, not an error.
89
+
90
+ **Bounds** (the list arrives from a public hub, so it is attacker-controlled):
91
+ at most 16 entries, each 1–24 characters matching `^[a-z0-9][a-z0-9_-]*$`. A
92
+ malformed list gets the whole `JOIN` **rejected**, not filtered — the relay hands
93
+ this list to other clients, and forwarding the good half of a bad list would make
94
+ a peer look capable of something it never claimed.
95
+
96
+ | Capability | Advertised by | Meaning |
97
+ |---|---|---|
98
+ | `sk1` | client | I can *receive* a group message (§7) |
99
+ | `sk1` | relay | I can fan a room-addressed message out |
100
+
101
+ Neither means "I send group messages". Receive and fan-out ship a release ahead
102
+ of send, because the switch is *every member agrees*: if reading and writing
103
+ arrived together, the switch would only ever be true in rooms where everybody
104
+ upgraded at the same moment.
105
+
106
+ **What a hostile relay gains by editing these lists:** stripping a capability
107
+ forces the room onto the older path, which is the status quo and reveals nothing
108
+ new. Adding one a peer never claimed makes senders encrypt in a form that peer
109
+ cannot read — denial of service, immediately visible, never a way to read
110
+ plaintext. The all-members rule is what keeps the damage on that side.
111
+
112
+ ---
113
+
114
+ ## 4. Session lifecycle
115
+
116
+ ```
117
+ client relay other clients
118
+ │ join(nickname, publicKey, │ │
119
+ │ pqPublicKey?, caps?) │ │
120
+ ├──────────────────────────────▶│ │
121
+ │ │ peer_joined(peer) │
122
+ │ join_ack(sessionId, peers, ├─────────────────────────────────▶│
123
+ │ room, serverCaps?) │ │
124
+ │◀──────────────────────────────┤ │
125
+ ```
126
+
127
+ A session is identified by a server-assigned `sessionId` (UUID). It is **not** an
128
+ identity: identity is the Curve25519 public key, and nicknames are neither unique
129
+ across time nor authenticated. A client that reconnects gets a new `sessionId`
130
+ and must be recognised by key.
131
+
132
+ ### `join` (client → relay)
133
+
134
+ | Field | Type | Required | Notes |
135
+ |---|---|---|---|
136
+ | `nickname` | string | yes | 1–20 chars, `^[a-zA-Z0-9_-]+$`, control characters stripped, case-insensitively unique among live sessions |
137
+ | `publicKey` | base64(32) | yes | Curve25519 identity key |
138
+ | `pqPublicKey` | base64(1184) | no | ML-KEM-768 encapsulation key; absent = classical-only peer |
139
+ | `caps` | string[] | no | §3; omitted when empty |
140
+
141
+ ### `join_ack` (relay → client)
142
+
143
+ | Field | Type | Notes |
144
+ |---|---|---|
145
+ | `sessionId` | string | UUID for this connection |
146
+ | `peers` | object[] | `{ sessionId, nickname, publicKey, pqPublicKey?, caps? }` |
147
+ | `room` | string | always `general` on join |
148
+ | `queuedCount` | number | omitted when 0 |
149
+ | `serverCaps` | string[] | omitted when empty |
150
+ | `roomOwner` | string | nickname, when the room has one |
151
+ | `motd` | string | operator notice, when configured |
152
+
153
+ ### `peer_joined` / `peer_left` (relay → clients)
154
+
155
+ `peer_joined` carries the same peer object as `join_ack.peers`. Both carry an
156
+ optional `room`; absent means the session's only room. Old clients ignore it.
157
+
158
+ ### `ping` / `pong`
159
+
160
+ Either direction, no payload beyond the framing.
161
+
162
+ ---
163
+
164
+ ## 5. Identity and key agreement
165
+
166
+ Each client holds a long-term Curve25519 keypair. Peers learn each other's public
167
+ key from `join_ack` / `peer_joined` — that is, **from the relay**, which is why
168
+ TOFU pinning and out-of-band SAS verification exist: the relay is trusted to
169
+ route, never to attest.
170
+
171
+ `key_update` (client → relay) announces a new public key; the relay forwards it
172
+ as `peer_key_updated` to every session sharing a room. The previous key is kept
173
+ briefly on both sides so messages in flight still open.
174
+
175
+ **Hybrid post-quantum.** When both sides advertised `pqPublicKey`, the ratchet
176
+ root is mixed once at initialisation:
177
+
178
+ ```
179
+ root' = BLAKE2b(root ‖ ML-KEM-768.shared ‖ "ciphermesh/pq-hybrid-v3")
180
+ ```
181
+
182
+ The KEM ciphertext rides in `payload.pqCiphertext` until the peer replies. The
183
+ mix happens exactly once, before any chain key is derived, so the two sides
184
+ cannot desynchronise: an envelope without the expected mix simply fails its MAC.
185
+
186
+ ---
187
+
188
+ ## 6. Pairwise messages
189
+
190
+ `encrypted_message` is the unicast path. On the wire it is **only**:
191
+
192
+ ```json
193
+ {
194
+ "type": "encrypted_message",
195
+ "version": 2,
196
+ "timestamp": 1739800001000,
197
+ "to": "<recipient sessionId>",
198
+ "sealed": "base64(crypto_box_seal({ from, payload }, recipientPublicKey))"
199
+ }
200
+ ```
201
+
202
+ There is deliberately **no `from`**. The sender's identity and the whole
203
+ already-encrypted payload are sealed to the recipient's public key with an
204
+ anonymous box. A relay implementation must:
205
+
206
+ - route on `to` alone
207
+ - strip any `from` a client sets, so it can never be forwarded
208
+ - never log, store or stamp the sender
209
+
210
+ The inner `payload`, once unsealed, is one of:
211
+
212
+ **Ratcheted** (the normal case) — Double Ratchet, per-message keys:
213
+
214
+ ```json
215
+ { "ephemeralPublicKey": "b64", "counter": 0, "previousCounter": 0,
216
+ "ciphertext": "b64", "nonce": "b64(24)", "pqCiphertext": "b64?" }
217
+ ```
218
+
219
+ **Static** (no ratchet yet) — `crypto_box_easy` under the two identity keys:
220
+
221
+ ```json
222
+ { "ciphertext": "b64", "nonce": "b64(24)" }
223
+ ```
224
+
225
+ **Deniable** — symmetric `crypto_secretbox` under a derived shared key, marked
226
+ `"deniable": true`. No signature, so neither party can prove authorship to a
227
+ third party.
228
+
229
+ Decrypted content is JSON. `text` and `sentAt` are the common case; an `action`
230
+ field selects everything else (typing, receipts, reactions, edits, file
231
+ transfer, topic, sender-key distribution — §7).
232
+
233
+ ### Replay and ordering
234
+
235
+ Static and deniable messages carry a structured nonce: 8 bytes big-endian
236
+ timestamp, then a per-peer counter. Recipients reject a nonce outside the
237
+ freshness window or with a non-increasing counter. Ratcheted messages are
238
+ protected by the ratchet's own counters, which also bound how many skipped
239
+ message keys may be cached.
240
+
241
+ ---
242
+
243
+ ## 7. Group messages (sender keys)
244
+
245
+ The unicast path costs one encryption and one envelope **per recipient**. Sender
246
+ keys make it one of each for the whole room.
247
+
248
+ ### The chain
249
+
250
+ Each member owns a symmetric ratchet chain per room:
251
+
252
+ ```
253
+ messageKey = BLAKE2b-256(key = chainKey, message = 0x01)
254
+ chainKey' = BLAKE2b-256(key = chainKey, message = 0x02)
255
+ ```
256
+
257
+ Note the argument order: the one-byte domain tag is the *message*, the chain key
258
+ is the *key*. Counters start at 0 and increment by one per message. Receivers may
259
+ cache up to 1000 skipped keys for out-of-order delivery; a larger gap is refused,
260
+ as is a counter already consumed.
261
+
262
+ **Pinned in `test/vectors/sender-key.json`.** Those values are frozen: a change
263
+ that forces them to move is a protocol version bump, not a regeneration.
264
+
265
+ ### Distribution
266
+
267
+ A member hands their chain to another member over the **pairwise sealed
268
+ channel**, never on the group path — the envelope is what authenticates who sent
269
+ it. The payload is:
270
+
271
+ ```json
272
+ { "action": "sk_dist", "room": "general",
273
+ "dist": { "chainKey": "b64(32)", "counter": 7,
274
+ "keyId": "b64(16)", "signPk": "b64(32)" },
275
+ "sentAt": 1739800000000 }
276
+ ```
277
+
278
+ A distribution serialises the chain at its **current** counter. A member who
279
+ receives one mid-conversation therefore cannot read anything sent before it —
280
+ that is forward secrecy, not a defect, and it is why the backlog question in §8
281
+ answers itself.
282
+
283
+ ### The message
284
+
285
+ ```json
286
+ {
287
+ "type": "group_message",
288
+ "version": 2,
289
+ "timestamp": 1739800001000,
290
+ "room": "general",
291
+ "keyId": "b64(16)",
292
+ "counter": 7,
293
+ "ciphertext": "b64",
294
+ "nonce": "b64(24)",
295
+ "signature": "b64(64)"
296
+ }
297
+ ```
298
+
299
+ No `to`, because there is no single recipient. **No `from`**, because the relay
300
+ must not become the one place on this wire that asserts who is speaking.
301
+
302
+ `keyId` names the sender's *chain*, not the sender. Members resolve it through
303
+ the distribution they were handed; to the relay it is a random string, and it
304
+ already knows which socket sent the frame, so it learns nothing from it. It is
305
+ redrawn on every rotation and dropped when a member is removed.
306
+
307
+ `signature` is Ed25519 over the length-prefixed concatenation of
308
+ `keyId`, `counter`, `ciphertext`, `nonce`, under the `signPk` from the sender's
309
+ distribution. **It is not optional.** A sender chain is symmetric — every member
310
+ holds the key that decrypts a given sender, and can therefore also produce
311
+ ciphertext on it — so without a signature "Alice said this" would only ever mean
312
+ "somebody in this room said this".
313
+
314
+ A recipient must **verify before touching the chain**. `messageKeyFor()` mutates
315
+ state, so an unauthenticated packet carrying a large counter would otherwise be a
316
+ way to make the receiver derive and cache a thousand message keys.
317
+
318
+ A member whose distribution carried no usable `signPk` is registered but
319
+ unverifiable: nothing they send is accepted. Fail closed.
320
+
321
+ ### Rotation
322
+
323
+ `rotate()` replaces the chain, the `keyId` and the signing key together. It must
324
+ follow **every** membership change, and the caller must redistribute afterwards —
325
+ nothing signals a failure to do so, and the room simply stops being able to read
326
+ the rotator.
327
+
328
+ ### What the relay does
329
+
330
+ Validates the shape, checks the sender **is a member of `room`**, spends the same
331
+ per-sender budget as the unicast path, and fans the message out to every other
332
+ member. It cannot verify the signature — it holds no signing keys — and does not
333
+ try.
334
+
335
+ Room membership is not a formality: without it one connection could inject into
336
+ every room on the hub at once, which the unicast path cannot do because it needs
337
+ a `sessionId` it could only have been told.
338
+
339
+ ---
340
+
341
+ ## 8. Offline delivery
342
+
343
+ `encrypted_message` addressed to a session that has just left is queued by
344
+ nickname + public key, for at most 1 hour, 100 per peer and 1000 in total. On
345
+ rejoin with the **same public key** the queue is delivered with `to` rewritten to
346
+ the new session. A different key drops the queue: it could not be opened anyway.
347
+
348
+ **`group_message` is never queued.** Not policy — arithmetic. A sender key handed
349
+ over on someone's return serialises the chain at its current counter, so the
350
+ backlog is unreadable to them whatever the relay does with it. Queueing would
351
+ store ciphertext on the relay that provably nobody can open: all of the storage
352
+ and the liability, none of the delivery.
353
+
354
+ ---
355
+
356
+ ## 9. Rooms
357
+
358
+ Room names are 1–30 characters, `^[a-zA-Z0-9_-]+$`, lowercased by the relay.
359
+ `general` always exists and can never be private.
360
+
361
+ | Message | Direction | Effect |
362
+ |---|---|---|
363
+ | `change_room` | c → r | Leave every room, enter one |
364
+ | `room_changed` | r → c | Confirmed, with `peers` and `private` |
365
+ | `join_room` | c → r | Enter an **additional** room |
366
+ | `room_joined` | r → c | Confirmed |
367
+ | `leave_room` / `room_left` | c ↔ r | Leave one; the last is refused |
368
+ | `list_rooms` / `room_list` | c ↔ r | `[{ name, memberCount, private }]` |
369
+
370
+ `change_room` and `join_room` carry an optional `roomAuthPk` — an Ed25519
371
+ verifier key, present only when **creating** a private room.
372
+
373
+ ### Private rooms, without the relay learning the password
374
+
375
+ ```
376
+ client relay
377
+ │ join_room(room) │
378
+ ├────────────────────────────────────────▶│
379
+ │ room_challenge(room, nonce) │
380
+ │◀────────────────────────────────────────┤
381
+ │ room_auth(room, nonce, signature) │
382
+ ├────────────────────────────────────────▶│ verify against stored roomAuthPk
383
+ │ room_joined(room, peers, private) │
384
+ │◀────────────────────────────────────────┤
385
+ ```
386
+
387
+ The password is stretched with Argon2id into a room key and an Ed25519 keypair.
388
+ Only the **verifier public key** is ever sent, at creation. Joining proves
389
+ knowledge by signing `(room, nonce, sessionId)` — binding to the session, so a
390
+ signature observed from another member cannot be replayed.
391
+
392
+ Challenges expire in 60 s. Five failures in 60 s on one connection stops further
393
+ attempts.
394
+
395
+ Room content gets a second encryption layer under the room key, **inside** the
396
+ transport layer: a message in a private room is room-encrypted and then sent
397
+ through the pairwise or group path as usual.
398
+
399
+ ---
400
+
401
+ ## 10. Moderation
402
+
403
+ `kick_peer`, `mute_peer` (with `durationMs`), `ban_peer` — owner only, with an
404
+ optional `room`. Reasons are truncated to 200 characters. The relay broadcasts
405
+ `peer_kicked` / `peer_muted` to the room. A muted session is refused for
406
+ `encrypted_message` and `group_message` alike.
407
+
408
+ These are relay-enforced conveniences and nothing more. A relay that ignores them
409
+ breaks no cryptographic guarantee, which is why blocking is *also* implemented
410
+ client-side, where it cannot be overruled.
411
+
412
+ ---
413
+
414
+ ## 11. Errors
415
+
416
+ ```json
417
+ { "type": "error", "version": 2, "timestamp": 0, "code": "...", "message": "..." }
418
+ ```
419
+
420
+ | Code | Meaning |
421
+ |---|---|
422
+ | `NICKNAME_TAKEN` | Another live session holds it |
423
+ | `INVALID_MESSAGE` | Failed validation, or sent before `join` |
424
+ | `PEER_NOT_FOUND` | No such session, and nothing queued |
425
+ | `RATE_LIMITED` | Over a per-second budget |
426
+ | `PAYLOAD_TOO_LARGE` | Frame above `MAX_PAYLOAD_SIZE` |
427
+ | `ROOM_AUTH_FAILED` | Bad signature, expired challenge, too many attempts |
428
+ | `ROOM_EXISTS` | Cannot create; already there |
429
+
430
+ Error messages are for humans and must never quote the content that caused them.
431
+
432
+ ---
433
+
434
+ ## 12. Padding
435
+
436
+ Every plaintext is padded before encryption to the smallest bucket that fits:
437
+
438
+ ```
439
+ 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768
440
+ ```
441
+
442
+ Format: 2 bytes big-endian length, the plaintext, then random filler. A payload
443
+ larger than the biggest bucket is sent unpadded — file chunks are the reason —
444
+ and a single plaintext may not exceed 65 535 bytes.
445
+
446
+ This is what makes "the relay sees a bucketed size" true rather than a slogan.
447
+ It is asserted in `test/guarantees.test.js`.
448
+
449
+ ---
450
+
451
+ ## 13. What the relay learns
452
+
453
+ Stated plainly, because a specification that only lists the protections is
454
+ misleading.
455
+
456
+ **It sees:** who is connected and under what nickname and public key; which
457
+ rooms exist and who is in them; for a unicast message, the recipient; for a group
458
+ message, the room; the timing and bucketed size of everything; and, since a
459
+ connection is authenticated and persistent, which socket sent any given frame.
460
+
461
+ **It does not see:** any plaintext; any private-room password; the sender named
462
+ *in* a message; the contents of a sender-key distribution.
463
+
464
+ **Sealed sender is a guarantee against an honest-but-curious relay**, the same
465
+ one Signal makes. A malicious relay can correlate the sending socket to a
466
+ session — inherent to a persistent authenticated connection, and not something
467
+ this protocol claims to solve. P2P mode removes the relay entirely.
468
+
469
+ **Sender keys give confidentiality and per-member authenticity within a room, not
470
+ anonymity within it.** Members can tell each other apart, which is the point.
471
+
472
+ ---
473
+
474
+ ## 14. Conformance
475
+
476
+ An implementation claiming compatibility should:
477
+
478
+ - reproduce `test/vectors/sender-key.json` exactly
479
+ - send no `from` on any frame, ever
480
+ - reject a `join` whose `caps` is malformed rather than filtering it
481
+ - verify a group signature before advancing the chain
482
+ - treat an absent capability as an older peer, never as an error
483
+ - never queue a `group_message`
484
+
485
+ `test/guarantees.test.js` asserts the properties above that can be observed from
486
+ outside a single implementation.
@@ -0,0 +1,118 @@
1
+ # Sender keys on the relay
2
+
3
+ Status: **design, not implemented.** Written 2026-08-07, straight after
4
+ measuring the problem, so the next session starts from the constraints rather
5
+ than rediscovering them.
6
+
7
+ ## The problem, measured
8
+
9
+ `ChatController.#broadcastPayload` loops over every peer in the room and seals
10
+ one envelope each:
11
+
12
+ ```js
13
+ for (const [peerId] of this.#peers) {
14
+ const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
15
+ ...
16
+ this.#sealAndSend(peerPublicKey, msg);
17
+ }
18
+ ```
19
+
20
+ One typed line in a room of N people is **N encryptions and N envelopes on the
21
+ wire**. Cost grows linearly with room size, on the sender's CPU and on the
22
+ sender's uplink — the two places least able to absorb it.
23
+
24
+ 2.10.0 made this a ceiling rather than a slope. `MAX_BYTES_PER_SECOND` bounds a
25
+ connection at 1 MiB/s by default, and messages are padded into buckets of up to
26
+ 32 KiB. A 32 KiB bucket sent to fifty people is 1.6 MiB for one line: the sender
27
+ is throttled, or disconnected, for saying one thing.
28
+
29
+ P2P does not have this problem. `P2PChatController` already uses
30
+ `GroupSession` from `src/crypto/SenderKey.js`, encrypts once, and distributes
31
+ the sender key per member. The code is written and tested; it is only the relay
32
+ path that never adopted it.
33
+
34
+ ## What changes
35
+
36
+ 1. Each member holds a **sender chain** for the room and distributes its
37
+ `distribution()` to every other member — sealed per member, once, rather
38
+ than per message.
39
+ 2. A message is encrypted **once** with the sender's chain and handed to the
40
+ relay with a room destination rather than a peer destination.
41
+ 3. The relay fans the single ciphertext out to the room's members. It still
42
+ cannot read anything, and it still never learns the sender under sealed
43
+ sender.
44
+ 4. On any membership change, the leaver's departure triggers `rotate()` and a
45
+ redistribution — exactly what `P2PChatController` does today at lines 320
46
+ and 487, with the comment already written there.
47
+
48
+ Cost per message goes from N encryptions and N envelopes to **one and one**.
49
+ Distribution cost is N, but paid on join and on membership change rather than
50
+ on every line.
51
+
52
+ ## The hard part: two versions in one room
53
+
54
+ This is a protocol change. A 2.11 client encrypting once to a group and a 2.10
55
+ client expecting an envelope addressed to it **cannot read each other**. The hub
56
+ is public and people upgrade whenever they upgrade, so "everyone updates at
57
+ once" is not available.
58
+
59
+ Rolling this out badly breaks live conversations for strangers. Options, in the
60
+ order they should be considered:
61
+
62
+ - **Negotiate, do not assume.** The JOIN acknowledgement already carries each
63
+ peer's public key; it can carry a capability list too. A sender uses group
64
+ encryption only when *every* member of the room advertises it, and falls back
65
+ to the current per-peer loop otherwise. Costs a room-wide check per send,
66
+ which is cheap and already computed.
67
+ - **Both paths coexist for at least one minor.** Deleting the fan-out in the
68
+ same release that adds sender keys leaves no way back if the new path has a
69
+ bug that only shows at scale — which is exactly the kind of bug it would have.
70
+ - **The relay needs a room-addressed message type** that does not exist yet.
71
+ It must not weaken sealed sender: today the relay learns the recipient and not
72
+ the sender, and a room-addressed envelope must not accidentally invert that.
73
+
74
+ ## What to be careful about
75
+
76
+ - **Rotation must be wired to every departure**, not just voluntary leaves.
77
+ `/kick`, `/mute`, `/ban` and a dropped connection all change membership. The
78
+ P2P side rotates on `peer_left`; the relay side has more ways to lose a member.
79
+ - **`SenderKey.rotate()` is a caller responsibility** — its own comment says
80
+ so. The distribution has to follow it or the room silently stops being able to
81
+ read the rotator.
82
+ - **Private rooms add a second layer** (`encryptRoomPayload` with the
83
+ password-derived key). Group encryption goes *inside* that, not instead of it.
84
+ - **The offline queue** stores envelopes addressed to a peer. A room-addressed
85
+ message needs an answer for someone who was offline when it was sent, and
86
+ "they get the sender key on rejoin but not the backlog" is a decision to make
87
+ deliberately rather than discover.
88
+
89
+ **Decided (2026-08-10): room-addressed messages are not queued.** The reason is
90
+ not policy but arithmetic — a sender key handed over on someone's return
91
+ serialises the chain at its *current* counter, so the backlog is unreadable to
92
+ them whatever the relay does with it. Queueing would hold ciphertext nobody can
93
+ open: all of the storage and the liability, none of the delivery. The unicast
94
+ queue survives because an envelope addressed to a peer is still openable when
95
+ they return with the same key. Pinned in `test/group-receive.test.js`.
96
+
97
+ - **Sender keys are symmetric, so any member can forge another.** Not in the
98
+ original list, and it does not matter much in a P2P mesh where membership is
99
+ small and deliberate. It matters on a public hub. **Closed (2026-08-10)** with
100
+ an Ed25519 key per sender chain, distributed alongside the chain and verified
101
+ before the ratchet is touched. Doing it before the send path existed meant it
102
+ cost a field on a wire nobody was using yet.
103
+
104
+ ## Suggested order
105
+
106
+ 1. Test vectors for `SenderKey` distribution and rotation, so both sides of the
107
+ change are pinned before either moves.
108
+ 2. Capability advertisement in JOIN, with the fallback path left untouched.
109
+ 3. Group send/receive behind that capability, both paths live.
110
+ 4. Rotation wired to every membership change, with a test per route in.
111
+ 5. Only then, consider retiring the per-peer loop — a release later, at least.
112
+
113
+ ## Why it is worth it
114
+
115
+ It is the one change that is simultaneously a feature, a fix and an
116
+ improvement: it removes a scaling limit the project just made visible to itself,
117
+ it reuses code that already exists and is already tested, and it is the
118
+ difference between the hub holding a room of five and a room of fifty.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ciphermesh",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Secure terminal chat for the local network (LAN) with real end-to-end encryption (E2EE) using libsodium",
5
5
  "type": "module",
6
6
  "main": "src/client/index.js",