ciphermesh 2.0.0 → 2.1.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,7 @@ import {
16
21
  createPeerLeft,
17
22
  createPeerKeyUpdated,
18
23
  createRoomChanged,
24
+ createRoomChallenge,
19
25
  createRoomList,
20
26
  createPeerKicked,
21
27
  createPeerMuted,
@@ -28,10 +34,12 @@ import {
28
34
  validateEncryptedMessage,
29
35
  validateKeyUpdate,
30
36
  validateChangeRoom,
37
+ validateRoomAuth,
31
38
  validateKickPeer,
32
39
  validateMutePeer,
33
40
  validateBanPeer,
34
41
  } from '../protocol/validators.js';
42
+ import { verifyRoomChallenge } from '../crypto/RoomKey.js';
35
43
 
36
44
  const log = createLogger('ws-server');
37
45
 
@@ -188,6 +196,10 @@ export class SecureWSServer {
188
196
  this.#handleChangeRoom(ws, msg);
189
197
  break;
190
198
 
199
+ case MSG.ROOM_AUTH:
200
+ this.#handleRoomAuth(ws, msg);
201
+ break;
202
+
191
203
  case MSG.LIST_ROOMS:
192
204
  this.#handleListRooms(ws);
193
205
  break;
@@ -353,6 +365,41 @@ export class SecureWSServer {
353
365
  ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
354
366
  return;
355
367
  }
368
+
369
+ // Creating a private room: register the password verifier, but only for
370
+ // a room that doesn't exist yet (rooms die when the last member leaves).
371
+ if (validation.roomAuthPk) {
372
+ if (validation.room === 'general' || this.#sessionManager.roomHasMembers(validation.room)) {
373
+ ws.send(
374
+ JSON.stringify(
375
+ createError(ERR.ROOM_EXISTS, 'Room already exists — join it with /join instead'),
376
+ ),
377
+ );
378
+ return;
379
+ }
380
+ const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
381
+ if (!result) {
382
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
383
+ return;
384
+ }
385
+ this.#sessionManager.setRoomPrivate(validation.room, validation.roomAuthPk);
386
+ this.#finishRoomSwitch(ws, session, result);
387
+ return;
388
+ }
389
+
390
+ // Joining a private room: don't switch yet — issue a challenge the client
391
+ // must sign with the password-derived key (see #handleRoomAuth).
392
+ 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)));
400
+ return;
401
+ }
402
+
356
403
  const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
