ciphermesh 2.0.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 +40 -7
- package/README.pt-BR.md +40 -7
- package/bin/ciphermesh.js +5 -0
- package/docs/ARCHITECTURE.md +44 -1
- package/docs/PLUGINS.md +69 -0
- package/examples/plugins/poll.js +28 -0
- package/examples/plugins/roll.js +24 -0
- package/package.json +4 -3
- package/src/client/ChatController.js +814 -92
- package/src/client/UI.js +262 -6
- package/src/client/index.js +50 -7
- package/src/crypto/RoomKey.js +162 -0
- package/src/crypto/TrustStore.js +47 -0
- package/src/p2p/P2PChatController.js +9 -1
- package/src/protocol/messages.js +76 -8
- package/src/protocol/validators.js +61 -1
- package/src/server/SessionManager.js +124 -16
- package/src/server/WebSocketServer.js +339 -41
- package/src/shared/AuditLog.js +3 -0
- package/src/shared/PluginManager.js +9 -6
- package/src/shared/config.js +27 -2
- package/src/shared/constants.js +8 -0
- package/src/shared/lastSession.js +55 -0
- package/src/shared/onboarding.js +111 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createServer as createHttpsServer } from 'node:https';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { WebSocketServer as WSServer } from 'ws';
|
|
3
4
|
import { createLogger } from '../shared/logger.js';
|
|
4
5
|
import {
|
|
@@ -8,6 +9,10 @@ import {
|
|
|
8
9
|
MAX_CONNECTIONS_PER_IP,
|
|
9
10
|
JOIN_TIMEOUT_MS,
|
|
10
11
|
MESSAGE_RATE_LIMIT_PER_SECOND,
|
|
12
|
+
ROOM_CHALLENGE_NONCE_SIZE,
|
|
13
|
+
ROOM_CHALLENGE_TTL_MS,
|
|
14
|
+
ROOM_AUTH_MAX_FAILS,
|
|
15
|
+
ROOM_AUTH_FAIL_WINDOW_MS,
|
|
11
16
|
} from '../shared/constants.js';
|
|
12
17
|
import {
|
|
13
18
|
MSG,
|
|
@@ -16,6 +21,9 @@ import {
|
|
|
16
21
|
createPeerLeft,
|
|
17
22
|
createPeerKeyUpdated,
|
|
18
23
|
createRoomChanged,
|
|
24
|
+
createRoomJoined,
|
|
25
|
+
createRoomLeft,
|
|
26
|
+
createRoomChallenge,
|
|
19
27
|
createRoomList,
|
|
20
28
|
createPeerKicked,
|
|
21
29
|
createPeerMuted,
|
|
@@ -28,10 +36,14 @@ import {
|
|
|
28
36
|
validateEncryptedMessage,
|
|
29
37
|
validateKeyUpdate,
|
|
30
38
|
validateChangeRoom,
|
|
39
|
+
validateJoinRoom,
|
|
40
|
+
validateLeaveRoom,
|
|
41
|
+
validateRoomAuth,
|
|
31
42
|
validateKickPeer,
|
|
32
43
|
validateMutePeer,
|
|
33
44
|
validateBanPeer,
|
|
34
45
|
} from '../protocol/validators.js';
|
|
46
|
+
import { verifyRoomChallenge } from '../crypto/RoomKey.js';
|
|
35
47
|
|
|
36
48
|
const log = createLogger('ws-server');
|
|
37
49
|
|
|
@@ -188,6 +200,18 @@ export class SecureWSServer {
|
|
|
188
200
|
this.#handleChangeRoom(ws, msg);
|
|
189
201
|
break;
|
|
190
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
|
+
|
|
211
|
+
case MSG.ROOM_AUTH:
|
|
212
|
+
this.#handleRoomAuth(ws, msg);
|
|
213
|
+
break;
|
|
214
|
+
|
|
191
215
|
case MSG.LIST_ROOMS:
|
|
192
216
|
this.#handleListRooms(ws);
|
|
193
217
|
break;
|
|
@@ -316,20 +340,11 @@ export class SecureWSServer {
|
|
|
316
340
|
|
|
317
341
|
this.#sessionManager.updatePublicKey(ws.sessionId, msg.publicKey);
|
|
318
342
|
|
|
319
|
-
// Broadcast new key to room
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
createPeerKeyUpdated(ws.sessionId, msg.publicKey),
|
|
325
|
-
ws.sessionId,
|
|
326
|
-
);
|
|
327
|
-
} else {
|
|
328
|
-
this.#sessionManager.broadcast(
|
|
329
|
-
createPeerKeyUpdated(ws.sessionId, msg.publicKey),
|
|
330
|
-
ws.sessionId,
|
|
331
|
-
);
|
|
332
|
-
}
|
|
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
|
+
);
|
|
333
348
|
|
|
334
349
|
log.info(`${ws.sessionId.slice(0, 8)} rotated keys`);
|
|
335
350
|
}
|
|
@@ -353,6 +368,35 @@ export class SecureWSServer {
|
|
|
353
368
|
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
|
|
354
369
|
return;
|
|
355
370
|
}
|
|
371
|
+
|
|
372
|
+
// Creating a private room: register the password verifier, but only for
|
|
373
|
+
// a room that doesn't exist yet (rooms die when the last member leaves).
|
|
374
|
+
if (validation.roomAuthPk) {
|
|
375
|
+
if (validation.room === 'general' || this.#sessionManager.roomHasMembers(validation.room)) {
|
|
376
|
+
ws.send(
|
|
377
|
+
JSON.stringify(
|
|
378
|
+
createError(ERR.ROOM_EXISTS, 'Room already exists — join it with /join instead'),
|
|
379
|
+
),
|
|
380
|
+
);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
|
|
384
|
+
if (!result) {
|
|
385
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.#sessionManager.setRoomPrivate(validation.room, validation.roomAuthPk);
|
|
389
|
+
this.#finishRoomSwitch(ws, session, result);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Joining a private room: don't switch yet — issue a challenge the client
|
|
394
|
+
// must sign with the password-derived key (see #handleRoomAuth).
|
|
395
|
+
if (this.#sessionManager.isRoomPrivate(validation.room)) {
|
|
396
|
+
this.#issueRoomChallenge(ws, validation.room, 'switch');
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
356
400
|
const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
|
|
357
401
|
if (!result) {
|
|
358
402
|
// Already in this room
|
|
@@ -360,27 +404,257 @@ export class SecureWSServer {
|
|
|
360
404
|
return;
|
|
361
405
|
}
|
|
362
406
|
|
|
363
|
-
|
|
407
|
+
this.#finishRoomSwitch(ws, session, result);
|
|
408
|
+
}
|
|
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) {
|
|
364
508
|
this.#sessionManager.broadcastToRoom(
|
|
365
|
-
|
|
366
|
-
|
|
509
|
+
room,
|
|
510
|
+
createPeerJoined(
|
|
511
|
+
{ sessionId: ws.sessionId, nickname: session.nickname, publicKey: session.publicKey },
|
|
512
|
+
room,
|
|
513
|
+
),
|
|
367
514
|
ws.sessionId,
|
|
368
515
|
);
|
|
369
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
|
+
|
|
531
|
+
#handleRoomAuth(ws, msg) {
|
|
532
|
+
if (!ws.hasJoined || !ws.sessionId) {
|
|
533
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const validation = validateRoomAuth(msg);
|
|
538
|
+
if (!validation.valid) {
|
|
539
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Throttle wrong-password attempts per connection.
|
|
544
|
+
const now = Date.now();
|
|
545
|
+
if (
|
|
546
|
+
!ws.roomAuthFailWindowStart ||
|
|
547
|
+
now - ws.roomAuthFailWindowStart >= ROOM_AUTH_FAIL_WINDOW_MS
|
|
548
|
+
) {
|
|
549
|
+
ws.roomAuthFailWindowStart = now;
|
|
550
|
+
ws.roomAuthFails = 0;
|
|
551
|
+
}
|
|
552
|
+
if (ws.roomAuthFails >= ROOM_AUTH_MAX_FAILS) {
|
|
553
|
+
ws.send(
|
|
554
|
+
JSON.stringify(createError(ERR.RATE_LIMITED, 'Too many failed attempts — wait a minute')),
|
|
555
|
+
);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const challenge = ws.roomChallenge;
|
|
560
|
+
if (
|
|
561
|
+
!challenge ||
|
|
562
|
+
challenge.room !== validation.room ||
|
|
563
|
+
challenge.nonce !== validation.nonce ||
|
|
564
|
+
now > challenge.expiresAt
|
|
565
|
+
) {
|
|
566
|
+
ws.send(JSON.stringify(createError(ERR.ROOM_AUTH_FAILED, 'Challenge expired — /join again')));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
ws.roomChallenge = null;
|
|
570
|
+
|
|
571
|
+
const authPkB64 = this.#sessionManager.getRoomAuthPk(validation.room);
|
|
572
|
+
if (!authPkB64) {
|
|
573
|
+
// Room emptied (and died) between challenge and answer.
|
|
574
|
+
ws.send(
|
|
575
|
+
JSON.stringify(
|
|
576
|
+
createError(ERR.ROOM_AUTH_FAILED, 'Room no longer exists — /join again to create it'),
|
|
577
|
+
),
|
|
578
|
+
);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const ok = verifyRoomChallenge(
|
|
583
|
+
Buffer.from(authPkB64, 'base64'),
|
|
584
|
+
Buffer.from(validation.signature, 'base64'),
|
|
585
|
+
validation.room,
|
|
586
|
+
validation.nonce,
|
|
587
|
+
ws.sessionId,
|
|
588
|
+
);
|
|
589
|
+
if (!ok) {
|
|
590
|
+
ws.roomAuthFails++;
|
|
591
|
+
ws.send(JSON.stringify(createError(ERR.ROOM_AUTH_FAILED, 'Wrong room password')));
|
|
592
|
+
log.warn(`Failed room auth for ${validation.room} (${ws.sessionId.slice(0, 8)})`);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const session = this.#sessionManager.getSession(ws.sessionId);
|
|
597
|
+
if (this.#sessionManager.isBanned(validation.room, session.nickname)) {
|
|
598
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
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
|
+
|
|
613
|
+
const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
|
|
614
|
+
if (!result) {
|
|
615
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
this.#finishRoomSwitch(ws, session, result);
|
|
620
|
+
}
|
|
621
|
+
|
|
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.
|
|
624
|
+
#finishRoomSwitch(ws, session, result) {
|
|
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
|
+
}
|
|
636
|
+
|
|
370
637
|
// Notify new room that peer joined
|
|
371
638
|
this.#sessionManager.broadcastToRoom(
|
|
372
639
|
result.newRoom,
|
|
373
|
-
createPeerJoined(
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
640
|
+
createPeerJoined(
|
|
641
|
+
{
|
|
642
|
+
sessionId: ws.sessionId,
|
|
643
|
+
nickname: session.nickname,
|
|
644
|
+
publicKey: session.publicKey,
|
|
645
|
+
},
|
|
646
|
+
result.newRoom,
|
|
647
|
+
),
|
|
378
648
|
ws.sessionId,
|
|
379
649
|
);
|
|
380
650
|
|
|
381
651
|
// Send new room info to the client
|
|
382
652
|
const newPeers = this.#sessionManager.getRoomPeers(result.newRoom, ws.sessionId);
|
|
383
|
-
const roomChanged = createRoomChanged(
|
|
653
|
+
const roomChanged = createRoomChanged(
|
|
654
|
+
result.newRoom,
|
|
655
|
+
newPeers,
|
|
656
|
+
this.#sessionManager.isRoomPrivate(result.newRoom),
|
|
657
|
+
);
|
|
384
658
|
const newOwnerSid = this.#sessionManager.getRoomOwner(result.newRoom);
|
|
385
659
|
if (newOwnerSid) {
|
|
386
660
|
const ownerSess = this.#sessionManager.getSession(newOwnerSid);
|
|
@@ -403,6 +677,28 @@ export class SecureWSServer {
|
|
|
403
677
|
ws.send(JSON.stringify(createRoomList(rooms)));
|
|
404
678
|
}
|
|
405
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
|
+
|
|
406
702
|
#handleKickPeer(ws, msg) {
|
|
407
703
|
if (!ws.hasJoined || !ws.sessionId) {
|
|
408
704
|
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
|
|
@@ -415,7 +711,10 @@ export class SecureWSServer {
|
|
|
415
711
|
return;
|
|
416
712
|
}
|
|
417
713
|
|
|
418
|
-
const room = this.#
|
|
714
|
+
const room = this.#resolveModerationRoom(ws, validation.room, '/kick');
|
|
715
|
+
if (!room) {
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
419
718
|
if (!this.#sessionManager.isRoomOwner(room, ws.sessionId)) {
|
|
420
719
|
ws.send(
|
|
421
720
|
JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Only the room owner can use /kick')),
|
|
@@ -431,8 +730,7 @@ export class SecureWSServer {
|
|
|
431
730
|
return;
|
|
432
731
|
}
|
|
433
732
|
|
|
434
|
-
|
|
435
|
-
if (targetRoom !== room) {
|
|
733
|
+
if (!this.#sessionManager.isInRoom(targetSessionId, room)) {
|
|
436
734
|
ws.send(
|
|
437
735
|
JSON.stringify(
|
|
438
736
|
createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" is not in this room`),
|
|
@@ -477,7 +775,10 @@ export class SecureWSServer {
|
|
|
477
775
|
return;
|
|
478
776
|
}
|
|
479
777
|
|
|
480
|
-
const room = this.#
|
|
778
|
+
const room = this.#resolveModerationRoom(ws, validation.room, '/mute');
|
|
779
|
+
if (!room) {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
481
782
|
if (!this.#sessionManager.isRoomOwner(room, ws.sessionId)) {
|
|
482
783
|
ws.send(
|
|
483
784
|
JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Only the room owner can use /mute')),
|
|
@@ -493,8 +794,7 @@ export class SecureWSServer {
|
|
|
493
794
|
return;
|
|
494
795
|
}
|
|
495
796
|
|
|
496
|
-
|
|
497
|
-
if (targetRoom !== room) {
|
|
797
|
+
if (!this.#sessionManager.isInRoom(targetSessionId, room)) {
|
|
498
798
|
ws.send(
|
|
499
799
|
JSON.stringify(
|
|
500
800
|
createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" is not in this room`),
|
|
@@ -526,7 +826,10 @@ export class SecureWSServer {
|
|
|
526
826
|
return;
|
|
527
827
|
}
|
|
528
828
|
|
|
529
|
-
const room = this.#
|
|
829
|
+
const room = this.#resolveModerationRoom(ws, validation.room, '/ban');
|
|
830
|
+
if (!room) {
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
530
833
|
if (!this.#sessionManager.isRoomOwner(room, ws.sessionId)) {
|
|
531
834
|
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Only the room owner can use /ban')));
|
|
532
835
|
return;
|
|
@@ -540,8 +843,7 @@ export class SecureWSServer {
|
|
|
540
843
|
return;
|
|
541
844
|
}
|
|
542
845
|
|
|
543
|
-
|
|
544
|
-
if (targetRoom !== room) {
|
|
846
|
+
if (!this.#sessionManager.isInRoom(targetSessionId, room)) {
|
|
545
847
|
ws.send(
|
|
546
848
|
JSON.stringify(
|
|
547
849
|
createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" is not in this room`),
|
|
@@ -579,21 +881,17 @@ export class SecureWSServer {
|
|
|
579
881
|
return;
|
|
580
882
|
}
|
|
581
883
|
|
|
582
|
-
const
|
|
884
|
+
const rooms = this.#sessionManager.getSessionRooms(ws.sessionId);
|
|
583
885
|
const session = this.#sessionManager.removeSession(ws.sessionId);
|
|
584
886
|
this.#messageRouter.cleanupSession(ws.sessionId);
|
|
585
887
|
|
|
586
888
|
if (session) {
|
|
587
|
-
//
|
|
588
|
-
|
|
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) {
|
|
589
892
|
this.#sessionManager.broadcastToRoom(
|
|
590
893
|
room,
|
|
591
|
-
createPeerLeft(ws.sessionId, session.nickname),
|
|
592
|
-
ws.sessionId,
|
|
593
|
-
);
|
|
594
|
-
} else {
|
|
595
|
-
this.#sessionManager.broadcast(
|
|
596
|
-
createPeerLeft(ws.sessionId, session.nickname),
|
|
894
|
+
createPeerLeft(ws.sessionId, session.nickname, room),
|
|
597
895
|
ws.sessionId,
|
|
598
896
|
);
|
|
599
897
|
}
|
package/src/shared/AuditLog.js
CHANGED
|
@@ -26,6 +26,9 @@ export const AuditEvent = {
|
|
|
26
26
|
ADMIN_KICK: 'ADMIN_KICK',
|
|
27
27
|
ADMIN_MUTE: 'ADMIN_MUTE',
|
|
28
28
|
ADMIN_BAN: 'ADMIN_BAN',
|
|
29
|
+
SCREEN_LOCKED: 'SCREEN_LOCKED',
|
|
30
|
+
SCREEN_UNLOCKED: 'SCREEN_UNLOCKED',
|
|
31
|
+
SCREEN_UNLOCK_FAILED: 'SCREEN_UNLOCK_FAILED',
|
|
29
32
|
};
|
|
30
33
|
|
|
31
34
|
export class AuditLog {
|
|
@@ -4,7 +4,10 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Resolved lazily so tests (and future flags) can point HOME elsewhere.
|
|
8
|
+
export function pluginDir() {
|
|
9
|
+
return join(homedir(), '.ciphermesh', 'plugins');
|
|
10
|
+
}
|
|
8
11
|
|
|
9
12
|
export class PluginManager {
|
|
10
13
|
#plugins; // Map<name, module>
|
|
@@ -15,15 +18,15 @@ export class PluginManager {
|
|
|
15
18
|
this.#commands = new Map();
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
async loadAll() {
|
|
19
|
-
if (!existsSync(
|
|
20
|
-
mkdirSync(
|
|
21
|
+
async loadAll(dir = pluginDir()) {
|
|
22
|
+
if (!existsSync(dir)) {
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
21
24
|
return;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
let files;
|
|
25
28
|
try {
|
|
26
|
-
files = await readdir(
|
|
29
|
+
files = await readdir(dir);
|
|
27
30
|
} catch {
|
|
28
31
|
return;
|
|
29
32
|
}
|
|
@@ -32,7 +35,7 @@ export class PluginManager {
|
|
|
32
35
|
|
|
33
36
|
for (const file of jsFiles) {
|
|
34
37
|
try {
|
|
35
|
-
const filePath = join(
|
|
38
|
+
const filePath = join(dir, file);
|
|
36
39
|
const fileUrl = pathToFileURL(filePath).href;
|
|
37
40
|
const mod = await import(fileUrl);
|
|
38
41
|
const plugin = mod.default || mod;
|
package/src/shared/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
4
|
|
|
5
5
|
// Optional user config at ~/.ciphermesh/config.json. Everything is a default the
|
|
6
6
|
// user can still override at the prompt or with a slash-command. Unknown keys
|
|
@@ -15,6 +15,7 @@ const ALLOWED = [
|
|
|
15
15
|
'deniable',
|
|
16
16
|
'theme',
|
|
17
17
|
'autoAway',
|
|
18
|
+
'autoLock',
|
|
18
19
|
'dnd',
|
|
19
20
|
];
|
|
20
21
|
|
|
@@ -51,6 +52,27 @@ export function loadConfig(path = configPath()) {
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/** True if a config file already exists (used to detect the first run). */
|
|
56
|
+
export function hasConfigFile(path = configPath()) {
|
|
57
|
+
return existsSync(path);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Persist config (whitelisted keys only, merged over what's on disk so a
|
|
62
|
+
* partial save never wipes hand-edited settings). Returns what was written.
|
|
63
|
+
*/
|
|
64
|
+
export function saveConfig(cfg, path = configPath()) {
|
|
65
|
+
const merged = { ...loadConfig(path) };
|
|
66
|
+
for (const k of ALLOWED) {
|
|
67
|
+
if (cfg[k] !== undefined) {
|
|
68
|
+
merged[k] = cfg[k];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
72
|
+
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
|
|
73
|
+
return merged;
|
|
74
|
+
}
|
|
75
|
+
|
|
54
76
|
/**
|
|
55
77
|
* Translate config toggles into the slash-commands that apply them, so startup
|
|
56
78
|
* reuses the exact command handlers (no duplicated logic). Pure — testable.
|
|
@@ -81,6 +103,9 @@ export function startupCommands(config) {
|
|
|
81
103
|
if (Number.isInteger(config.autoAway) && config.autoAway > 0) {
|
|
82
104
|
cmds.push(`/autoaway ${config.autoAway}`);
|
|
83
105
|
}
|
|
106
|
+
if (Number.isInteger(config.autoLock) && config.autoLock > 0) {
|
|
107
|
+
cmds.push(`/autolock ${config.autoLock}`);
|
|
108
|
+
}
|
|
84
109
|
if (config.dnd === 'on' || config.dnd === 'mentions') {
|
|
85
110
|
cmds.push(`/dnd ${config.dnd}`);
|
|
86
111
|
} else if (typeof config.dnd === 'string' && /^\d/.test(config.dnd)) {
|
package/src/shared/constants.js
CHANGED
|
@@ -41,6 +41,14 @@ export const OFFLINE_QUEUE_MAX_PER_PEER = 100;
|
|
|
41
41
|
export const OFFLINE_QUEUE_MAX_AGE_MS = 3_600_000; // 1h
|
|
42
42
|
export const OFFLINE_QUEUE_MAX_TOTAL = 1000;
|
|
43
43
|
|
|
44
|
+
// Private rooms (password-protected, zero-knowledge)
|
|
45
|
+
export const ROOM_AUTH_PK_SIZE = 32; // Ed25519 verifier public key
|
|
46
|
+
export const ROOM_AUTH_SIG_SIZE = 64; // Ed25519 detached signature
|
|
47
|
+
export const ROOM_CHALLENGE_NONCE_SIZE = 24;
|
|
48
|
+
export const ROOM_CHALLENGE_TTL_MS = 60_000; // challenge must be answered within this
|
|
49
|
+
export const ROOM_AUTH_MAX_FAILS = 5; // wrong-password attempts per connection…
|
|
50
|
+
export const ROOM_AUTH_FAIL_WINDOW_MS = 60_000; // …within this window
|
|
51
|
+
|
|
44
52
|
// Message padding (anti-metadata): every ciphertext is padded up to one of
|
|
45
53
|
// these bucket sizes so the relay can't read the true plaintext length.
|
|
46
54
|
export const MESSAGE_PAD_BUCKETS = [128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Remembers where the user was (server + room) so the next launch can offer to
|
|
2
|
+
// reconnect. Deliberately tiny and best-effort: losing this file only costs a
|
|
3
|
+
// couple of keystrokes. Privacy: callers must NOT pass the room of a private
|
|
4
|
+
// room — its name never touches disk (see ChatController#saveLastSession).
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export function lastSessionPath() {
|
|
10
|
+
return join(homedir(), '.ciphermesh', 'last-session.json');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Load { server, room?, at } or null when missing/corrupt. */
|
|
14
|
+
export function loadLastSession(path = lastSessionPath()) {
|
|
15
|
+
try {
|
|
16
|
+
const obj = JSON.parse(readFileSync(path, 'utf-8'));
|
|
17
|
+
if (obj === null || typeof obj !== 'object' || typeof obj.server !== 'string') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const out = { server: obj.server, at: Number(obj.at) || 0 };
|
|
21
|
+
if (typeof obj.room === 'string' && /^[a-zA-Z0-9_-]{1,30}$/.test(obj.room)) {
|
|
22
|
+
out.room = obj.room;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Persist { server, room? }. Room is optional on purpose (private rooms). */
|
|
31
|
+
export function saveLastSession({ server, room }, path = lastSessionPath()) {
|
|
32
|
+
if (typeof server !== 'string' || !server) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const data = { server, at: Date.now() };
|
|
36
|
+
if (typeof room === 'string' && room) {
|
|
37
|
+
data.room = room;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
41
|
+
writeFileSync(path, `${JSON.stringify(data)}\n`, { mode: 0o600 });
|
|
42
|
+
} catch {
|
|
43
|
+
// Best effort — never break the chat over this.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function clearLastSession(path = lastSessionPath()) {
|
|
48
|
+
try {
|
|
49
|
+
if (existsSync(path)) {
|
|
50
|
+
unlinkSync(path);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// Best effort.
|
|
54
|
+
}
|
|
55
|
+
}
|