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.
- package/CHANGELOG.md +85 -0
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +127 -4
- package/docs/PROTOCOL.md +486 -0
- package/docs/design/sender-keys-on-relay.md +118 -0
- package/package.json +1 -1
- package/src/client/ChatController.js +231 -58
- package/src/crypto/SenderKey.js +159 -3
- package/src/p2p/P2PChatController.js +2 -0
- package/src/protocol/capabilities.js +74 -0
- package/src/protocol/messages.js +39 -2
- package/src/protocol/validators.js +73 -1
- package/src/server/MessageRouter.js +9 -0
- package/src/server/SessionManager.js +3 -1
- package/src/server/WebSocketServer.js +66 -1
- package/src/shared/constants.js +35 -0
package/docs/PROTOCOL.md
ADDED
|
@@ -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