357
404
  if (!result) {
358
405
  // Already in this room
@@ -360,6 +407,92 @@ export class SecureWSServer {
360
407
  return;
361
408
  }
362
409
 
410
+ this.#finishRoomSwitch(ws, session, result);
411
+ }
412
+
413
+ #handleRoomAuth(ws, msg) {
414
+ if (!ws.hasJoined || !ws.sessionId) {
415
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
416
+ return;
417
+ }
418
+
419
+ const validation = validateRoomAuth(msg);
420
+ if (!validation.valid) {
421
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
422
+ return;
423
+ }
424
+
425
+ // Throttle wrong-password attempts per connection.
426
+ const now = Date.now();
427
+ if (
428
+ !ws.roomAuthFailWindowStart ||
429
+ now - ws.roomAuthFailWindowStart >= ROOM_AUTH_FAIL_WINDOW_MS
430
+ ) {
431
+ ws.roomAuthFailWindowStart = now;
432
+ ws.roomAuthFails = 0;
433
+ }
434
+ if (ws.roomAuthFails >= ROOM_AUTH_MAX_FAILS) {
435
+ ws.send(
436
+ JSON.stringify(createError(ERR.RATE_LIMITED, 'Too many failed attempts — wait a minute')),
437
+ );
438
+ return;
439
+ }
440
+
441
+ const challenge = ws.roomChallenge;
442
+ if (
443
+ !challenge ||
444
+ challenge.room !== validation.room ||
445
+ challenge.nonce !== validation.nonce ||
446
+ now > challenge.expiresAt
447
+ ) {
448
+ ws.send(JSON.stringify(createError(ERR.ROOM_AUTH_FAILED, 'Challenge expired — /join again')));
449
+ return;
450
+ }
451
+ ws.roomChallenge = null;
452
+
453
+ const authPkB64 = this.#sessionManager.getRoomAuthPk(validation.room);
454
+ if (!authPkB64) {
455
+ // Room emptied (and died) between challenge and answer.
456
+ ws.send(
457
+ JSON.stringify(
458
+ createError(ERR.ROOM_AUTH_FAILED, 'Room no longer exists — /join again to create it'),
459
+ ),
460
+ );
461
+ return;
462
+ }
463
+
464
+ const ok = verifyRoomChallenge(
465
+ Buffer.from(authPkB64, 'base64'),
466
+ Buffer.from(validation.signature, 'base64'),
467
+ validation.room,
468
+ validation.nonce,
469
+ ws.sessionId,
470
+ );
471
+ if (!ok) {
472
+ ws.roomAuthFails++;
473
+ ws.send(JSON.stringify(createError(ERR.ROOM_AUTH_FAILED, 'Wrong room password')));
474
+ log.warn(`Failed room auth for ${validation.room} (${ws.sessionId.slice(0, 8)})`);
475
+ return;
476
+ }
477
+
478
+ const session = this.#sessionManager.getSession(ws.sessionId);
479
+ if (this.#sessionManager.isBanned(validation.room, session.nickname)) {
480
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
481
+ return;
482
+ }
483
+
484
+ const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
485
+ if (!result) {
486
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
487
+ return;
488
+ }
489
+
490
+ this.#finishRoomSwitch(ws, session, result);
491
+ }
492
+
493
+ // Shared tail of a successful room switch: notify both rooms and send the
494
+ // ROOM_CHANGED (with the private flag) to the mover.
495
+ #finishRoomSwitch(ws, session, result) {
363
496
  // Notify old room that peer left
364
497
  this.#sessionManager.broadcastToRoom(
365
498
  result.oldRoom,
