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.
@@ -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
- sodium.sodium_memzero(this.#chainKey);
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 must sodium_memzero the returned key.
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
- sodium.sodium_memzero(this.#chainKey);
170
+ freeKey(this.#chainKey);
171
+ this.#chainKey = null;
93
172
  for (const key of this.#skipped.values()) {
94
- sodium.sodium_memzero(key);
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
- sodium.sodium_memzero(messageKey);
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
- sodium.sodium_memzero(messageKey);
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
- sodium.sodium_memzero(messageKey);
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
- return {
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 this.#own.serialize();
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
+ }
@@ -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
- export function createJoin(nickname, publicKeyB64, pqPublicKeyB64 = null) {
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
- export function createJoinAck(sessionId, peers, queuedCount = 0, room = 'general') {
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
- export function createPeerKicked(nickname, reason = '') {
216
- return { ...base(MSG.PEER_KICKED), nickname, reason };
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
- return { valid: true, nickname: nick, pqPublicKey: msg.pqPublicKey || null };
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
  }