ciphermesh 2.1.0 → 2.3.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.
@@ -21,6 +21,8 @@ import {
21
21
  createPeerLeft,
22
22
  createPeerKeyUpdated,
23
23
  createRoomChanged,
24
+ createRoomJoined,
25
+ createRoomLeft,
24
26
  createRoomChallenge,
25
27
  createRoomList,
26
28
  createPeerKicked,
@@ -34,6 +36,8 @@ import {
34
36
  validateEncryptedMessage,
35
37
  validateKeyUpdate,
36
38
  validateChangeRoom,
39
+ validateJoinRoom,
40
+ validateLeaveRoom,
37
41
  validateRoomAuth,
38
42
  validateKickPeer,
39
43
  validateMutePeer,
@@ -196,6 +200,14 @@ export class SecureWSServer {
196
200
  this.#handleChangeRoom(ws, msg);
197
201
  break;
198
202
 
203
+ case MSG.JOIN_ROOM:
204
+ this.#handleJoinRoom(ws, msg);
205
+ break;
206
+
207
+ case MSG.LEAVE_ROOM:
208
+ this.#handleLeaveRoom(ws, msg);
209
+ break;
210
+
199
211
  case MSG.ROOM_AUTH:
200
212
  this.#handleRoomAuth(ws, msg);
201
213
  break;
@@ -247,7 +259,13 @@ export class SecureWSServer {
247
259
  }
248
260
 
249
261
  const room = 'general';
250
- const sessionId = this.#sessionManager.addSession(ws, validation.nickname, msg.publicKey, room);
262
+ const sessionId = this.#sessionManager.addSession(
263
+ ws,
264
+ validation.nickname,
265
+ msg.publicKey,
266
+ room,
267
+ validation.pqPublicKey,
268
+ );
251
269
  ws.sessionId = sessionId;
252
270
  ws.hasJoined = true;
253
271
  clearTimeout(ws.joinTimer);
@@ -280,6 +298,7 @@ export class SecureWSServer {
280
298
  sessionId,
281
299
  nickname: validation.nickname,
282
300
  publicKey: msg.publicKey,
301
+ ...(validation.pqPublicKey ? { pqPublicKey: validation.pqPublicKey } : {}),
283
302
  }),
284
303
  sessionId,
285
304
  );
@@ -328,20 +347,11 @@ export class SecureWSServer {
328
347
 
329
348
  this.#sessionManager.updatePublicKey(ws.sessionId, msg.publicKey);
330
349
 
331
- // Broadcast new key to room peers
332
- const room = this.#sessionManager.getSessionRoom(ws.sessionId);
333
- if (room) {
334
- this.#sessionManager.broadcastToRoom(
335
- room,
336
- createPeerKeyUpdated(ws.sessionId, msg.publicKey),
337
- ws.sessionId,
338
- );
339
- } else {
340
- this.#sessionManager.broadcast(
341
- createPeerKeyUpdated(ws.sessionId, msg.publicKey),
342
- ws.sessionId,
343
- );
344
- }
350
+ // Broadcast the new key once to every peer sharing at least one room.
351
+ this.#sessionManager.broadcastToPeersOf(
352
+ ws.sessionId,
353
+ createPeerKeyUpdated(ws.sessionId, msg.publicKey),
354
+ );
345
355
 
346
356
  log.info(`${ws.sessionId.slice(0, 8)} rotated keys`);
347
357
  }
