ciphermesh 2.1.0 → 2.2.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;
@@ -328,20 +340,11 @@ export class SecureWSServer {
328
340
 
329
341
  this.#sessionManager.updatePublicKey(ws.sessionId, msg.publicKey);
330
342
 
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
- }
343
+ // Broadcast the new key once to every peer sharing at least one room.
344
+ this.#sessionManager.broadcastToPeersOf(
345
+ ws.sessionId,
346
+ createPeerKeyUpdated(ws.sessionId, msg.publicKey),
347
+ );
345
348
 
346
349
  log.info(`${ws.sessionId.slice(0, 8)} rotated keys`);
347
350
  }
@@ -390,13 +393,7 @@ export class SecureWSServer {
390
393
  // Joining a private room: don't switch yet — issue a challenge the client
391
394
  // must sign with the password-derived key (see #handleRoomAuth).
392
395
  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)));
396
+ this.#issueRoomChallenge(ws, validation.room, 'switch');
400
397
  return;
401
398
  }
402
399
 
@@ -410,6 +407,127 @@ export class SecureWSServer {
410
407
  this.#finishRoomSwitch(ws, session, result);
411
408
  }
412
409
 
410
+ // Multi-room: join an ADDITIONAL room, keeping current memberships.
411
+ #handleJoinRoom(ws, msg) {
412
+ if (!ws.hasJoined || !ws.sessionId) {
413
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
414
+ return;
415
+ }
416
+
417
+ const validation = validateJoinRoom(msg);
418
+ if (!validation.valid) {
419
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
420
+ return;
421
+ }
422
+
423
+ const session = this.#sessionManager.getSession(ws.sessionId);
424
+ if (this.#sessionManager.isBanned(validation.room, session.nickname)) {
425
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
426
+ return;
427
+ }
428
+
429
+ // Creating a private room additively.
430
+ if (validation.roomAuthPk) {
431
+ if (validation.room === 'general' || this.#sessionManager.roomHasMembers(validation.room)) {
432
+ ws.send(
433
+ JSON.stringify(
434
+ createError(ERR.ROOM_EXISTS, 'Room already exists — join it with /join instead'),
435
+ ),
436
+ );
437
+ return;
438
+ }
439
+ const result = this.#sessionManager.joinAdditional(ws.sessionId, validation.room);
440
+ if (!result) {
441
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
442
+ return;
443
+ }
444
+ this.#sessionManager.setRoomPrivate(validation.room, validation.roomAuthPk);
445
+ this.#finishRoomJoin(ws, session, validation.room);
446
+ return;
447
+ }
448
+
449
+ if (this.#sessionManager.isRoomPrivate(validation.room)) {
450
+ this.#issueRoomChallenge(ws, validation.room, 'join');
451
+ return;
452
+ }
453
+
454
+ const result = this.#sessionManager.joinAdditional(ws.sessionId, validation.room);
455
+ if (!result) {
456
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
457
+ return;
458
+ }
459
+ this.#finishRoomJoin(ws, session, validation.room);
460
+ }
461
+
462
+ // Multi-room: leave one room (never the last — a session is always somewhere).
463
+ #handleLeaveRoom(ws, msg) {
464
+ if (!ws.hasJoined || !ws.sessionId) {
465
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
466
+ return;
467
+ }
468
+
469
+ const validation = validateLeaveRoom(msg);
470
+ if (!validation.valid) {
471
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
472
+ return;
473
+ }
474
+
475
+ const session = this.#sessionManager.getSession(ws.sessionId);
476
+ const result = this.#sessionManager.leaveOneRoom(ws.sessionId, validation.room);
477
+ if (!result) {
478
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are not in this room')));
479
+ return;
480
+ }
481
+ if (result.lastRoom) {
482
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Cannot leave your last room')));
483
+ return;
484
+ }
485
+
486
+ this.#sessionManager.broadcastToRoom(
487
+ validation.room,
488
+ createPeerLeft(ws.sessionId, session.nickname, validation.room),
489
+ ws.sessionId,
490
+ );
491
+ ws.send(JSON.stringify(createRoomLeft(validation.room)));
492
+ log.info(`${session.nickname} left room ${validation.room}`);
493
+ }
494
+
495
+ #issueRoomChallenge(ws, room, mode) {
496
+ const nonce = randomBytes(ROOM_CHALLENGE_NONCE_SIZE).toString('base64');
497
+ ws.roomChallenge = {
498
+ room,
499
+ nonce,
500
+ mode, // 'switch' (change_room) or 'join' (additive join_room)
501
+ expiresAt: Date.now() + ROOM_CHALLENGE_TTL_MS,
502
+ };
503
+ ws.send(JSON.stringify(createRoomChallenge(room, nonce)));
504
+ }
505
+
506
+ // Shared tail of an additive join: notify the room and confirm to the joiner.
507
+ #finishRoomJoin(ws, session, room) {
508
+ this.#sessionManager.broadcastToRoom(
509
+ room,
510
+ createPeerJoined(
511
+ { sessionId: ws.sessionId, nickname: session.nickname, publicKey: session.publicKey },
512
+ room,
513
+ ),
514
+ ws.sessionId,
515
+ );
516
+
517
+ const peers = this.#sessionManager.getRoomPeers(room, ws.sessionId);
518
+ const roomJoined = createRoomJoined(room, peers, this.#sessionManager.isRoomPrivate(room));
519
+ const ownerSid = this.#sessionManager.getRoomOwner(room);
520
+ if (ownerSid) {
521
+ const ownerSess = this.#sessionManager.getSession(ownerSid);
522
+ if (ownerSess) {
523
+ roomJoined.roomOwner = ownerSess.nickname;
524
+ }
525
+ }
526
+ ws.send(JSON.stringify(roomJoined));
527
+
528
+ log.info(`${session.nickname} joined room ${room} (additive)`);
529
+ }
530
+
413
531
  #handleRoomAuth(ws, msg) {
