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.
@@ -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')));
@@ -711,6 +776,27 @@ export class SecureWSServer {
711
776
  this.#finishRoomSwitch(ws, session, result);
712
777
  }
713
778
 
779
+ // One session stopped being in one room. Every way out of a room ends here:
780
+ // leaving, switching, disconnecting, and — since this was the gap — being
781
+ // kicked or banned.
782
+ //
783
+ // Kick and ban used to announce only `peer_kicked`, which carries a nickname
784
+ // and no session. Clients had nothing to remove a member *by*, so the removed
785
+ // peer stayed in every remaining roster. Harmless-looking while the relay path
786
+ // seals one envelope per peer; not harmless at all once a room encrypts once
787
+ // to a shared chain, because "who is still in this room" is then the input to
788
+ // when that chain must be rotated away from someone.
789
+ #emitRoomDeparture(room, sessionId, nickname) {
790
+ if (!room || !sessionId) {
791
+ return;
792
+ }
793
+ this.#sessionManager.broadcastToRoom(
794
+ room,
795
+ createPeerLeft(sessionId, nickname || 'Unknown', room),
796
+ sessionId,
797
+ );
798
+ }
799
+
714
800
  // Shared tail of a successful room switch: notify every old room and send
715
801
  // the ROOM_CHANGED (with the private flag) to the mover.
716
802
  #finishRoomSwitch(ws, session, result) {
@@ -837,11 +923,14 @@ export class SecureWSServer {
837
923
  if (result) {
838
924
  const targetSession = this.#sessionManager.getSession(targetSessionId);
839
925
 
840
- // Notify old room
926
+ // Notify old room. The kick goes first so a client can mark the session
927
+ // before the peer_left lands and report it as a kick rather than a
928
+ // departure; both travel the same socket, so the order holds.
841
929
  this.#sessionManager.broadcastToRoom(
842
930
  room,
843
- createPeerKicked(validation.targetNickname, validation.reason),
931
+ createPeerKicked(validation.targetNickname, validation.reason, targetSessionId),
844
932
  );
933
+ this.#emitRoomDeparture(room, targetSessionId, targetSession?.nickname);
845
934
 
846
935
  // Notify target with room change + kick reason
847
936
  const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
@@ -953,8 +1042,9 @@ export class SecureWSServer {
953
1042
  if (result) {
954
1043
  this.#sessionManager.broadcastToRoom(
955
1044
  room,
956
- createPeerKicked(validation.targetNickname, validation.reason || 'banned'),
1045
+ createPeerKicked(validation.targetNickname, validation.reason || 'banned', targetSessionId),
957
1046
  );
1047
+ this.#emitRoomDeparture(room, targetSessionId, targetSession?.nickname);
958
1048
 
959
1049
  const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
960
1050
  targetSession.ws.send(JSON.stringify(createRoomChanged('general', newPeers)));
@@ -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;