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.
@@ -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 peers
320
- const room = this.#sessionManager.getSessionRoom(ws.sessionId);
321
- if (room) {
322
- this.#sessionManager.broadcastToRoom(
323
- room,
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
- // Notify old room that peer left
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
- result.oldRoom,
366
- createPeerLeft(ws.sessionId, session.nickname),
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
- sessionId: ws.sessionId,
375
- nickname: session.nickname,
376
- publicKey: session.publicKey,
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(result.newRoom, newPeers);
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.#sessionManager.getSessionRoom(ws.sessionId);
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
- const targetRoom = this.#sessionManager.getSessionRoom(targetSessionId);
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.#sessionManager.getSessionRoom(ws.sessionId);
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
- const targetRoom = this.#sessionManager.getSessionRoom(targetSessionId);
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.#sessionManager.getSessionRoom(ws.sessionId);
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
- const targetRoom = this.#sessionManager.getSessionRoom(targetSessionId);
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 room = this.#sessionManager.getSessionRoom(ws.sessionId);
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
- // Broadcast to former room members only
588
- 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) {
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
  }
@@ -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
- const PLUGIN_DIR = join(homedir(), '.ciphermesh', 'plugins');
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(PLUGIN_DIR)) {
20
- mkdirSync(PLUGIN_DIR, { recursive: true });
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(PLUGIN_DIR);
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(PLUGIN_DIR, file);
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;
@@ -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)) {
@@ -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
+ }