@@ -390,13 +400,7 @@ export class SecureWSServer {
390
400
  // Joining a private room: don't switch yet — issue a challenge the client
391
401
  // must sign with the password-derived key (see #handleRoomAuth).
392
402
  if (this.#sessionManager.isRoomPrivate(validation.room)) {
393
- const nonce = randomBytes(ROOM_CHALLENGE_NONCE_SIZE).toString('base64');
394
- ws.roomChallenge = {
395
- room: validation.room,
396
- nonce,
397
- expiresAt: Date.now() + ROOM_CHALLENGE_TTL_MS,
398
- };
399
- ws.send(JSON.stringify(createRoomChallenge(validation.room, nonce)));
403
+ this.#issueRoomChallenge(ws, validation.room, 'switch');
400
404
  return;
401
405
  }
402
406
 
@@ -410,6 +414,132 @@ export class SecureWSServer {
410
414
  this.#finishRoomSwitch(ws, session, result);
411
415
  }
412
416
 
417
+ // Multi-room: join an ADDITIONAL room, keeping current memberships.
418
+ #handleJoinRoom(ws, msg) {
419
+ if (!ws.hasJoined || !ws.sessionId) {
420
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
421
+ return;
422
+ }
423
+
424
+ const validation = validateJoinRoom(msg);
425
+ if (!validation.valid) {
426
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
427
+ return;
428
+ }
429
+
430
+ const session = this.#sessionManager.getSession(ws.sessionId);
431
+ if (this.#sessionManager.isBanned(validation.room, session.nickname)) {
432
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
433
+ return;
434
+ }
435
+
436
+ // Creating a private room additively.
437
+ if (validation.roomAuthPk) {
438
+ if (validation.room === 'general' || this.#sessionManager.roomHasMembers(validation.room)) {
439
+ ws.send(
440
+ JSON.stringify(
441
+ createError(ERR.ROOM_EXISTS, 'Room already exists — join it with /join instead'),
442
+ ),
443
+ );
444
+ return;
445
+ }
446
+ const result = this.#sessionManager.joinAdditional(ws.sessionId, validation.room);
447
+ if (!result) {
448
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
449
+ return;
450
+ }
451
+ this.#sessionManager.setRoomPrivate(validation.room, validation.roomAuthPk);
452
+ this.#finishRoomJoin(ws, session, validation.room);
453
+ return;
454
+ }
455
+
456
+ if (this.#sessionManager.isRoomPrivate(validation.room)) {
457
+ this.#issueRoomChallenge(ws, validation.room, 'join');
458
+ return;
459
+ }
460
+
461
+ const result = this.#sessionManager.joinAdditional(ws.sessionId, validation.room);
462
+ if (!result) {
463
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
464
+ return;
465
+ }
466
+ this.#finishRoomJoin(ws, session, validation.room);
467
+ }
468
+
469
+ // Multi-room: leave one room (never the last — a session is always somewhere).
470
+ #handleLeaveRoom(ws, msg) {
471
+ if (!ws.hasJoined || !ws.sessionId) {
472
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
473
+ return;
474
+ }
475
+
476
+ const validation = validateLeaveRoom(msg);
477
+ if (!validation.valid) {
478
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
479
+ return;
480
+ }
481
+
482
+ const session = this.#sessionManager.getSession(ws.sessionId);
483
+ const result = this.#sessionManager.leaveOneRoom(ws.sessionId, validation.room);
484
+ if (!result) {
485
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are not in this room')));
486
+ return;
487
+ }
488
+ if (result.lastRoom) {
489
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Cannot leave your last room')));
490
+ return;
491
+ }
492
+
493
+ this.#sessionManager.broadcastToRoom(
494
+ validation.room,
495
+ createPeerLeft(ws.sessionId, session.nickname, validation.room),
496
+ ws.sessionId,
497
+ );
498
+ ws.send(JSON.stringify(createRoomLeft(validation.room)));
499
+ log.info(`${session.nickname} left room ${validation.room}`);
500
+ }
501
+
502
+ #issueRoomChallenge(ws, room, mode) {
503
+ const nonce = randomBytes(ROOM_CHALLENGE_NONCE_SIZE).toString('base64');
504
+ ws.roomChallenge = {
505
+ room,
506
+ nonce,
507
+ mode, // 'switch' (change_room) or 'join' (additive join_room)
508
+ expiresAt: Date.now() + ROOM_CHALLENGE_TTL_MS,
509
+ };
510
+ ws.send(JSON.stringify(createRoomChallenge(room, nonce)));
511
+ }
512
+
513
+ // Shared tail of an additive join: notify the room and confirm to the joiner.
514
+ #finishRoomJoin(ws, session, room) {
515
+ this.#sessionManager.broadcastToRoom(
516
+ room,
517
+ createPeerJoined(
518
+ {
519
+ sessionId: ws.sessionId,
520
+ nickname: session.nickname,
521
+ publicKey: session.publicKey,
522
+ ...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
523
+ },
524
+ room,
525
+ ),
526
+ ws.sessionId,
527
+ );
528
+
529
+ const peers = this.#sessionManager.getRoomPeers(room, ws.sessionId);
530
+ const roomJoined = createRoomJoined(room, peers, this.#sessionManager.isRoomPrivate(room));
531
+ const ownerSid = this.#sessionManager.getRoomOwner(room);
532
+ if (ownerSid) {
533
+ const ownerSess = this.#sessionManager.getSession(ownerSid);
534
+ if (ownerSess) {
535
+ roomJoined.roomOwner = ownerSess.nickname;
536
+ }
537
+ }
538
+ ws.send(JSON.stringify(roomJoined));
539
+
540
+ log.info(`${session.nickname} joined room ${room} (additive)`);
541
+ }
542
+
413
543
  #handleRoomAuth(ws, msg) {