@@ -380,7 +513,11 @@ export class SecureWSServer {
380
513
 
381
514
  // Send new room info to the client
382
515
  const newPeers = this.#sessionManager.getRoomPeers(result.newRoom, ws.sessionId);
383
- const roomChanged = createRoomChanged(result.newRoom, newPeers);
516
+ const roomChanged = createRoomChanged(
517
+ result.newRoom,
518
+ newPeers,
519
+ this.#sessionManager.isRoomPrivate(result.newRoom),
520
+ );
384
521
  const newOwnerSid = this.#sessionManager.getRoomOwner(result.newRoom);
385
522
  if (newOwnerSid) {
386
523
  const ownerSess = this.#sessionManager.getSession(newOwnerSid);
@@ -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
+ }
@@ -0,0 +1,111 @@
1
+ // First-run setup wizard: ~30 seconds from `npx ciphermesh` to chatting, with
2
+ // just enough context to use the security features. Runs when no config file
3
+ // exists yet (or on demand via --setup) and persists the answers so it never
4
+ // asks twice.
5
+ import chalk from 'chalk';
6
+ import { SERVER_PORT } from './constants.js';
7
+ import { promptLabel, promptDim, promptError } from './banner.js';
8
+ import { themeNames, setTheme, getThemeName, THEMES } from './themes.js';
9
+ import { parseInvite } from './invite.js';
10
+ import { configPath, saveConfig } from './config.js';
11
+
12
+ /**
13
+ * Resolve a theme answer: a number from the printed list ("2"), a name
14
+ * ("matrix"), or empty → fallback. Pure — exported for testing.
15
+ */
16
+ export function parseThemeChoice(input, names, fallback) {
17
+ const clean = (input || '').trim().toLowerCase();
18
+ if (!clean) {
19
+ return fallback;
20
+ }
21
+ if (/^\d+$/.test(clean)) {
22
+ const idx = Number(clean) - 1;
23
+ return names[idx] || fallback;
24
+ }
25
+ return names.includes(clean) ? clean : fallback;
26
+ }
27
+
28
+ /**
29
+ * Resolve the server answer into what to use this session and what to save
30
+ * as the default. Invites are used as-is for the session but saved as their
31
+ * host:port (a room invite is one-shot, the host is worth keeping).
32
+ * Pure — exported for testing.
33
+ */
34
+ export function resolveServerAnswer(input, fallback = `localhost:${SERVER_PORT}`) {
35
+ const clean = (input || '').trim();
36
+ if (!clean) {
37
+ return { session: fallback, save: fallback };
38
+ }
39
+ const invite = parseInvite(clean);
40
+ if (invite) {
41
+ return { session: clean, save: invite.wsUrl.replace(/^wss?:\/\//, '') };
42
+ }
43
+ return { session: clean, save: clean };
44
+ }
45
+
46
+ /**
47
+ * Interactive first-run wizard. Uses the same readline interface as the rest
48
+ * of startup. Returns `{ nickname, server, theme }` — the caller uses them
49
+ * directly for this session (no duplicate prompts) — after persisting them
50
+ * to the config file.
51
+ */
52
+ export async function runOnboarding(rl, { savePath = configPath() } = {}) {
53
+ const dim = (t) => console.log(promptDim(` ${t}`));
54
+
55
+ console.log();
56
+ console.log(chalk.bold.white(' First time here? Quick setup — 30 seconds.'));
57
+ dim(`Everything is saved to ${savePath} (re-run anytime with: ciphermesh --setup)`);
58
+ console.log();
59
+ console.log(chalk.white(' How CipherMesh works, in three lines:'));
60
+ dim('• Everything is end-to-end encrypted — the relay only ever sees ciphertext.');
61
+ dim('• Your identity is a keypair; its short fingerprint is shown when you connect.');
62
+ dim('• Verify friends out-of-band with /verify — a green ✓ appears next to their name.');
63
+ console.log();
64
+
65
+ // 1. Nickname
66
+ let nickname = '';
67
+ while (!nickname) {
68
+ const raw = await rl.question(promptLabel(`Nickname ${promptDim('(a-z, 0-9, _, -)')}: `));
69
+ const clean = raw.trim().replace(/[^a-zA-Z0-9_-]/g, '');
70
+ if (clean.length >= 1 && clean.length <= 20) {
71
+ nickname = clean;
72
+ } else {
73
+ console.log(promptError('Invalid nickname. Use 1-20 alphanumeric characters.'));
74
+ }
75
+ }
76
+
77
+ // 2. Theme
78
+ const names = themeNames();
79
+ console.log();
80
+ console.log(chalk.white(' Colour theme for nicknames:'));
81
+ names.forEach((name, i) => {
82
+ const swatch = THEMES[name]
83
+ .slice(0, 5)
84
+ .map((c) => (c.startsWith('#') ? chalk.hex(c)('█') : chalk[c]?.('█') || '█'))
85
+ .join('');
86
+ console.log(promptDim(` ${i + 1}. ${name.padEnd(8)} ${swatch}`));
87
+ });
88
+ const themeRaw = await rl.question(
89
+ promptLabel(`Theme ${promptDim(`(1-${names.length} or name, Enter = ${getThemeName()})`)}: `),
90
+ );
91
+ const theme = parseThemeChoice(themeRaw, names, getThemeName());
92
+ setTheme(theme);
93
+
94
+ // 3. Default server
95
+ console.log();
96
+ console.log(chalk.white(' Which server should be your default?'));
97
+ dim(`• Same machine as the relay → localhost:${SERVER_PORT}`);
98
+ dim(`• Someone else hosts it (LAN/Tailscale) → their IP, e.g. 100.64.0.9:${SERVER_PORT}`);
99
+ dim('• Got a ciphermesh:// invite? Paste it here.');
100
+ const serverRaw = await rl.question(
101
+ promptLabel(`Server ${promptDim(`(Enter = localhost:${SERVER_PORT})`)}: `),
102
+ );
103
+ const { session: server, save: serverToSave } = resolveServerAnswer(serverRaw);
104
+
105
+ saveConfig({ nickname, theme, server: serverToSave }, savePath);
106
+ console.log();
107
+ console.log(promptLabel('Setup saved — next time you go straight to the chat.'));
108
+ console.log();
109
+
110
+ return { nickname, server, theme };
111
+ }