414
532
  if (!ws.hasJoined || !ws.sessionId) {
415
533
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
@@ -481,6 +599,17 @@ export class SecureWSServer {
481
599
  return;
482
600
  }
483
601
 
602
+ // Complete whichever action requested the challenge.
603
+ if (challenge.mode === 'join') {
604
+ const result = this.#sessionManager.joinAdditional(ws.sessionId, validation.room);
605
+ if (!result) {
606
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
607
+ return;
608
+ }
609
+ this.#finishRoomJoin(ws, session, validation.room);
610
+ return;
611
+ }
612
+
484
613
  const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
485
614
  if (!result) {
486
615
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
@@ -490,24 +619,32 @@ export class SecureWSServer {
490
619
  this.#finishRoomSwitch(ws, session, result);
491
620
  }
492
621
 
493
- // Shared tail of a successful room switch: notify both rooms and send the
494
- // ROOM_CHANGED (with the private flag) to the mover.
622
+ // Shared tail of a successful room switch: notify every old room and send
623
+ // the ROOM_CHANGED (with the private flag) to the mover.
495
624
  #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
- );
625
+ // Notify each old room that the peer left it
626
+ for (const oldRoom of result.oldRooms || [result.oldRoom]) {
627
+ if (!oldRoom) {
628
+ continue;
629
+ }
630
+ this.#sessionManager.broadcastToRoom(
631
+ oldRoom,
632
+ createPeerLeft(ws.sessionId, session.nickname, oldRoom),
633
+ ws.sessionId,
634
+ );
635
+ }
502
636
 
503
637
  // Notify new room that peer joined
504
638
  this.#sessionManager.broadcastToRoom(
505
639
  result.newRoom,
506
- createPeerJoined({
507
- sessionId: ws.sessionId,
508
- nickname: session.nickname,
509
- publicKey: session.publicKey,
510
- }),
640
+ createPeerJoined(
641
+ {
642
+ sessionId: ws.sessionId,
643
+ nickname: session.nickname,
644
+ publicKey: session.publicKey,
645
+ },
646
+ result.newRoom,
647
+ ),
511
648
  ws.sessionId,
512
649
  );
513
650
 
@@ -540,6 +677,28 @@ export class SecureWSServer {
540
677
  ws.send(JSON.stringify(createRoomList(rooms)));
541
678
  }
542
679
 
680
+ // Which room a moderation command targets: the explicit `room` field (must
681
+ // be one the moderator is in), else the session's only room. Owners in
682
+ // several rooms must say which one.
683
+ #resolveModerationRoom(ws, explicitRoom, cmdName) {
684
+ if (explicitRoom) {
685
+ if (!this.#sessionManager.isInRoom(ws.sessionId, explicitRoom)) {
686
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are not in that room')));
687
+ return null;
688
+ }
689
+ return explicitRoom;
690
+ }
691
+ const only = this.#sessionManager.getSessionRoom(ws.sessionId);
692
+ if (!only) {
693
+ ws.send(
694
+ JSON.stringify(
695
+ createError(ERR.INVALID_MESSAGE, `You are in several rooms — specify one for ${cmdName}`),
696
+ ),
697
+ );
698
+ }
699
+ return only;
700
+ }
701
+
543
702
  #handleKickPeer(ws, msg) {