414
544
  if (!ws.hasJoined || !ws.sessionId) {
415
545
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
@@ -481,6 +611,17 @@ export class SecureWSServer {
481
611
  return;
482
612
  }
483
613
 
614
+ // Complete whichever action requested the challenge.
615
+ if (challenge.mode === 'join') {
616
+ const result = this.#sessionManager.joinAdditional(ws.sessionId, validation.room);
617
+ if (!result) {
618
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
619
+ return;
620
+ }
621
+ this.#finishRoomJoin(ws, session, validation.room);
622
+ return;
623
+ }
624
+
484
625
  const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
485
626
  if (!result) {
486
627
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
@@ -490,24 +631,33 @@ export class SecureWSServer {
490
631
  this.#finishRoomSwitch(ws, session, result);
491
632
  }
492
633
 
493
- // Shared tail of a successful room switch: notify both rooms and send the
494
- // ROOM_CHANGED (with the private flag) to the mover.
634
+ // Shared tail of a successful room switch: notify every old room and send
635
+ // the ROOM_CHANGED (with the private flag) to the mover.
495
636
  #finishRoomSwitch(ws, session, result) {
496
- // Notify old room that peer left
497
- this.#sessionManager.broadcastToRoom(
498
- result.oldRoom,
499
- createPeerLeft(ws.sessionId, session.nickname),
500
- ws.sessionId,
501
- );
637
+ // Notify each old room that the peer left it
638
+ for (const oldRoom of result.oldRooms || [result.oldRoom]) {
639
+ if (!oldRoom) {
640
+ continue;
641
+ }
642
+ this.#sessionManager.broadcastToRoom(
643
+ oldRoom,
644
+ createPeerLeft(ws.sessionId, session.nickname, oldRoom),
645
+ ws.sessionId,
646
+ );
647
+ }
502
648
 
503
649
  // Notify new room that peer joined
504
650
  this.#sessionManager.broadcastToRoom(
505
651
  result.newRoom,
506
- createPeerJoined({
507
- sessionId: ws.sessionId,
508
- nickname: session.nickname,
509
- publicKey: session.publicKey,
510
- }),
652
+ createPeerJoined(
653
+ {
654
+ sessionId: ws.sessionId,
655
+ nickname: session.nickname,
656
+ publicKey: session.publicKey,
657
+ ...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
658
+ },
659
+ result.newRoom,
660
+ ),
511
661
  ws.sessionId,
512
662
  );
513
663
 
@@ -540,6 +690,28 @@ export class SecureWSServer {
540
690
  ws.send(JSON.stringify(createRoomList(rooms)));
541
691
  }
542
692
 
693
+ // Which room a moderation command targets: the explicit `room` field (must
694
+ // be one the moderator is in), else the session's only room. Owners in
695
+ // several rooms must say which one.
696
+ #resolveModerationRoom(ws, explicitRoom, cmdName) {
697
+ if (explicitRoom) {
698
+ if (!this.#sessionManager.isInRoom(ws.sessionId, explicitRoom)) {
699
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are not in that room')));
700
+ return null;
701
+ }
702
+ return explicitRoom;
703
+ }
704
+ const only = this.#sessionManager.getSessionRoom(ws.sessionId);
705
+ if (!only) {
706
+ ws.send(
707
+ JSON.stringify(
708
+ createError(ERR.INVALID_MESSAGE, `You are in several rooms — specify one for ${cmdName}`),
709
+ ),
710
+ );
711
+ }
712
+ return only;
713
+ }
714
+
543
715
  #handleKickPeer(ws, msg) {
