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.
- package/CHANGELOG.md +142 -0
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +127 -4
- package/docs/PROTOCOL.md +556 -0
- package/docs/design/sender-keys-on-relay.md +118 -0
- package/package.json +2 -2
- package/src/client/ChatController.js +461 -60
- package/src/crypto/SenderKey.js +201 -10
- package/src/p2p/P2PChatController.js +2 -0
- package/src/protocol/capabilities.js +74 -0
- package/src/protocol/messages.js +50 -4
- 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 +94 -4
- package/src/shared/constants.js +35 -0
package/src/crypto/SenderKey.js
CHANGED
|
@@ -14,6 +14,82 @@ const KEY_SIZE = 32;
|
|
|
14
14
|
const MSG_KEY_TAG = Buffer.from([0x01]);
|
|
15
15
|
const CHAIN_KEY_TAG = Buffer.from([0x02]);
|
|
16
16
|
const DEFAULT_MAX_SKIP = 1000; // bound out-of-order / skipped message keys
|
|
17
|
+
const KEY_ID_SIZE = 16;
|
|
18
|
+
|
|
19
|
+
// Release a key allocated with sodium_malloc.
|
|
20
|
+
//
|
|
21
|
+
// `sodium_memzero` alone is not enough, and the difference is not academic.
|
|
22
|
+
// sodium_malloc'd pages are **mlock'd**, and an operating system caps how much
|
|
23
|
+
// a process may lock at once — RLIMIT_MEMLOCK, which on Linux is small and on
|
|
24
|
+
// macOS is typically unlimited. Zeroing a key leaves its pages locked until the
|
|
25
|
+
// garbage collector happens to run the buffer's finaliser, so a process that
|
|
26
|
+
// derives keys faster than it collects them walks into an allocation failure it
|
|
27
|
+
// never sees coming: sodium_malloc returns NULL, and the crash lands on whatever
|
|
28
|
+
// unrelated call allocated next.
|
|
29
|
+
//
|
|
30
|
+
// sodium_free zeroes before releasing, so this is strictly stronger than the
|
|
31
|
+
// memzero it replaces. Null-safe and idempotent by convention: every caller
|
|
32
|
+
// drops its reference immediately after, so nothing can reach freed memory.
|
|
33
|
+
function freeKey(buf) {
|
|
34
|
+
if (buf) {
|
|
35
|
+
sodium.sodium_free(buf);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// An opaque label for a sender chain, handed out with the distribution.
|
|
40
|
+
//
|
|
41
|
+
// On the relay path a group message arrives by fan-out with no sender on it —
|
|
42
|
+
// the relay must not stamp one, or it would be asserting an identity that today
|
|
43
|
+
// is only ever carried sealed inside the envelope. So the packet names its
|
|
44
|
+
// *chain* instead of its sender, and only members who hold the distribution can
|
|
45
|
+
// map the two. To the relay it is a random string; it already knows which socket
|
|
46
|
+
// sent the frame, so this tells it nothing new. It is drawn fresh on every
|
|
47
|
+
// rotate(), so it never outlives the chain it labels.
|
|
48
|
+
function newKeyId() {
|
|
49
|
+
const buf = Buffer.alloc(KEY_ID_SIZE);
|
|
50
|
+
sodium.randombytes_buf(buf);
|
|
51
|
+
return buf.toString('base64');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Per-sender signatures ───────────────────────────────────────
|
|
55
|
+
// A sender key is symmetric: every member of the room holds the chain that
|
|
56
|
+
// decrypts a given sender, which means every member can also *produce*
|
|
57
|
+
// ciphertext on it. Without something asymmetric on top, "Alice said this" is
|
|
58
|
+
// only ever "somebody in this room said this" — and on a public relay, where
|
|
59
|
+
// `general` has no owner and no admission control, that is a much wider set of
|
|
60
|
+
// somebodies than in a P2P mesh.
|
|
61
|
+
//
|
|
62
|
+
// So each sender also holds an Ed25519 keypair for the life of its chain. The
|
|
63
|
+
// public half travels in the distribution, over the pairwise sealed channel that
|
|
64
|
+
// already authenticates who sent it; every packet carries a detached signature.
|
|
65
|
+
// Forging a member now needs their signing key, not just membership.
|
|
66
|
+
function newSigningKeypair() {
|
|
67
|
+
const publicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
|
|
68
|
+
const secretKey = sodium.sodium_malloc(sodium.crypto_sign_SECRETKEYBYTES);
|
|
69
|
+
sodium.crypto_sign_keypair(publicKey, secretKey);
|
|
70
|
+
return { publicKey, secretKey };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// What the signature covers. Every field the relay could tamper with or replay
|
|
74
|
+
// across chains: the label, the position in the chain, and the box itself.
|
|
75
|
+
// Length-prefixed so no two different packets can serialise to the same bytes.
|
|
76
|
+
function signedBytes({ keyId, counter, ciphertext, nonce }) {
|
|
77
|
+
const parts = [keyId, String(counter), ciphertext, nonce];
|
|
78
|
+
return Buffer.from(parts.map((p) => `${p.length}:${p}`).join('|'), 'utf-8');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function decodeSignPk(b64) {
|
|
82
|
+
if (typeof b64 !== 'string') {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
let buf;
|
|
86
|
+
try {
|
|
87
|
+
buf = Buffer.from(b64, 'base64');
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return buf.length === sodium.crypto_sign_PUBLICKEYBYTES ? buf : null;
|
|
92
|
+
}
|
|
17
93
|
|
|
18
94
|
// A single sender's ratchet chain. Used to *send* (deriveNext) when it's your
|
|
19
95
|
// own chain, or to *receive* (messageKeyFor) when it's a peer's distributed one.
|
|
@@ -41,8 +117,9 @@ export class SenderChain {
|
|
|
41
117
|
sodium.crypto_generichash(messageKey, MSG_KEY_TAG, this.#chainKey);
|
|
42
118
|
const nextChainKey = sodium.sodium_malloc(KEY_SIZE);
|
|
43
119
|
sodium.crypto_generichash(nextChainKey, CHAIN_KEY_TAG, this.#chainKey);
|
|
44
|
-
|
|
120
|
+
const spent = this.#chainKey;
|
|
45
121
|
this.#chainKey = nextChainKey;
|
|
122
|
+
freeKey(spent); // released, not merely zeroed — the hot path, once per message
|
|
46
123
|
return messageKey;
|
|
47
124
|
}
|
|
48
125
|
|
|
@@ -56,7 +133,8 @@ export class SenderChain {
|
|
|
56
133
|
|
|
57
134
|
// Receiving: the message key at `targetCounter`, caching skipped keys for
|
|
58
135
|
// out-of-order delivery. Returns null on replay (already consumed) or if the
|
|
59
|
-
// gap exceeds maxSkip. The caller
|
|
136
|
+
// gap exceeds maxSkip. The caller owns the returned key and must release it —
|
|
137
|
+
// groupDecrypt does, on every path including the failures.
|
|
60
138
|
messageKeyFor(targetCounter) {
|
|
61
139
|
if (this.#skipped.has(targetCounter)) {
|
|
62
140
|
const key = this.#skipped.get(targetCounter);
|
|
@@ -89,9 +167,10 @@ export class SenderChain {
|
|
|
89
167
|
}
|
|
90
168
|
|
|
91
169
|
destroy() {
|
|
92
|
-
|
|
170
|
+
freeKey(this.#chainKey);
|
|
171
|
+
this.#chainKey = null;
|
|
93
172
|
for (const key of this.#skipped.values()) {
|
|
94
|
-
|
|
173
|
+
freeKey(key);
|
|
95
174
|
}
|
|
96
175
|
this.#skipped.clear();
|
|
97
176
|
}
|
|
@@ -106,18 +185,18 @@ export function groupEncrypt(messageKey, plaintext) {
|
|
|
106
185
|
const ciphertext = Buffer.alloc(padded.length + sodium.crypto_secretbox_MACBYTES);
|
|
107
186
|
sodium.crypto_secretbox_easy(ciphertext, padded, nonce, messageKey);
|
|
108
187
|
sodium.sodium_memzero(padded);
|
|
109
|
-
|
|
188
|
+
freeKey(messageKey);
|
|
110
189
|
return { ciphertext, nonce };
|
|
111
190
|
}
|
|
112
191
|
|
|
113
192
|
export function groupDecrypt(messageKey, ciphertext, nonce) {
|
|
114
193
|
if (ciphertext.length < sodium.crypto_secretbox_MACBYTES) {
|
|
115
|
-
|
|
194
|
+
freeKey(messageKey);
|
|
116
195
|
return null;
|
|
117
196
|
}
|
|
118
197
|
const padded = Buffer.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES);
|
|
119
198
|
const ok = sodium.crypto_secretbox_open_easy(padded, ciphertext, nonce, messageKey);
|
|
120
|
-
|
|
199
|
+
freeKey(messageKey);
|
|
121
200
|
if (!ok) {
|
|
122
201
|
sodium.sodium_memzero(padded);
|
|
123
202
|
return null;
|
|
@@ -129,28 +208,51 @@ export function groupDecrypt(messageKey, ciphertext, nonce) {
|
|
|
129
208
|
// member. encrypt() runs once; every member decrypt()s the same ciphertext.
|
|
130
209
|
export class GroupSession {
|
|
131
210
|
#own;
|
|
211
|
+
#ownKeyId;
|
|
212
|
+
#signPk; // Ed25519 — proves *which member* wrote a packet
|
|
213
|
+
#signSk;
|
|
132
214
|
#members; // Map<memberId, SenderChain>
|
|
215
|
+
#byKeyId; // Map<keyId, memberId> — which chain opens an incoming packet
|
|
216
|
+
#memberSignPk; // Map<memberId, Buffer> — who is allowed to have written it
|
|
133
217
|
|
|
134
218
|
constructor() {
|
|
135
219
|
this.#own = new SenderChain();
|
|
220
|
+
this.#ownKeyId = newKeyId();
|
|
221
|
+
({ publicKey: this.#signPk, secretKey: this.#signSk } = newSigningKeypair());
|
|
136
222
|
this.#members = new Map();
|
|
223
|
+
this.#byKeyId = new Map();
|
|
224
|
+
this.#memberSignPk = new Map();
|
|
137
225
|
}
|
|
138
226
|
|
|
139
227
|
encrypt(plaintext) {
|
|
140
228
|
const { messageKey, counter } = this.#own.deriveNext();
|
|
141
229
|
const { ciphertext, nonce } = groupEncrypt(messageKey, plaintext);
|
|
142
|
-
|
|
230
|
+
const packet = {
|
|
231
|
+
keyId: this.#ownKeyId,
|
|
143
232
|
counter,
|
|
144
233
|
ciphertext: ciphertext.toString('base64'),
|
|
145
234
|
nonce: nonce.toString('base64'),
|
|
146
235
|
};
|
|
236
|
+
const signature = Buffer.alloc(sodium.crypto_sign_BYTES);
|
|
237
|
+
sodium.crypto_sign_detached(signature, signedBytes(packet), this.#signSk);
|
|
238
|
+
packet.signature = signature.toString('base64');
|
|
239
|
+
return packet;
|
|
147
240
|
}
|
|
148
241
|
|
|
149
|
-
decrypt(memberId, { counter, ciphertext, nonce }) {
|
|
242
|
+
decrypt(memberId, { keyId, counter, ciphertext, nonce, signature }) {
|
|
150
243
|
const chain = this.#members.get(memberId);
|
|
151
244
|
if (!chain) {
|
|
152
245
|
return null;
|
|
153
246
|
}
|
|
247
|
+
|
|
248
|
+
// Verify BEFORE touching the chain. Two reasons, and the second is the one
|
|
249
|
+
// that is easy to miss: a bad signature must not be able to advance the
|
|
250
|
+
// ratchet or fill the skipped-key cache, or an unauthenticated packet with a
|
|
251
|
+
// large counter becomes a way to make the receiver derive a thousand keys.
|
|
252
|
+
if (!this.#verify(memberId, { keyId, counter, ciphertext, nonce, signature })) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
154
256
|
const messageKey = chain.messageKeyFor(counter);
|
|
155
257
|
if (!messageKey) {
|
|
156
258
|
return null;
|
|
@@ -162,9 +264,51 @@ export class GroupSession {
|
|
|
162
264
|
);
|
|
163
265
|
}
|
|
164
266
|
|
|
267
|
+
#verify(memberId, packet) {
|
|
268
|
+
const signPk = this.#memberSignPk.get(memberId);
|
|
269
|
+
if (!signPk || typeof packet.signature !== 'string' || typeof packet.keyId !== 'string') {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
// The label on the packet has to be the one this member distributed.
|
|
273
|
+
// Verifying against the stored label instead would leave keyId outside
|
|
274
|
+
// everything that protects it — the AEAD does not cover it either — so
|
|
275
|
+
// decrypt() could be talked into checking one member's signature against
|
|
276
|
+
// another member's label.
|
|
277
|
+
if (this.#byKeyId.get(packet.keyId) !== memberId) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
let sig;
|
|
281
|
+
try {
|
|
282
|
+
sig = Buffer.from(packet.signature, 'base64');
|
|
283
|
+
} catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
if (sig.length !== sodium.crypto_sign_BYTES) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
return sodium.crypto_sign_verify_detached(sig, signedBytes(packet), signPk);
|
|
290
|
+
}
|
|
291
|
+
|
|
165
292
|
// The distribution message to hand a (new) member so they can decrypt you.
|
|
293
|
+
// `keyId` labels the chain; `signPk` is what stops any *other* member using
|
|
294
|
+
// that chain to write in your name.
|
|
166
295
|
distribution() {
|
|
167
|
-
return
|
|
296
|
+
return {
|
|
297
|
+
...this.#own.serialize(),
|
|
298
|
+
keyId: this.#ownKeyId,
|
|
299
|
+
signPk: this.#signPk.toString('base64'),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Which member does an incoming packet's keyId belong to? Null for a label we
|
|
304
|
+
// were never given a distribution for — an unknown sender, or one that
|
|
305
|
+
// rotated without telling us yet.
|
|
306
|
+
memberForKeyId(keyId) {
|
|
307
|
+
return this.#byKeyId.get(keyId) ?? null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
get keyId() {
|
|
311
|
+
return this.#ownKeyId;
|
|
168
312
|
}
|
|
169
313
|
|
|
170
314
|
addMember(memberId, distribution) {
|
|
@@ -172,15 +316,48 @@ export class GroupSession {
|
|
|
172
316
|
if (existing) {
|
|
173
317
|
existing.destroy();
|
|
174
318
|
}
|
|
319
|
+
// Drop any label this member held before — a redistribution after rotate()
|
|
320
|
+
// replaces the chain, and leaving the old keyId mapped would route the
|
|
321
|
+
// member's next packet to a chain that can no longer open it.
|
|
322
|
+
this.#forgetKeyIdsOf(memberId);
|
|
175
323
|
this.#members.set(memberId, SenderChain.deserialize(distribution));
|
|
324
|
+
if (typeof distribution?.keyId === 'string') {
|
|
325
|
+
this.#byKeyId.set(distribution.keyId, memberId);
|
|
326
|
+
}
|
|
327
|
+
// A distribution without a usable signing key leaves the member registered
|
|
328
|
+
// but unreadable: #verify fails closed, so nothing they send is accepted.
|
|
329
|
+
// Better a member who cannot be heard than one who cannot be attributed.
|
|
330
|
+
const signPk = decodeSignPk(distribution?.signPk);
|
|
331
|
+
if (signPk) {
|
|
332
|
+
this.#memberSignPk.set(memberId, signPk);
|
|
333
|
+
} else {
|
|
334
|
+
this.#memberSignPk.delete(memberId);
|
|
335
|
+
}
|
|
176
336
|
}
|
|
177
337
|
|
|
338
|
+
// Returns whether this actually removed a member. Callers rotate on a real
|
|
339
|
+
// membership change and must not rotate on a departure they have already
|
|
340
|
+
// handled: every rotation costs a redistribution to everyone remaining, and a
|
|
341
|
+
// redistribution that crosses an incoming message is a message the recipient
|
|
342
|
+
// has to buffer.
|
|
178
343
|
removeMember(memberId) {
|
|
179
344
|
const chain = this.#members.get(memberId);
|
|
345
|
+
const had = this.#members.has(memberId) || this.#memberSignPk.has(memberId);
|
|
180
346
|
if (chain) {
|
|
181
347
|
chain.destroy();
|
|
182
348
|
this.#members.delete(memberId);
|
|
183
349
|
}
|
|
350
|
+
this.#forgetKeyIdsOf(memberId);
|
|
351
|
+
this.#memberSignPk.delete(memberId);
|
|
352
|
+
return had;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
#forgetKeyIdsOf(memberId) {
|
|
356
|
+
for (const [id, owner] of this.#byKeyId) {
|
|
357
|
+
if (owner === memberId) {
|
|
358
|
+
this.#byKeyId.delete(id);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
184
361
|
}
|
|
185
362
|
|
|
186
363
|
hasMember(memberId) {
|
|
@@ -192,13 +369,27 @@ export class GroupSession {
|
|
|
192
369
|
rotate() {
|
|
193
370
|
this.#own.destroy();
|
|
194
371
|
this.#own = new SenderChain();
|
|
372
|
+
this.#ownKeyId = newKeyId();
|
|
373
|
+
// The signing key rotates with the chain it authenticates. Keeping it would
|
|
374
|
+
// let anyone holding the old public key keep attributing new packets to a
|
|
375
|
+
// chain that was rotated precisely because the room membership changed.
|
|
376
|
+
//
|
|
377
|
+
// Released rather than zeroed: rotation runs on every membership change, so
|
|
378
|
+
// a room with any churn would otherwise accumulate locked pages for the life
|
|
379
|
+
// of the session.
|
|
380
|
+
freeKey(this.#signSk);
|
|
381
|
+
({ publicKey: this.#signPk, secretKey: this.#signSk } = newSigningKeypair());
|
|
195
382
|
}
|
|
196
383
|
|
|
197
384
|
destroy() {
|
|
198
385
|
this.#own.destroy();
|
|
386
|
+
freeKey(this.#signSk);
|
|
387
|
+
this.#signSk = null;
|
|
199
388
|
for (const chain of this.#members.values()) {
|
|
200
389
|
chain.destroy();
|
|
201
390
|
}
|
|
202
391
|
this.#members.clear();
|
|
392
|
+
this.#byKeyId.clear();
|
|
393
|
+
this.#memberSignPk.clear();
|
|
203
394
|
}
|
|
204
395
|
}
|
|
@@ -2392,9 +2392,11 @@ export class P2PChatController {
|
|
|
2392
2392
|
return;
|
|
2393
2393
|
}
|
|
2394
2394
|
const plaintext = this.#getGroup(msg.room).decrypt(fromNickname, {
|
|
2395
|
+
keyId: msg.keyId,
|
|
2395
2396
|
counter: msg.counter,
|
|
2396
2397
|
ciphertext: msg.ciphertext,
|
|
2397
2398
|
nonce: msg.nonce,
|
|
2399
|
+
signature: msg.signature,
|
|
2398
2400
|
});
|
|
2399
2401
|
if (!plaintext) {
|
|
2400
2402
|
// No sender key yet (rare race) — buffer until sk_dist arrives.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { MAX_CAPABILITIES, MAX_CAPABILITY_LENGTH, OWN_CAPABILITIES } from '../shared/constants.js';
|
|
2
|
+
|
|
3
|
+
// ── Capability negotiation ─────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// `PROTOCOL_VERSION` is checked for exact equality, so it can only say "same" or
|
|
6
|
+
// "refuse to talk". It cannot express a client that is newer but still willing
|
|
7
|
+
// to speak the old way, which is precisely what rolling a protocol change
|
|
8
|
+
// through a public hub needs. Capabilities carry that: advertised in JOIN,
|
|
9
|
+
// relayed verbatim with the peer list, and read here.
|
|
10
|
+
//
|
|
11
|
+
// The rule for turning a feature on is deliberately the strict one — *every*
|
|
12
|
+
// member of the room must advertise it. One peer on an older build is enough to
|
|
13
|
+
// keep the whole room on the old path, which is the outcome that keeps a
|
|
14
|
+
// half-upgraded room readable.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Defensive read of a capability list that arrived over the wire.
|
|
18
|
+
*
|
|
19
|
+
* The relay validates what it accepts, but a client should not depend on the
|
|
20
|
+
* relay having done so — this list is used to decide how to encrypt.
|
|
21
|
+
*
|
|
22
|
+
* @returns {string[]} the valid entries, or an empty list
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeCaps(caps) {
|
|
25
|
+
if (!Array.isArray(caps)) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
const clean = [];
|
|
29
|
+
for (const cap of caps.slice(0, MAX_CAPABILITIES)) {
|
|
30
|
+
if (typeof cap !== 'string' || cap.length === 0 || cap.length > MAX_CAPABILITY_LENGTH) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(cap) || clean.includes(cap)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
clean.push(cap);
|
|
37
|
+
}
|
|
38
|
+
return clean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Does a single peer record advertise `cap`? */
|
|
42
|
+
export function peerSupports(peer, cap) {
|
|
43
|
+
return normalizeCaps(peer?.caps).includes(cap);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Can the whole room use `cap`?
|
|
48
|
+
*
|
|
49
|
+
* True only when this client advertises it and so does every peer. An empty
|
|
50
|
+
* room is vacuously true — there is nobody who could fail to understand.
|
|
51
|
+
*
|
|
52
|
+
* A hostile relay can edit these lists, so it is worth being explicit about what
|
|
53
|
+
* that buys it. Stripping capabilities forces the room back onto the per-peer
|
|
54
|
+
* path, which is the status quo and reveals nothing new. Adding a capability a
|
|
55
|
+
* peer never claimed makes senders encrypt in a form that peer cannot read —
|
|
56
|
+
* denial of service, visible immediately as a room that stopped working, and
|
|
57
|
+
* never a way to read plaintext. The strict-consensus rule is what keeps the
|
|
58
|
+
* damage on that side of the line.
|
|
59
|
+
*
|
|
60
|
+
* @param {Iterable<{caps?: string[]}>} peers - the room's peers, excluding self
|
|
61
|
+
* @param {string} cap
|
|
62
|
+
* @param {string[]} [own] - what this build advertises
|
|
63
|
+
*/
|
|
64
|
+
export function roomSupports(peers, cap, own = OWN_CAPABILITIES) {
|
|
65
|
+
if (!normalizeCaps(own).includes(cap)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
for (const peer of peers) {
|
|
69
|
+
if (!peerSupports(peer, cap)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
package/src/protocol/messages.js
CHANGED
|
@@ -7,6 +7,7 @@ export const MSG = {
|
|
|
7
7
|
PEER_JOINED: 'peer_joined',
|
|
8
8
|
PEER_LEFT: 'peer_left',
|
|
9
9
|
ENCRYPTED_MESSAGE: 'encrypted_message',
|
|
10
|
+
GROUP_MESSAGE: 'group_message',
|
|
10
11
|
ERROR: 'error',
|
|
11
12
|
KEY_UPDATE: 'key_update',
|
|
12
13
|
PEER_KEY_UPDATED: 'peer_key_updated',
|
|
@@ -47,19 +48,31 @@ function base(type) {
|
|
|
47
48
|
|
|
48
49
|
// pqPublicKey (v3, optional): ML-KEM-768 encapsulation key for the hybrid
|
|
49
50
|
// post-quantum handshake. Absent = classical-only peer (pre-2.3 client).
|
|
50
|
-
|
|
51
|
+
// caps (optional): what this client can do beyond the baseline protocol. Omitted
|
|
52
|
+
// when empty so the wire is byte-identical to a pre-capability client.
|
|
53
|
+
export function createJoin(nickname, publicKeyB64, pqPublicKeyB64 = null, caps = null) {
|
|
51
54
|
const msg = { ...base(MSG.JOIN), nickname, publicKey: publicKeyB64 };
|
|
52
55
|
if (pqPublicKeyB64) {
|
|
53
56
|
msg.pqPublicKey = pqPublicKeyB64;
|
|
54
57
|
}
|
|
58
|
+
if (Array.isArray(caps) && caps.length > 0) {
|
|
59
|
+
msg.caps = [...caps];
|
|
60
|
+
}
|
|
55
61
|
return msg;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
// serverCaps: what the relay itself can do, as opposed to what each peer can.
|
|
65
|
+
// A client cannot advertise on the relay's behalf, and some features (group
|
|
66
|
+
// fan-out) need the relay to play along. Omitted when empty so an ack from a
|
|
67
|
+
// pre-capability relay and one from a relay with nothing to offer look alike.
|
|
68
|
+
export function createJoinAck(sessionId, peers, queuedCount = 0, room = 'general', caps = null) {
|
|
59
69
|
const ack = { ...base(MSG.JOIN_ACK), sessionId, peers, room };
|
|
60
70
|
if (queuedCount > 0) {
|
|
61
71
|
ack.queuedCount = queuedCount;
|
|
62
72
|
}
|
|
73
|
+
if (Array.isArray(caps) && caps.length > 0) {
|
|
74
|
+
ack.serverCaps = [...caps];
|
|
75
|
+
}
|
|
63
76
|
return ack;
|
|
64
77
|
}
|
|
65
78
|
|
|
@@ -81,6 +94,30 @@ export function createPeerLeft(sessionId, nickname, room = null) {
|
|
|
81
94
|
return msg;
|
|
82
95
|
}
|
|
83
96
|
|
|
97
|
+
// Room-addressed group message. One ciphertext for the whole room, fanned out by
|
|
98
|
+
// the relay to the room's members.
|
|
99
|
+
//
|
|
100
|
+
// There is deliberately no `to` and no `from`. The relay routes on `room`, which
|
|
101
|
+
// it already knows the sender is in, and it must not stamp a sender on the way
|
|
102
|
+
// out — identity on this project's wire is only ever carried sealed. Recipients
|
|
103
|
+
// resolve `keyId` to a member through the sender key they were handed over the
|
|
104
|
+
// pairwise channel, so the label means something to members and nothing to the
|
|
105
|
+
// relay.
|
|
106
|
+
// `signature` is what makes the sender a *person* rather than just a member: the
|
|
107
|
+
// chain is symmetric, so without it any member of the room could write in
|
|
108
|
+
// anyone's name. See src/crypto/SenderKey.js.
|
|
109
|
+
export function createGroupMessage(room, packet) {
|
|
110
|
+
return {
|
|
111
|
+
...base(MSG.GROUP_MESSAGE),
|
|
112
|
+
room,
|
|
113
|
+
keyId: packet.keyId,
|
|
114
|
+
counter: packet.counter,
|
|
115
|
+
ciphertext: packet.ciphertext,
|
|
116
|
+
nonce: packet.nonce,
|
|
117
|
+
signature: packet.signature,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
84
121
|
export function createEncryptedMessage(from, to, ciphertextB64, nonceB64) {
|
|
85
122
|
return {
|
|
86
123
|
...base(MSG.ENCRYPTED_MESSAGE),
|
|
@@ -212,8 +249,17 @@ export function createBanPeer(targetNickname, reason = '') {
|
|
|
212
249
|
return { ...base(MSG.BAN_PEER), targetNickname, reason };
|
|
213
250
|
}
|
|
214
251
|
|
|
215
|
-
|
|
216
|
-
|
|
252
|
+
// `sessionId` (optional) names *which* session was removed. `nickname` cannot:
|
|
253
|
+
// it is not unique over time — /nick reassigns it — and a client that removed a
|
|
254
|
+
// peer by name would drop the wrong session whenever two people had ever shared
|
|
255
|
+
// one. Older clients ignore the field; it exists so a kick can be matched to the
|
|
256
|
+
// `peer_left` that follows it and reported as a kick rather than a departure.
|
|
257
|
+
export function createPeerKicked(nickname, reason = '', sessionId = null) {
|
|
258
|
+
const msg = { ...base(MSG.PEER_KICKED), nickname, reason };
|
|
259
|
+
if (sessionId) {
|
|
260
|
+
msg.sessionId = sessionId;
|
|
261
|
+
}
|
|
262
|
+
return msg;
|
|
217
263
|
}
|
|
218
264
|
|
|
219
265
|
export function createPeerMuted(nickname, durationMs) {
|
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
ROOM_AUTH_PK_SIZE,
|
|
7
7
|
ROOM_AUTH_SIG_SIZE,
|
|
8
8
|
ROOM_CHALLENGE_NONCE_SIZE,
|
|
9
|
+
MAX_CAPABILITIES,
|
|
10
|
+
MAX_CAPABILITY_LENGTH,
|
|
11
|
+
SIGNATURE_SIZE,
|
|
9
12
|
} from '../shared/constants.js';
|
|
10
13
|
import { PQ_PUBLIC_KEY_SIZE } from '../crypto/PQHybrid.js';
|
|
11
14
|
|
|
@@ -49,6 +52,34 @@ function sanitizeNickname(nick) {
|
|
|
49
52
|
return clean;
|
|
50
53
|
}
|
|
51
54
|
|
|
55
|
+
// Capability list off the wire. Absent is the normal case — every client before
|
|
56
|
+
// capabilities existed — and means "supports nothing extra", not "malformed".
|
|
57
|
+
// Anything present but misshapen is rejected rather than filtered: the relay
|
|
58
|
+
// stores this list and hands it to other clients, and quietly forwarding junk
|
|
59
|
+
// makes a peer look capable of something it never claimed.
|
|
60
|
+
// Returns an array on success, or null to signal rejection.
|
|
61
|
+
function sanitizeCapabilities(caps) {
|
|
62
|
+
if (caps === undefined || caps === null) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(caps) || caps.length > MAX_CAPABILITIES) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const clean = [];
|
|
69
|
+
for (const cap of caps) {
|
|
70
|
+
if (!isString(cap) || cap.length === 0 || cap.length > MAX_CAPABILITY_LENGTH) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(cap)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (!clean.includes(cap)) {
|
|
77
|
+
clean.push(cap);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return clean;
|
|
81
|
+
}
|
|
82
|
+
|
|
52
83
|
// ── Parse + validate incoming JSON ─────────────────────────────
|
|
53
84
|
export function parseMessage(raw) {
|
|
54
85
|
if (typeof raw === 'string' && raw.length > MAX_PAYLOAD_SIZE) {
|
|
@@ -100,7 +131,19 @@ export function validateJoin(msg) {
|
|
|
100
131
|
if (msg.pqPublicKey !== undefined && !isValidBase64(msg.pqPublicKey, PQ_PUBLIC_KEY_SIZE)) {
|
|
101
132
|
return { valid: false, error: 'Invalid post-quantum public key' };
|
|
102
133
|
}
|
|
103
|
-
|
|
134
|
+
const capabilities = sanitizeCapabilities(msg.caps);
|
|
135
|
+
if (capabilities === null) {
|
|
136
|
+
return {
|
|
137
|
+
valid: false,
|
|
138
|
+
error: `Invalid capability list (max ${MAX_CAPABILITIES} entries of up to ${MAX_CAPABILITY_LENGTH} lowercase chars)`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
valid: true,
|
|
143
|
+
nickname: nick,
|
|
144
|
+
pqPublicKey: msg.pqPublicKey || null,
|
|
145
|
+
capabilities,
|
|
146
|
+
};
|
|
104
147
|
}
|
|
105
148
|
|
|
106
149
|
// Sealed sender (protocol v2): the relay only ever sees the recipient and an
|
|
@@ -125,6 +168,35 @@ export function validateKeyUpdate(msg) {
|
|
|
125
168
|
return { valid: true };
|
|
126
169
|
}
|
|
127
170
|
|
|
171
|
+
// Room-addressed group message. Like validateEncryptedMessage, the relay cannot
|
|
172
|
+
// and must not inspect the content — it checks only what it needs to route:
|
|
173
|
+
// a well-formed room, an opaque chain label, a sane counter, and a non-empty
|
|
174
|
+
// envelope. Membership of `room` is the caller's check, not this one's.
|
|
175
|
+
export function validateGroupMessage(msg) {
|
|
176
|
+
if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
|
|
177
|
+
return { valid: false, error: 'Invalid room name (1-30 chars)' };
|
|
178
|
+
}
|
|
179
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(msg.room)) {
|
|
180
|
+
return { valid: false, error: 'Room name must be alphanumeric, dash or underscore' };
|
|
181
|
+
}
|
|
182
|
+
if (!isString(msg.keyId) || msg.keyId.length === 0 || msg.keyId.length > 64) {
|
|
183
|
+
return { valid: false, error: 'Invalid sender key id' };
|
|
184
|
+
}
|
|
185
|
+
if (!isNumber(msg.counter) || !Number.isInteger(msg.counter) || msg.counter < 0) {
|
|
186
|
+
return { valid: false, error: 'Invalid counter' };
|
|
187
|
+
}
|
|
188
|
+
if (!isValidBase64(msg.ciphertext) || !isValidBase64(msg.nonce)) {
|
|
189
|
+
return { valid: false, error: 'Missing or invalid group ciphertext' };
|
|
190
|
+
}
|
|
191
|
+
// Presence and size only. The relay holds no signing keys and could not
|
|
192
|
+
// verify this if it wanted to — that is the recipient's job, and the recipient
|
|
193
|
+
// does it before touching the ratchet.
|
|
194
|
+
if (!isValidBase64(msg.signature, SIGNATURE_SIZE)) {
|
|
195
|
+
return { valid: false, error: 'Missing or invalid group signature' };
|
|
196
|
+
}
|
|
197
|
+
return { valid: true, room: msg.room.toLowerCase() };
|
|
198
|
+
}
|
|
199
|
+
|
|
128
200
|
export function validateChangeRoom(msg) {
|
|
129
201
|
if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
|
|
130
202
|
return { valid: false, error: 'Invalid room name (1-30 chars)' };
|
|
@@ -59,6 +59,15 @@ export class MessageRouter {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Spend one unit of a sender's per-second budget without routing anything.
|
|
64
|
+
* For paths that deliver themselves (the room fan-out) but must not get a
|
|
65
|
+
* cheaper allowance than route() gives the unicast path.
|
|
66
|
+
*/
|
|
67
|
+
allowFrom(senderSessionId) {
|
|
68
|
+
return this.#checkRateLimit(senderSessionId);
|
|
69
|
+
}
|
|
70
|
+
|
|
62
71
|
#checkRateLimit(sessionId) {
|
|
63
72
|
const now = Date.now();
|
|
64
73
|
const entry = this.#rateCounts.get(sessionId);
|
|
@@ -30,13 +30,14 @@ export class SessionManager {
|
|
|
30
30
|
return this.#nicknames.has(nickname.toLowerCase());
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
addSession(ws, nickname, publicKey, room = 'general', pqPublicKey = null) {
|
|
33
|
+
addSession(ws, nickname, publicKey, room = 'general', pqPublicKey = null, capabilities = []) {
|
|
34
34
|
const sessionId = randomUUID();
|
|
35
35
|
const session = {
|
|
36
36
|
ws,
|
|
37
37
|
nickname,
|
|
38
38
|
publicKey,
|
|
39
39
|
pqPublicKey, // ML-KEM-768 key, relayed verbatim (server never uses it)
|
|
40
|
+
capabilities, // advertised in JOIN, relayed verbatim — the relay never acts on these
|
|
40
41
|
connectedAt: Date.now(),
|
|
41
42
|
rooms: new Set(),
|
|
42
43
|
};
|
|
@@ -104,6 +105,7 @@ export class SessionManager {
|
|
|
104
105
|
nickname: session.nickname,
|
|
105
106
|
publicKey: session.publicKey,
|
|
106
107
|
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
108
|
+
...(session.capabilities?.length ? { caps: [...session.capabilities] } : {}),
|
|
107
109
|
});
|
|
108
110
|
}
|
|
109
111
|
}
|