ciphermesh 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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),
@@ -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
  }
@@ -11,6 +11,7 @@ import {
11
11
  ROOM_CHALLENGE_TTL_MS,
12
12
  ROOM_AUTH_MAX_FAILS,
13
13
  ROOM_AUTH_FAIL_WINDOW_MS,
14
+ SERVER_CAPABILITIES,
14
15
  } from '../shared/constants.js';
15
16
  import {
16
17
  MSG,
@@ -32,6 +33,7 @@ import {
32
33
  parseMessage,
33
34
  validateJoin,
34
35
  validateEncryptedMessage,
36
+ validateGroupMessage,
35
37
  validateKeyUpdate,
36
38
  validateChangeRoom,
37
39
  validateJoinRoom,
@@ -230,6 +232,10 @@ export class SecureWSServer {
230
232
  this.#handleEncryptedMessage(ws, msg);
231
233
  break;
232
234
 
235
+ case MSG.GROUP_MESSAGE:
236
+ this.#handleGroupMessage(ws, msg);
237
+ break;
238
+
233
239
  case MSG.KEY_UPDATE:
234
240
  this.#handleKeyUpdate(ws, msg);
235
241
  break;
@@ -303,6 +309,7 @@ export class SecureWSServer {
303
309
  msg.publicKey,
304
310
  room,
305
311
  validation.pqPublicKey,
312
+ validation.capabilities,
306
313
  );
307
314
  ws.sessionId = sessionId;
308
315
  ws.hasJoined = true;
@@ -313,7 +320,7 @@ export class SecureWSServer {
313
320
 
314
321
  // Send ACK with peer list (room-scoped)
315
322
  const peers = this.#sessionManager.getRoomPeers(room, sessionId);
316
- const joinAck = createJoinAck(sessionId, peers, queued.length, room);
323
+ const joinAck = createJoinAck(sessionId, peers, queued.length, room, SERVER_CAPABILITIES);
317
324
  const ownerSid = this.#sessionManager.getRoomOwner(room);
318
325
  if (ownerSid) {
319
326
  const ownerSession = this.#sessionManager.getSession(ownerSid);
@@ -340,6 +347,7 @@ export class SecureWSServer {
340
347
  nickname: validation.nickname,
341
348
  publicKey: msg.publicKey,
342
349
  ...(validation.pqPublicKey ? { pqPublicKey: validation.pqPublicKey } : {}),
350
+ ...(validation.capabilities.length ? { caps: [...validation.capabilities] } : {}),
343
351
  }),
344
352
  sessionId,
345
353
  );
@@ -374,6 +382,63 @@ export class SecureWSServer {
374
382
  this.#messageRouter.route(ws.sessionId, msg);
375
383
  }
376
384
 
385
+ // One ciphertext in, one copy to each member of the room. The relay reads the
386
+ // room and nothing else: no `to` to route by, and no `from` to stamp on the
387
+ // way out — a room-addressed envelope must not become the one place the relay
388
+ // asserts who is speaking.
389
+ #handleGroupMessage(ws, msg) {
390
+ if (!ws.hasJoined || !ws.sessionId) {
391
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
392
+ return;
393
+ }
394
+
395
+ if (this.#sessionManager.isMuted(ws.sessionId)) {
396
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are muted')));
397
+ return;
398
+ }
399
+
400
+ const validation = validateGroupMessage(msg);
401
+ if (!validation.valid) {
402
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
403
+ return;
404
+ }
405
+
406
+ // A member may only address a room it is in. Without this, one connection
407
+ // could inject into every room on the hub at once — the per-peer path has no
408
+ // equivalent, because it needs a sessionId it could only have been told.
409
+ if (!this.#sessionManager.isInRoom(ws.sessionId, validation.room)) {
410
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Not in that room')));
411
+ return;
412
+ }
413
+
414
+ // Same per-sender budget as the unicast path. One frame in now costs the
415
+ // relay N frames out instead of one, so the limit that used to be applied
416
+ // N times per line is applied once — charging less here than there would
417
+ // turn the saving into an amplifier.
418
+ if (!this.#messageRouter.allowFrom(ws.sessionId)) {
419
+ ws.send(JSON.stringify(createError(ERR.RATE_LIMITED, 'Too many messages per second')));
420
+ return;
421
+ }
422
+
423
+ // Deliberately NOT queued for absent members, unlike the unicast path.
424
+ //
425
+ // A member who was away could not read it anyway: a sender key handed over
426
+ // on their return serialises the chain at its *current* counter, so anything
427
+ // sent while they were gone stays shut — the forward secrecy the ratchet
428
+ // buys, pinned in chat-controller.test.js. Queueing would therefore store
429
+ // ciphertext on the relay that provably nobody can open: all of the cost and
430
+ // the liability of holding it, and none of the delivery.
431
+ //
432
+ // The unicast queue survives because an envelope addressed to a peer is
433
+ // still openable when they come back with the same key. That is not true
434
+ // here, and it is the difference that decides it.
435
+ delete msg.from;
436
+ this.#sessionManager.broadcastToRoom(validation.room, msg, ws.sessionId);
437
+ // Never log the sender or the room membership this reveals — only that a
438
+ // fan-out happened. Correlating would defeat what sealed sender buys.
439
+ log.debug('group message fanned out');
440
+ }
441
+
377
442
  #handleKeyUpdate(ws, msg) {
378
443
  if (!ws.hasJoined || !ws.sessionId) {
379
444
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
@@ -1,5 +1,40 @@
1
1
  export const PROTOCOL_VERSION = 2; // v2: sealed sender (encrypted_message carries `sealed`, no `from`)
2
2
 
3
+ // ── Capability negotiation ─────────────────────────────────────
4
+ // PROTOCOL_VERSION is an exact-equality gate, so it cannot express "newer, but
5
+ // still able to talk to you". Capabilities fill that gap: a client lists what it
6
+ // can do in JOIN, the relay hands the list on verbatim with the peer list, and a
7
+ // feature turns on only when every member of the room advertises it. Absent or
8
+ // empty means an older peer, which is the safe default rather than an error.
9
+ export const CAP = {
10
+ // Group encryption on the relay path — one ciphertext for the whole room
11
+ // instead of one sealed envelope per peer. See
12
+ // docs/design/sender-keys-on-relay.md.
13
+ //
14
+ // From a client it means "I can *receive* a group message": I accept a sender
15
+ // key over the pairwise channel and can decrypt what that chain produces.
16
+ // From the relay it means "I can fan a room-addressed message out". Receive
17
+ // and fan-out land a release before anyone sends, so that by the time a sender
18
+ // exists, every advertised room can already read it.
19
+ SENDER_KEYS: 'sk1',
20
+ };
21
+
22
+ // What this client advertises. SENDER_KEYS is honest here: the receive path
23
+ // exists. Nothing sends group messages yet.
24
+ export const OWN_CAPABILITIES = [CAP.SENDER_KEYS];
25
+
26
+ // What the relay advertises, in join_ack. A client cannot promise this on the
27
+ // relay's behalf — the fan-out is the relay's job — so a sender has to check the
28
+ // room *and* the hub it is sitting on before switching paths.
29
+ export const SERVER_CAPABILITIES = [CAP.SENDER_KEYS];
30
+
31
+ // Bounds. This list arrives from a public hub, so it is attacker-controlled.
32
+ export const MAX_CAPABILITIES = 16;
33
+ export const MAX_CAPABILITY_LENGTH = 24;
34
+
35
+ // Ed25519 detached signature on a group message (src/crypto/SenderKey.js).
36
+ export const SIGNATURE_SIZE = 64;
37
+
3
38
  // Network
4
39
  export const SERVER_PORT = 3600;
5
40
  export const HEARTBEAT_INTERVAL_MS = 30_000;