544
716
  if (!ws.hasJoined || !ws.sessionId) {
545
717
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
@@ -552,7 +724,10 @@ export class SecureWSServer {
552
724
  return;
553
725
  }
554
726
 
555
- const room = this.#sessionManager.getSessionRoom(ws.sessionId);
727
+ const room = this.#resolveModerationRoom(ws, validation.room, '/kick');
728
+ if (!room) {
729
+ return;
730
+ }
556
731
  if (!this.#sessionManager.isRoomOwner(room, ws.sessionId)) {
557
732
  ws.send(
558
733
  JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Only the room owner can use /kick')),
@@ -568,8 +743,7 @@ export class SecureWSServer {
568
743
  return;
569
744
  }
570
745
 
571
- const targetRoom = this.#sessionManager.getSessionRoom(targetSessionId);
572
- if (targetRoom !== room) {
746
+ if (!this.#sessionManager.isInRoom(targetSessionId, room)) {
573
747
  ws.send(
574
748
  JSON.stringify(
575
749
  createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" is not in this room`),
@@ -614,7 +788,10 @@ export class SecureWSServer {
614
788
  return;
615
789
  }
616
790
 
617
- const room = this.#sessionManager.getSessionRoom(ws.sessionId);
791
+ const room = this.#resolveModerationRoom(ws, validation.room, '/mute');
792
+ if (!room) {
793
+ return;
794
+ }
618
795
  if (!this.#sessionManager.isRoomOwner(room, ws.sessionId)) {
619
796
  ws.send(
620
797
  JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Only the room owner can use /mute')),
@@ -630,8 +807,7 @@ export class SecureWSServer {
630
807
  return;
631
808
  }
632
809
 
633
- const targetRoom = this.#sessionManager.getSessionRoom(targetSessionId);
634
- if (targetRoom !== room) {
810
+ if (!this.#sessionManager.isInRoom(targetSessionId, room)) {
635
811
  ws.send(
636
812
  JSON.stringify(
637
813
  createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" is not in this room`),
@@ -663,7 +839,10 @@ export class SecureWSServer {
663
839
  return;
664
840
  }
665
841
 
666
- const room = this.#sessionManager.getSessionRoom(ws.sessionId);
842
+ const room = this.#resolveModerationRoom(ws, validation.room, '/ban');
843
+ if (!room) {
844
+ return;
845
+ }
667
846
  if (!this.#sessionManager.isRoomOwner(room, ws.sessionId)) {
668
847
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Only the room owner can use /ban')));
669
848
  return;
@@ -677,8 +856,7 @@ export class SecureWSServer {
677
856
  return;
678
857
  }
679
858
 
680
- const targetRoom = this.#sessionManager.getSessionRoom(targetSessionId);
681
- if (targetRoom !== room) {
859
+ if (!this.#sessionManager.isInRoom(targetSessionId, room)) {
682
860
  ws.send(
683
861
  JSON.stringify(
684
862
  createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" is not in this room`),
@@ -716,21 +894,17 @@ export class SecureWSServer {
716
894
  return;
717
895
  }
718
896
 
719
- const room = this.#sessionManager.getSessionRoom(ws.sessionId);
897
+ const rooms = this.#sessionManager.getSessionRooms(ws.sessionId);
720
898
  const session = this.#sessionManager.removeSession(ws.sessionId);
721
899
  this.#messageRouter.cleanupSession(ws.sessionId);
722
900
 
723
901
  if (session) {
724
- // Broadcast to former room members only
725
- if (room) {
902
+ // One peer_left per former room, tagged, so multi-room clients drop the
903
+ // peer from exactly the right buffers.
904
+ for (const room of rooms) {
726
905
  this.#sessionManager.broadcastToRoom(
727
906
  room,
728
- createPeerLeft(ws.sessionId, session.nickname),
729
- ws.sessionId,
730
- );
731
- } else {
732
- this.#sessionManager.broadcast(
733
- createPeerLeft(ws.sessionId, session.nickname),
907
+ createPeerLeft(ws.sessionId, session.nickname, room),
734
908
  ws.sessionId,
735
909
  );
736
910
  }