ciphermesh 2.10.0 → 2.12.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,556 @@
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
+ `peer_left` is emitted for **every** way a session stops being in a room:
159
+ leaving, switching, disconnecting, being kicked, being banned. That completeness
160
+ is load-bearing rather than tidy. A sender chain (§7) is shared with exactly the
161
+ members of a room, so the set of departures a client hears about is the set of
162
+ moments it can rotate that chain at — and a departure it never hears about is a
163
+ chain that outlives the membership it was drawn for.
164
+
165
+ ### `ping` / `pong`
166
+
167
+ Either direction, no payload beyond the framing.
168
+
169
+ ---
170
+
171
+ ## 5. Identity and key agreement
172
+
173
+ Each client holds a long-term Curve25519 keypair. Peers learn each other's public
174
+ key from `join_ack` / `peer_joined` — that is, **from the relay**, which is why
175
+ TOFU pinning and out-of-band SAS verification exist: the relay is trusted to
176
+ route, never to attest.
177
+
178
+ `key_update` (client → relay) announces a new public key; the relay forwards it
179
+ as `peer_key_updated` to every session sharing a room. The previous key is kept
180
+ briefly on both sides so messages in flight still open.
181
+
182
+ **Hybrid post-quantum.** When both sides advertised `pqPublicKey`, the ratchet
183
+ root is mixed once at initialisation:
184
+
185
+ ```
186
+ root' = BLAKE2b(root ‖ ML-KEM-768.shared ‖ "ciphermesh/pq-hybrid-v3")
187
+ ```
188
+
189
+ The KEM ciphertext rides in `payload.pqCiphertext` until the peer replies. The
190
+ mix happens exactly once, before any chain key is derived, so the two sides
191
+ cannot desynchronise: an envelope without the expected mix simply fails its MAC.
192
+
193
+ ---
194
+
195
+ ## 6. Pairwise messages
196
+
197
+ `encrypted_message` is the unicast path. On the wire it is **only**:
198
+
199
+ ```json
200
+ {
201
+ "type": "encrypted_message",
202
+ "version": 2,
203
+ "timestamp": 1739800001000,
204
+ "to": "<recipient sessionId>",
205
+ "sealed": "base64(crypto_box_seal({ from, payload }, recipientPublicKey))"
206
+ }
207
+ ```
208
+
209
+ There is deliberately **no `from`**. The sender's identity and the whole
210
+ already-encrypted payload are sealed to the recipient's public key with an
211
+ anonymous box. A relay implementation must:
212
+
213
+ - route on `to` alone
214
+ - strip any `from` a client sets, so it can never be forwarded
215
+ - never log, store or stamp the sender
216
+
217
+ The inner `payload`, once unsealed, is one of:
218
+
219
+ **Ratcheted** (the normal case) — Double Ratchet, per-message keys:
220
+
221
+ ```json
222
+ { "ephemeralPublicKey": "b64", "counter": 0, "previousCounter": 0,
223
+ "ciphertext": "b64", "nonce": "b64(24)", "pqCiphertext": "b64?" }
224
+ ```
225
+
226
+ **Static** (no ratchet yet) — `crypto_box_easy` under the two identity keys:
227
+
228
+ ```json
229
+ { "ciphertext": "b64", "nonce": "b64(24)" }
230
+ ```
231
+
232
+ **Deniable** — symmetric `crypto_secretbox` under a derived shared key, marked
233
+ `"deniable": true`. No signature, so neither party can prove authorship to a
234
+ third party.
235
+
236
+ Decrypted content is JSON. `text` and `sentAt` are the common case; an `action`
237
+ field selects everything else (typing, receipts, reactions, edits, file
238
+ transfer, topic, sender-key distribution — §7).
239
+
240
+ ### Replay and ordering
241
+
242
+ Static and deniable messages carry a structured nonce: 8 bytes big-endian
243
+ timestamp, then a per-peer counter. Recipients reject a nonce outside the
244
+ freshness window or with a non-increasing counter. Ratcheted messages are
245
+ protected by the ratchet's own counters, which also bound how many skipped
246
+ message keys may be cached.
247
+
248
+ ---
249
+
250
+ ## 7. Group messages (sender keys)
251
+
252
+ The unicast path costs one encryption and one envelope **per recipient**. Sender
253
+ keys make it one of each for the whole room.
254
+
255
+ ### When this path is used
256
+
257
+ Three conditions, all required, each failing for a different reason:
258
+
259
+ | Condition | Fails when |
260
+ | --- | --- |
261
+ | every member of the room advertises `sk1` | one peer is on an older build |
262
+ | `join_ack.serverCaps` contains `sk1` | the hub is older |
263
+ | the message is not deniable | see below |
264
+
265
+ Any one false and the per-peer path runs unchanged. A sender **must** re-check
266
+ per message rather than caching the answer: a single arrival can take a room off
267
+ this path, and encrypting for a member who cannot decrypt is silent.
268
+
269
+ **Deniable messages never take this path.** Deniability comes from a symmetric
270
+ key both sides could have derived, so neither can prove the other wrote it. A
271
+ group packet is signed by exactly one sender — sending a deniable message on it
272
+ would publish precisely what deniability is for hiding.
273
+
274
+ **Cover traffic takes whichever path real messages take.** A decoy that travelled
275
+ the per-peer path while the room was sending group messages would be
276
+ distinguishable from the thing it exists to imitate.
277
+
278
+ ### The chain
279
+
280
+ Each member owns a symmetric ratchet chain per room:
281
+
282
+ ```
283
+ messageKey = BLAKE2b-256(key = chainKey, message = 0x01)
284
+ chainKey' = BLAKE2b-256(key = chainKey, message = 0x02)
285
+ ```
286
+
287
+ Note the argument order: the one-byte domain tag is the *message*, the chain key
288
+ is the *key*. Counters start at 0 and increment by one per message. Receivers may
289
+ cache up to 1000 skipped keys for out-of-order delivery; a larger gap is refused,
290
+ as is a counter already consumed.
291
+
292
+ **Pinned in `test/vectors/sender-key.json`.** Those values are frozen: a change
293
+ that forces them to move is a protocol version bump, not a regeneration.
294
+
295
+ ### Distribution
296
+
297
+ A member hands their chain to another member over the **pairwise sealed
298
+ channel**, never on the group path — the envelope is what authenticates who sent
299
+ it. The payload is:
300
+
301
+ ```json
302
+ { "action": "sk_dist", "room": "general",
303
+ "dist": { "chainKey": "b64(32)", "counter": 7,
304
+ "keyId": "b64(16)", "signPk": "b64(32)" },
305
+ "sentAt": 1739800000000 }
306
+ ```
307
+
308
+ A distribution serialises the chain at its **current** counter. A member who
309
+ receives one mid-conversation therefore cannot read anything sent before it —
310
+ that is forward secrecy, not a defect, and it is why the backlog question in §8
311
+ answers itself.
312
+
313
+ **Who speaks first is not a free choice.** A client drops a ciphertext from a
314
+ session it holds no public key for, and a joiner learns the room from its
315
+ `join_ack` *before* the room learns of the joiner from `peer_joined`. A newcomer
316
+ that distributed on arrival would be talking to peers who cannot yet hear it, and
317
+ would have no way to discover that.
318
+
319
+ So distribution is **answered, never announced**:
320
+
321
+ 1. the peers who already know the newcomer distribute to it (on `peer_joined`);
322
+ 2. the newcomer replies with its own to anyone whose distribution it receives and
323
+ who does not already hold its current chain.
324
+
325
+ Receiving a distribution proves the sender holds your public key, so the reply
326
+ cannot race. A sender must also distribute to any room member it has not yet
327
+ given its current chain to before sending — the exchange above covers every
328
+ ordinary path, and that check covers the rest.
329
+
330
+ ### The message
331
+
332
+ ```json
333
+ {
334
+ "type": "group_message",
335
+ "version": 2,
336
+ "timestamp": 1739800001000,
337
+ "room": "general",
338
+ "keyId": "b64(16)",
339
+ "counter": 7,
340
+ "ciphertext": "b64",
341
+ "nonce": "b64(24)",
342
+ "signature": "b64(64)"
343
+ }
344
+ ```
345
+
346
+ No `to`, because there is no single recipient. **No `from`**, because the relay
347
+ must not become the one place on this wire that asserts who is speaking.
348
+
349
+ `keyId` names the sender's *chain*, not the sender. Members resolve it through
350
+ the distribution they were handed; to the relay it is a random string, and it
351
+ already knows which socket sent the frame, so it learns nothing from it. It is
352
+ redrawn on every rotation and dropped when a member is removed.
353
+
354
+ `signature` is Ed25519 over the length-prefixed concatenation of
355
+ `keyId`, `counter`, `ciphertext`, `nonce`, under the `signPk` from the sender's
356
+ distribution. **It is not optional.** A sender chain is symmetric — every member
357
+ holds the key that decrypts a given sender, and can therefore also produce
358
+ ciphertext on it — so without a signature "Alice said this" would only ever mean
359
+ "somebody in this room said this".
360
+
361
+ A recipient must **verify before touching the chain**. `messageKeyFor()` mutates
362
+ state, so an unauthenticated packet carrying a large counter would otherwise be a
363
+ way to make the receiver derive and cache a thousand message keys.
364
+
365
+ A member whose distribution carried no usable `signPk` is registered but
366
+ unverifiable: nothing they send is accepted. Fail closed.
367
+
368
+ ### Rotation
369
+
370
+ `rotate()` replaces the chain, the `keyId` and the signing key together. It must
371
+ follow **every** membership change, and the caller must redistribute afterwards —
372
+ nothing signals a failure to do so, and the room simply stops being able to read
373
+ the rotator.
374
+
375
+ Concretely, a departure rotates: leaving, switching rooms, disconnecting, being
376
+ kicked, being banned. All five reach the client as `peer_left` (§4), which is why
377
+ that message has to be emitted for all five and not only the voluntary ones — a
378
+ departure the client never hears about is a chain that is never rotated, and a
379
+ removed member whose copy still opens everything that follows.
380
+
381
+ **An arrival does not rotate.** A distribution carries the chain's current
382
+ counter, so a newcomer is handed what opens the next message and nothing before
383
+ it. Rotating on arrival would cost a redistribution to the whole room and buy
384
+ nothing.
385
+
386
+ A full room switch drops every chain rather than rotating one: the client is no
387
+ longer in the rooms those chains were drawn for, and carrying one across would
388
+ use a chain drawn for one membership against another.
389
+
390
+ ### What the relay does
391
+
392
+ Validates the shape, checks the sender **is a member of `room`**, spends the same
393
+ per-sender budget as the unicast path, and fans the message out to every other
394
+ member. It cannot verify the signature — it holds no signing keys — and does not
395
+ try.
396
+
397
+ Room membership is not a formality: without it one connection could inject into
398
+ every room on the hub at once, which the unicast path cannot do because it needs
399
+ a `sessionId` it could only have been told.
400
+
401
+ ---
402
+
403
+ ## 8. Offline delivery
404
+
405
+ `encrypted_message` addressed to a session that has just left is queued by
406
+ nickname + public key, for at most 1 hour, 100 per peer and 1000 in total. On
407
+ rejoin with the **same public key** the queue is delivered with `to` rewritten to
408
+ the new session. A different key drops the queue: it could not be opened anyway.
409
+
410
+ **`group_message` is never queued.** Not policy — arithmetic. A sender key handed
411
+ over on someone's return serialises the chain at its current counter, so the
412
+ backlog is unreadable to them whatever the relay does with it. Queueing would
413
+ store ciphertext on the relay that provably nobody can open: all of the storage
414
+ and the liability, none of the delivery.
415
+
416
+ ---
417
+
418
+ ## 9. Rooms
419
+
420
+ Room names are 1–30 characters, `^[a-zA-Z0-9_-]+$`, lowercased by the relay.
421
+ `general` always exists and can never be private.
422
+
423
+ | Message | Direction | Effect |
424
+ |---|---|---|
425
+ | `change_room` | c → r | Leave every room, enter one |
426
+ | `room_changed` | r → c | Confirmed, with `peers` and `private` |
427
+ | `join_room` | c → r | Enter an **additional** room |
428
+ | `room_joined` | r → c | Confirmed |
429
+ | `leave_room` / `room_left` | c ↔ r | Leave one; the last is refused |
430
+ | `list_rooms` / `room_list` | c ↔ r | `[{ name, memberCount, private }]` |
431
+
432
+ `change_room` and `join_room` carry an optional `roomAuthPk` — an Ed25519
433
+ verifier key, present only when **creating** a private room.
434
+
435
+ ### Private rooms, without the relay learning the password
436
+
437
+ ```
438
+ client relay
439
+ │ join_room(room) │
440
+ ├────────────────────────────────────────▶│
441
+ │ room_challenge(room, nonce) │
442
+ │◀────────────────────────────────────────┤
443
+ │ room_auth(room, nonce, signature) │
444
+ ├────────────────────────────────────────▶│ verify against stored roomAuthPk
445
+ │ room_joined(room, peers, private) │
446
+ │◀────────────────────────────────────────┤
447
+ ```
448
+
449
+ The password is stretched with Argon2id into a room key and an Ed25519 keypair.
450
+ Only the **verifier public key** is ever sent, at creation. Joining proves
451
+ knowledge by signing `(room, nonce, sessionId)` — binding to the session, so a
452
+ signature observed from another member cannot be replayed.
453
+
454
+ Challenges expire in 60 s. Five failures in 60 s on one connection stops further
455
+ attempts.
456
+
457
+ Room content gets a second encryption layer under the room key, **inside** the
458
+ transport layer: a message in a private room is room-encrypted and then sent
459
+ through the pairwise or group path as usual.
460
+
461
+ ---
462
+
463
+ ## 10. Moderation
464
+
465
+ `kick_peer`, `mute_peer` (with `durationMs`), `ban_peer` — owner only, with an
466
+ optional `room`. Reasons are truncated to 200 characters. The relay broadcasts
467
+ `peer_kicked` / `peer_muted` to the room. A muted session is refused for
468
+ `encrypted_message` and `group_message` alike.
469
+
470
+ A kick or a ban emits **`peer_kicked` and then `peer_left`**, in that order, for
471
+ the same session. `peer_kicked` carries an optional `sessionId` alongside the
472
+ nickname; a client that has it can match the two and report one event once,
473
+ while one that does not — every client before this — simply sees an ordinary
474
+ departure and a kick notice. Nicknames could not do this job: `/nick` reassigns
475
+ them, so unwinding a peer by name drops the wrong session as soon as two people
476
+ have ever shared one.
477
+
478
+ These are relay-enforced conveniences and nothing more. A relay that ignores them
479
+ breaks no cryptographic guarantee, which is why blocking is *also* implemented
480
+ client-side, where it cannot be overruled.
481
+
482
+ ---
483
+
484
+ ## 11. Errors
485
+
486
+ ```json
487
+ { "type": "error", "version": 2, "timestamp": 0, "code": "...", "message": "..." }
488
+ ```
489
+
490
+ | Code | Meaning |
491
+ |---|---|
492
+ | `NICKNAME_TAKEN` | Another live session holds it |
493
+ | `INVALID_MESSAGE` | Failed validation, or sent before `join` |
494
+ | `PEER_NOT_FOUND` | No such session, and nothing queued |
495
+ | `RATE_LIMITED` | Over a per-second budget |
496
+ | `PAYLOAD_TOO_LARGE` | Frame above `MAX_PAYLOAD_SIZE` |
497
+ | `ROOM_AUTH_FAILED` | Bad signature, expired challenge, too many attempts |
498
+ | `ROOM_EXISTS` | Cannot create; already there |
499
+
500
+ Error messages are for humans and must never quote the content that caused them.
501
+
502
+ ---
503
+
504
+ ## 12. Padding
505
+
506
+ Every plaintext is padded before encryption to the smallest bucket that fits:
507
+
508
+ ```
509
+ 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768
510
+ ```
511
+
512
+ Format: 2 bytes big-endian length, the plaintext, then random filler. A payload
513
+ larger than the biggest bucket is sent unpadded — file chunks are the reason —
514
+ and a single plaintext may not exceed 65 535 bytes.
515
+
516
+ This is what makes "the relay sees a bucketed size" true rather than a slogan.
517
+ It is asserted in `test/guarantees.test.js`.
518
+
519
+ ---
520
+
521
+ ## 13. What the relay learns
522
+
523
+ Stated plainly, because a specification that only lists the protections is
524
+ misleading.
525
+
526
+ **It sees:** who is connected and under what nickname and public key; which
527
+ rooms exist and who is in them; for a unicast message, the recipient; for a group
528
+ message, the room; the timing and bucketed size of everything; and, since a
529
+ connection is authenticated and persistent, which socket sent any given frame.
530
+
531
+ **It does not see:** any plaintext; any private-room password; the sender named
532
+ *in* a message; the contents of a sender-key distribution.
533
+
534
+ **Sealed sender is a guarantee against an honest-but-curious relay**, the same
535
+ one Signal makes. A malicious relay can correlate the sending socket to a
536
+ session — inherent to a persistent authenticated connection, and not something
537
+ this protocol claims to solve. P2P mode removes the relay entirely.
538
+
539
+ **Sender keys give confidentiality and per-member authenticity within a room, not
540
+ anonymity within it.** Members can tell each other apart, which is the point.
541
+
542
+ ---
543
+
544
+ ## 14. Conformance
545
+
546
+ An implementation claiming compatibility should:
547
+
548
+ - reproduce `test/vectors/sender-key.json` exactly
549
+ - send no `from` on any frame, ever
550
+ - reject a `join` whose `caps` is malformed rather than filtering it
551
+ - verify a group signature before advancing the chain
552
+ - treat an absent capability as an older peer, never as an error
553
+ - never queue a `group_message`
554
+
555
+ `test/guarantees.test.js` asserts the properties above that can be observed from
556
+ 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.12.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",
@@ -63,7 +63,7 @@
63
63
  "node-notifier": "10.0.1",
64
64
  "qrcode-terminal": "0.12.0",
65
65
  "sodium-native": "5.1.0",
66
- "ws": "8.21.1"
66
+ "ws": "8.21.3"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@eslint/js": "10.0.1",