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.
- package/README.md +8 -2
- package/README.pt-BR.md +8 -2
- package/docs/ARCHITECTURE.md +4 -1
- package/package.json +3 -3
- package/src/client/ChatController.js +485 -139
- package/src/client/UI.js +135 -3
- package/src/protocol/messages.js +45 -4
- package/src/protocol/validators.js +40 -1
- package/src/server/SessionManager.js +98 -14
- package/src/server/WebSocketServer.js +213 -52
|
@@ -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
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
725
|
-
|
|
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
|
}
|