544
703
  if (!ws.hasJoined || !ws.sessionId) {
545
704
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
@@ -552,7 +711,10 @@ export class SecureWSServer {
552
711
  return;
553
712
  }
554
713
 
555
- const room = this.#sessionManager.getSessionRoom(ws.sessionId);
714
+ const room = this.#resolveModerationRoom(ws, validation.room, '/kick');
715
+ if (!room) {
716
+ return;
717
+ }
556
718
  if (!this.#sessionManager.isRoomOwner(room, ws.sessionId)) {
557
719
  ws.send(
558
720
  JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Only the room owner can use /kick')),
@@ -568,8 +730,7 @@ export class SecureWSServer {
568
730
  return;
569
731
  }
570
732
 
571
- const targetRoom = this.#sessionManager.getSessionRoom(targetSessionId);
572
- if (targetRoom !== room) {
733
+ if (!this.#sessionManager.isInRoom(targetSessionId, room)) {
573
734
  ws.send(
574
735
  JSON.stringify(
575
736
  createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" is not in this room`),
@@ -614,7 +775,10 @@ export class SecureWSServer {
614
775
  return;
615
776
  }
616
777
 
617
- const room = this.#sessionManager.getSessionRoom(ws.sessionId);
778
+ const room = this.#resolveModerationRoom(ws, validation.room, '/mute');
779
+ if (!room) {
780
+ return;
781
+ }
618
782
  if (!this.#sessionManager.isRoomOwner(room, ws.sessionId)) {
619
783
  ws.send(
620
784
  JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Only the room owner can use /mute')),
@@ -630,8 +794,7 @@ export class SecureWSServer {
630
794
  return;
631
795
  }
632
796
 
633
- const targetRoom = this.#sessionManager.getSessionRoom(targetSessionId);
634
- if (targetRoom !== room) {
797
+ if (!this.#sessionManager.isInRoom(targetSessionId, room)) {
635
798
  ws.send(
636
799
  JSON.stringify(
637
800
  createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" is not in this room`),
@@ -663,7 +826,10 @@ export class SecureWSServer {
663
826
  return;
664
827
  }
665
828
 
666
- const room = this.#sessionManager.getSessionRoom(ws.sessionId);
829
+ const room = this.#resolveModerationRoom(ws, validation.room, '/ban');
830
+ if (!room) {
831
+ return;
832
+ }
667
833
  if (!this.#sessionManager.isRoomOwner(room, ws.sessionId)) {
668
834
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Only the room owner can use /ban')));
669
835
  return;
@@ -677,8 +843,7 @@ export class SecureWSServer {
677
843
  return;
678
844
  }
679
845
 
680
- const targetRoom = this.#sessionManager.getSessionRoom(targetSessionId);
681
- if (targetRoom !== room) {
846
+ if (!this.#sessionManager.isInRoom(targetSessionId, room)) {
682
847
  ws.send(
683
848
  JSON.stringify(
684
849
  createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" is not in this room`),
@@ -716,21 +881,17 @@ export class SecureWSServer {
716
881
  return;
717
882
  }
718
883
 
719
- const room = this.#sessionManager.getSessionRoom(ws.sessionId);
884
+ const rooms = this.#sessionManager.getSessionRooms(ws.sessionId);
720
885
  const session = this.#sessionManager.removeSession(ws.sessionId);
721
886
  this.#messageRouter.cleanupSession(ws.sessionId);
722
887
 
723
888
  if (session) {
724
- // Broadcast to former room members only
725
- if (room) {
889
+ // One peer_left per former room, tagged, so multi-room clients drop the
890
+ // peer from exactly the right buffers.
891
+ for (const room of rooms) {
726
892
  this.#sessionManager.broadcastToRoom(
727
893
  room,
728
- createPeerLeft(ws.sessionId, session.nickname),
729
- ws.sessionId,
730
- );
731
- } else {
732
- this.#sessionManager.broadcast(
733
- createPeerLeft(ws.sessionId, session.nickname),
894
+ createPeerLeft(ws.sessionId, session.nickname, room),
734
895
  ws.sessionId,
735
896
  );
736
897
  }