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.
- package/README.md +16 -2
- package/README.pt-BR.md +16 -2
- package/bin/server-binary.js +7 -0
- package/docs/ARCHITECTURE.md +40 -1
- package/package.json +4 -3
- package/src/client/ChatController.js +499 -145
- package/src/client/UI.js +135 -3
- package/src/crypto/DoubleRatchet.js +70 -3
- package/src/crypto/Handshake.js +44 -3
- package/src/crypto/KeyManager.js +27 -0
- package/src/crypto/PQHybrid.js +76 -0
- package/src/protocol/messages.js +66 -17
- package/src/protocol/validators.js +46 -2
- package/src/server/SessionManager.js +101 -15
- package/src/server/WebSocketServer.js +227 -53
|
@@ -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(
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
725
|
-
|
|
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
|
}
|