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.
package/src/client/UI.js CHANGED
@@ -19,10 +19,13 @@ const COMMAND_INFO = [
19
19
  ['/users', 'List online users'],
20
20
  ['/msg', 'Private message (DM)'],
21
21
  ['/reply', 'Reply to the last message'],
22
+ ['/mentions', 'Recent mentions of you'],
23
+ ['/contacts', 'Contact book — aliases for peers'],
22
24
  ['/away', 'Mark yourself as away'],
23
25
  ['/back', 'Clear away status'],
24
26
  ['/status', 'Set a status'],
25
- ['/join', 'Join a room'],
27
+ ['/join', 'Join a room (password if private)'],
28
+ ['/create', 'Create a private room with a password'],
26
29
  ['/rooms', 'List rooms'],
27
30
  ['/room', 'Show the current room'],
28
31
  ['/invite', 'Generate an invite with QR'],
@@ -54,6 +57,8 @@ const COMMAND_INFO = [
54
57
  ['/receipts', 'Read receipts'],
55
58
  ['/cover', 'Cover traffic (anti-metadata)'],
56
59
  ['/theme', 'Nick color theme'],
60
+ ['/lock', 'Lock the screen (session passphrase to unlock)'],
61
+ ['/autolock', 'Auto-lock on inactivity'],
57
62
  ['/panic', 'Wipe everything from disk and exit (duress)'],
58
63
  ['/kick', 'Kick a user (owner)'],
59
64
  ['/mute', 'Mute a user (owner)'],
@@ -83,6 +88,8 @@ export const COMMANDS = [
83
88
  '/sound',
84
89
  '/msg',
85
90
  '/reply',
91
+ '/mentions',
92
+ '/contacts',
86
93
  '/away',
87
94
  '/back',
88
95
  '/autoaway',
@@ -90,6 +97,7 @@ export const COMMANDS = [
90
97
  '/notify',
91
98
  '/dnd',
92
99
  '/join',
100
+ '/create',
93
101
  '/rooms',
94
102
  '/room',
95
103
  '/invite',
@@ -110,6 +118,8 @@ export const COMMANDS = [
110
118
  '/receipts',
111
119
  '/cover',
112
120
  '/theme',
121
+ '/lock',
122
+ '/autolock',
113
123
  '/panic',
114
124
  '/kick',
115
125
  '/mute',
@@ -302,6 +312,23 @@ export function renderMarkdown(text) {
302
312
  return out.join('\n');
303
313
  }
304
314
 
315
+ // Sanitizes pasted text while PRESERVING its line structure — the input box is
316
+ // multi-line and fenced code blocks render in markdown, so pasted code must
317
+ // keep its newlines. Normalizes CRLF/CR, turns tabs into spaces and strips the
318
+ // remaining control chars (incl. stray paste markers). Pure and exported for
319
+ // testing.
320
+ export function cleanPaste(raw) {
321
+ return (
322
+ raw
323
+ // eslint-disable-next-line no-control-regex
324
+ .replace(/\x1b\[20[01]~/g, '')
325
+ .replace(/\r\n?/g, '\n')
326
+ .replace(/\t/g, ' ')
327
+ // eslint-disable-next-line no-control-regex
328
+ .replace(/[\x00-\x09\x0b-\x1f\x7f]/g, '')
329
+ );
330
+ }
331
+
305
332
  // Builds the rendered content of the (possibly multi-line) input box with an
306
333
  // inverse cursor cell, windowed so the cursor line is always visible. Pure and
307
334
  // exported for testing. Returns { content, height } (height includes borders).
@@ -466,6 +493,11 @@ export class UI extends EventEmitter {
466
493
  #emojiPicker;
467
494
  #emojiOpen;
468
495
  #emojiQuery;
496
+ #locked;
497
+ #lockBox;
498
+ #lockInput;
499
+ #lockError;
500
+ #lockVerify;
469
501
 
470
502
  constructor(nickname) {
471
503
  super();
@@ -511,6 +543,10 @@ export class UI extends EventEmitter {
511
543
  this.#paletteQuery = '';
512
544
  this.#emojiOpen = false;
513
545
  this.#emojiQuery = '';
546
+ this.#locked = false;
547
+ this.#lockInput = '';
548
+ this.#lockError = false;
549
+ this.#lockVerify = null;
514
550
 
515
551
  // blessed's terminfo parser can't compile the modern Setulc (underline
516
552
  // colour) capability that terminals like ghostty ship, so it dumps a
@@ -670,6 +706,12 @@ export class UI extends EventEmitter {
670
706
  }
671
707
  this.#lastKeyEvent = { seq, time: now };
672
708
 
709
+ // Locked screen swallows everything except the passphrase entry.
710
+ if (this.#locked) {
711
+ this.#handleLockKey(ch, key);
712
+ return;
713
+ }
714
+
673
715
  if (this.#paletteOpen) {
674
716
  this.#handlePaletteKey(ch, key);
675
717
  return;
@@ -833,6 +875,87 @@ export class UI extends EventEmitter {
833
875
  }
834
876
  }
835
877
 
878
+ // ── Screen lock ──────────────────────────────────────────────
879
+ // Fullscreen overlay that hides the chat and swallows all input until the
880
+ // session passphrase is re-entered. `verify` is a callback so the passphrase
881
+ // itself never lives in the UI layer.
882
+ showLock(verify) {
883
+ if (this.#locked || typeof verify !== 'function') {
884
+ return;
885
+ }
886
+ this.#locked = true;
887
+ this.#lockVerify = verify;
888
+ this.#lockInput = '';
889
+ this.#lockError = false;
890
+ this.#lockBox = blessed.box({
891
+ parent: this.#screen,
892
+ top: 0,
893
+ left: 0,
894
+ width: '100%',
895
+ height: '100%',
896
+ tags: true,
897
+ style: { bg: 'black', fg: 'white' },
898
+ });
899
+ this.#lockBox.setFront();
900
+ this.#renderLock();
901
+ }
902
+
903
+ get isLocked() {
904
+ return this.#locked;
905
+ }
906
+
907
+ #renderLock() {
908
+ if (!this.#lockBox) {
909
+ return;
910
+ }
911
+ const dots = '●'.repeat(Math.min(this.#lockInput.length, 40));
912
+ const error = this.#lockError ? '{red-fg}Wrong passphrase — try again{/red-fg}' : '';
913
+ const pad = '\n'.repeat(Math.max(1, Math.floor((this.#screen.height - 8) / 2)));
914
+ this.#lockBox.setContent(
915
+ `${pad}{center}{bold}🔒 Session locked{/bold}{/center}\n` +
916
+ `{center}{#8888aa-fg}Messages keep arriving encrypted underneath{/#8888aa-fg}{/center}\n\n` +
917
+ `{center}Passphrase: ${dots}{inverse} {/inverse}{/center}\n` +
918
+ `{center}${error}{/center}`,
919
+ );
920
+ this.#screen.render();
921
+ }
922
+
923
+ #handleLockKey(ch, key) {
924
+ const name = key.name || '';
925
+ if (key.ctrl && name === 'c') {
926
+ this.emit('quit'); // locking is privacy, not a prison
927
+ return;
928
+ }
929
+ if (name === 'return' || name === 'enter') {
930
+ if (this.#lockVerify(this.#lockInput)) {
931
+ this.#lockBox.destroy();
932
+ this.#lockBox = null;
933
+ this.#locked = false;
934
+ this.#lockVerify = null;
935
+ this.#lockInput = '';
936
+ this.#lockError = false;
937
+ this.#screen.render();
938
+ this.emit('unlocked');
939
+ } else {
940
+ this.#lockError = true;
941
+ this.#lockInput = '';
942
+ this.emit('lock-failed');
943
+ this.#renderLock();
944
+ }
945
+ return;
946
+ }
947
+ if (name === 'backspace') {
948
+ this.#lockInput = this.#lockInput.slice(0, -1);
949
+ this.#renderLock();
950
+ return;
951
+ }
952
+ const code = ch ? ch.charCodeAt(0) : 0;
953
+ if (ch && ch.length <= 2 && !key.ctrl && !key.meta && code > 0x1f && code !== 0x7f) {
954
+ this.#lockInput += ch;
955
+ this.#renderLock();
956
+ }
957
+ }
958
+
836
959
  // Bracketed-paste state machine. Returns true when the sequence is part of a
837
960
  // paste (start / content / end) and must not be treated as normal keypresses.
838
961
  #handlePaste(seq) {
@@ -870,9 +993,10 @@ export class UI extends EventEmitter {
870
993
  }
871
994
 
872
995
  #insertPaste(raw) {
873
- // Strip any stray paste markers and control chars (a message is one line).
874
- // eslint-disable-next-line no-control-regex
875
- const clean = raw.replace(/\x1b\[20[01]~/g, '').replace(/[\x00-\x1f\x7f]/g, '');
996
+ if (this.#locked) {
997
+ return; // no pasting into a locked screen
998
+ }
999
+ const clean = cleanPaste(raw);
876
1000
  if (!clean) {
877
1001
  return;
878
1002
  }
@@ -17,7 +17,9 @@ import { HistoryStore } from '../crypto/HistoryStore.js';
17
17
  import { parseInvite } from '../shared/invite.js';
18
18
  import { importBackup } from '../crypto/IdentityBackup.js';
19
19
  import { questionHidden } from '../shared/prompt.js';
20
- import { loadConfig, startupCommands } from '../shared/config.js';
20
+ import { loadConfig, hasConfigFile, startupCommands } from '../shared/config.js';
21
+ import { runOnboarding } from '../shared/onboarding.js';
22
+ import { loadLastSession, clearLastSession } from '../shared/lastSession.js';
21
23
  import { randomTip } from '../shared/tips.js';
22
24
  import { setTheme } from '../shared/themes.js';
23
25
  import { Connection } from './Connection.js';
@@ -37,7 +39,31 @@ if (config.theme) {
37
39
  // ── Prompt setup ────────────────────────────────────────────────
38
40
  const rl = readline.createInterface({ input: stdin, output: stdout });
39
41
 
40
- let nickname = '';
42
+ // ── First-run onboarding ────────────────────────────────────────
43
+ // Runs once (no config file yet) or on demand with --setup; --no-onboard
44
+ // skips it (scripts/CI). The wizard's answers double as this session's
45
+ // nickname/server, so nothing is asked twice.
46
+ const argvFlags = process.argv.slice(2);
47
+ const forceSetup = argvFlags.includes('--setup');
48
+ const skipOnboard = argvFlags.includes('--no-onboard') || !stdin.isTTY;
49
+ const freshStart = argvFlags.includes('--fresh');
50
+
51
+ // Last session (server + room) — restored unless --fresh wipes it.
52
+ let lastSession = null;
53
+ if (freshStart) {
54
+ clearLastSession();
55
+ } else {
56
+ lastSession = loadLastSession();
57
+ }
58
+ let onboarded = null;
59
+ if (forceSetup || (!hasConfigFile() && !skipOnboard)) {
60
+ onboarded = await runOnboarding(rl);
61
+ config.nickname = onboarded.nickname;
62
+ config.theme = onboarded.theme;
63
+ config.server = onboarded.server;
64
+ }
65
+
66
+ let nickname = onboarded?.nickname || '';
41
67
  while (!nickname) {
42
68
  const hint = config.nickname ? `(${config.nickname})` : '(a-z, 0-9, _, -)';
43
69
  const raw = await rl.question(promptLabel(`Nickname ${promptDim(hint)}: `));
@@ -112,11 +138,19 @@ if (!restoredState?.keyManager) {
112
138
  }
113
139
  }
114
140
 
115
- const defaultServer = config.server || `localhost:${SERVER_PORT}`;
116
- const serverInput = await rl.question(
117
- promptLabel(`Server ${promptDim(`(${defaultServer} or ciphermesh:// invite)`)}: `),
118
- );
119
- const serverAddr = serverInput.trim() || defaultServer;
141
+ // Most recent wins: last real session > configured default > localhost.
142
+ const defaultServer = lastSession?.server || config.server || `localhost:${SERVER_PORT}`;
143
+ let serverAddr;
144
+ if (onboarded) {
145
+ // The wizard just asked — don't ask again this session.
146
+ serverAddr = onboarded.server;
147
+ } else {
148
+ const hint = lastSession?.server
149
+ ? `(Enter = ${defaultServer}, your last session)`
150
+ : `(${defaultServer} or ciphermesh:// invite)`;
151
+ const serverInput = await rl.question(promptLabel(`Server ${promptDim(hint)}: `));
152
+ serverAddr = serverInput.trim() || defaultServer;
153
+ }
120
154
 
121
155
  let wsUrl;
122
156
  let inviteRoom = null;
@@ -131,6 +165,15 @@ if (invite) {
131
165
  : `wss://${serverAddr}`;
132
166
  }
133
167
 
168
+ // Rejoin the last room — only on the same server, and never a private one
169
+ // (those are not saved). An explicit invite room wins.
170
+ if (!inviteRoom && lastSession?.room && lastSession.room !== 'general') {
171
+ if (serverAddr === lastSession.server) {
172
+ inviteRoom = lastSession.room;
173
+ console.log(promptLabel(`Rejoining #${inviteRoom} ${promptDim('(--fresh starts clean)')}`));
174
+ }
175
+ }
176
+
134
177
  rl.close();
135
178
 
136
179
  // ── Encrypted local history (opt-in, needs passphrase) ─────────
@@ -0,0 +1,162 @@
1
+ import sodium from 'sodium-native';
2
+
3
+ // Domain-separation contexts — never reuse across protocols.
4
+ const ROOM_SALT_CONTEXT = 'ciphermesh/room-v1:';
5
+ const ROOM_AUTH_CONTEXT = 'ciphermesh/room-auth-v1';
6
+
7
+ /**
8
+ * Derive the secrets of a password-protected room.
9
+ *
10
+ * Argon2id(password, salt=BLAKE2b(context + roomName)) → 64 bytes, split into:
11
+ * - authSeed (32B) → deterministic Ed25519 keypair. The PUBLIC key is the
12
+ * verifier the server stores (in memory only); the secret key signs join
13
+ * challenges. The server never sees the password — and cracking the
14
+ * verifier costs a full Argon2id derivation per guess.
15
+ * - roomKey (32B) → symmetric content key layered UNDER the pairwise E2EE,
16
+ * so even a malicious relay that skips verification and injects a member
17
+ * cannot read the room without the password.
18
+ *
19
+ * Everyone with (roomName, password) derives the same secrets — nothing else
20
+ * needs to be exchanged.
21
+ *
22
+ * @param {string} roomName - Normalized (lowercase) room name
23
+ * @param {string} password
24
+ * @returns {{ authPublicKey: Buffer, authSecretKey: Buffer, roomKey: Buffer }}
25
+ */
26
+ export function deriveRoomSecrets(roomName, password) {
27
+ const salt = Buffer.alloc(sodium.crypto_pwhash_SALTBYTES);
28
+ sodium.crypto_generichash(salt, Buffer.from(ROOM_SALT_CONTEXT + roomName, 'utf-8'));
29
+
30
+ const seed = sodium.sodium_malloc(
31
+ sodium.crypto_sign_SEEDBYTES + sodium.crypto_secretbox_KEYBYTES,
32
+ );
33
+ sodium.crypto_pwhash(
34
+ seed,
35
+ Buffer.from(password, 'utf-8'),
36
+ salt,
37
+ sodium.crypto_pwhash_OPSLIMIT_MODERATE,
38
+ sodium.crypto_pwhash_MEMLIMIT_MODERATE,
39
+ sodium.crypto_pwhash_ALG_ARGON2ID13,
40
+ );
41
+
42
+ const authSeed = sodium.sodium_malloc(sodium.crypto_sign_SEEDBYTES);
43
+ seed.copy(authSeed, 0, 0, sodium.crypto_sign_SEEDBYTES);
44
+ const roomKey = sodium.sodium_malloc(sodium.crypto_secretbox_KEYBYTES);
45
+ seed.copy(roomKey, 0, sodium.crypto_sign_SEEDBYTES);
46
+ sodium.sodium_memzero(seed);
47
+
48
+ const authPublicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
49
+ const authSecretKey = sodium.sodium_malloc(sodium.crypto_sign_SECRETKEYBYTES);
50
+ sodium.crypto_sign_seed_keypair(authPublicKey, authSecretKey, authSeed);
51
+ sodium.sodium_memzero(authSeed);
52
+
53
+ return { authPublicKey, authSecretKey, roomKey };
54
+ }
55
+
56
+ /**
57
+ * The exact bytes signed in the join challenge. Binds room + server nonce +
58
+ * the joiner's sessionId, so a captured signature can't be replayed for
59
+ * another session, room or challenge.
60
+ */
61
+ function challengeMessage(room, nonceB64, sessionId) {
62
+ return Buffer.from(`${ROOM_AUTH_CONTEXT}:${room}:${nonceB64}:${sessionId}`, 'utf-8');
63
+ }
64
+
65
+ export function signRoomChallenge(authSecretKey, room, nonceB64, sessionId) {
66
+ const signature = Buffer.alloc(sodium.crypto_sign_BYTES);
67
+ sodium.crypto_sign_detached(
68
+ signature,
69
+ challengeMessage(room, nonceB64, sessionId),
70
+ authSecretKey,
71
+ );
72
+ return signature;
73
+ }
74
+
75
+ export function verifyRoomChallenge(authPublicKey, signature, room, nonceB64, sessionId) {
76
+ if (
77
+ !Buffer.isBuffer(authPublicKey) ||
78
+ authPublicKey.length !== sodium.crypto_sign_PUBLICKEYBYTES ||
79
+ !Buffer.isBuffer(signature) ||
80
+ signature.length !== sodium.crypto_sign_BYTES
81
+ ) {
82
+ return false;
83
+ }
84
+ try {
85
+ return sodium.crypto_sign_verify_detached(
86
+ signature,
87
+ challengeMessage(room, nonceB64, sessionId),
88
+ authPublicKey,
89
+ );
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ // ── Room content layer ───────────────────────────────────────────
96
+ // Applied to the payload BEFORE the pairwise encryption (ratchet/sealed
97
+ // sender), which already pads the result — so no extra padding here.
98
+
99
+ /**
100
+ * Wrap a payload string in the private-room symmetric layer.
101
+ * @returns {string} JSON `{ rk: 1, n, c }` to feed the pairwise encryption
102
+ */
103
+ export function encryptRoomPayload(payloadStr, roomKey) {
104
+ const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES);
105
+ sodium.randombytes_buf(nonce);
106
+ const plaintext = Buffer.from(payloadStr, 'utf-8');
107
+ const ciphertext = Buffer.alloc(plaintext.length + sodium.crypto_secretbox_MACBYTES);
108
+ sodium.crypto_secretbox_easy(ciphertext, plaintext, nonce, roomKey);
109
+ sodium.sodium_memzero(plaintext);
110
+ return JSON.stringify({ rk: 1, n: nonce.toString('base64'), c: ciphertext.toString('base64') });
111
+ }
112
+
113
+ /** True if a decrypted payload object is a private-room wrapper. */
114
+ export function isRoomWrapped(data) {
115
+ return (
116
+ data !== null &&
117
+ typeof data === 'object' &&
118
+ data.rk === 1 &&
119
+ typeof data.n === 'string' &&
120
+ typeof data.c === 'string'
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Open the private-room layer. Returns the inner payload string, or null if
126
+ * the wrapper is malformed or the key doesn't match (wrong/stale room key).
127
+ */
128
+ export function decryptRoomPayload(data, roomKey) {
129
+ if (!isRoomWrapped(data)) {
130
+ return null;
131
+ }
132
+ try {
133
+ const nonce = Buffer.from(data.n, 'base64');
134
+ const ciphertext = Buffer.from(data.c, 'base64');
135
+ if (
136
+ nonce.length !== sodium.crypto_secretbox_NONCEBYTES ||
137
+ ciphertext.length <= sodium.crypto_secretbox_MACBYTES
138
+ ) {
139
+ return null;
140
+ }
141
+ const plaintext = Buffer.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES);
142
+ if (!sodium.crypto_secretbox_open_easy(plaintext, ciphertext, nonce, roomKey)) {
143
+ return null;
144
+ }
145
+ return plaintext.toString('utf-8');
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ /** Zero out room secrets when leaving a private room. */
152
+ export function freeRoomSecrets(secrets) {
153
+ if (!secrets) {
154
+ return;
155
+ }
156
+ if (Buffer.isBuffer(secrets.authSecretKey)) {
157
+ sodium.sodium_memzero(secrets.authSecretKey);
158
+ }
159
+ if (Buffer.isBuffer(secrets.roomKey)) {
160
+ sodium.sodium_memzero(secrets.roomKey);
161
+ }
162
+ }
@@ -194,6 +194,53 @@ export class TrustStore {
194
194
  return this.#store.get(nickname.toLowerCase()) || null;
195
195
  }
196
196
 
197
+ // ── Contacts (friendly aliases on top of trust records) ──────
198
+ // The alias lives on the trust record, so it survives restarts and rides
199
+ // along in the existing identity backup for free.
200
+
201
+ /** Set a friendly alias for an already-seen peer. False if peer unknown. */
202
+ setAlias(nickname, alias) {
203
+ const record = this.#store.get(nickname.toLowerCase());
204
+ if (!record) {
205
+ return false;
206
+ }
207
+ record.alias = String(alias).slice(0, 30);
208
+ this.#save();
209
+ return true;
210
+ }
211
+
212
+ /** Remove a peer's alias. False if there was none. */
213
+ clearAlias(nickname) {
214
+ const record = this.#store.get(nickname.toLowerCase());
215
+ if (!record || !record.alias) {
216
+ return false;
217
+ }
218
+ delete record.alias;
219
+ this.#save();
220
+ return true;
221
+ }
222
+
223
+ getAlias(nickname) {
224
+ return this.#store.get(nickname.toLowerCase())?.alias || null;
225
+ }
226
+
227
+ /**
228
+ * The contact book: aliased peers, or every known peer with `all`.
229
+ * Sorted by most recently seen.
230
+ */
231
+ listContacts(all = false) {
232
+ return [...this.#store.entries()]
233
+ .filter(([, r]) => all || r.alias)
234
+ .map(([nickname, r]) => ({
235
+ nickname,
236
+ alias: r.alias || null,
237
+ verified: r.verified === true,
238
+ fingerprint: r.fingerprint,
239
+ lastSeen: r.lastSeen || 0,
240
+ }))
241
+ .sort((a, b) => b.lastSeen - a.lastSeen);
242
+ }
243
+
197
244
  /** Export all trust records as a plain object (for identity backup). */
198
245
  exportData() {
199
246
  return Object.fromEntries(this.#store);
@@ -1368,7 +1368,15 @@ export class P2PChatController {
1368
1368
  if (this.#pluginManager) {
1369
1369
  const result = this.#pluginManager.handleCommand(cmd, parts.slice(1));
1370
1370
  if (result) {
1371
- this.#ui.addInfoMessage(result);
1371
+ // Plugin API: `{ send }` goes to the room as a normal E2EE
1372
+ // message; `{ info }` or a plain string stays local.
1373
+ if (typeof result === 'object' && typeof result.send === 'string' && result.send) {
1374
+ this.#sendMessageToAll(result.send);
1375
+ } else if (typeof result === 'object' && typeof result.info === 'string') {
1376
+ this.#ui.addInfoMessage(result.info);
1377
+ } else if (typeof result === 'string') {
1378
+ this.#ui.addInfoMessage(result);
1379
+ }
1372
1380
  break;
1373
1381
  }
1374
1382
  }
@@ -14,6 +14,8 @@ export const MSG = {
14
14
  ROOM_CHANGED: 'room_changed',
15
15
  LIST_ROOMS: 'list_rooms',
16
16
  ROOM_LIST: 'room_list',
17
+ ROOM_CHALLENGE: 'room_challenge',
18
+ ROOM_AUTH: 'room_auth',
17
19
  KICK_PEER: 'kick_peer',
18
20
  MUTE_PEER: 'mute_peer',
19
21
  BAN_PEER: 'ban_peer',
@@ -30,6 +32,8 @@ export const ERR = {
30
32
  PEER_NOT_FOUND: 'PEER_NOT_FOUND',
31
33
  RATE_LIMITED: 'RATE_LIMITED',
32
34
  PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
35
+ ROOM_AUTH_FAILED: 'ROOM_AUTH_FAILED',
36
+ ROOM_EXISTS: 'ROOM_EXISTS',
33
37
  };
34
38
 
35
39
  // ── Factory helpers ────────────────────────────────────────────
@@ -108,12 +112,35 @@ export function createPong() {
108
112
  return base(MSG.PONG);
109
113
  }
110
114
 
111
- export function createChangeRoom(room) {
112
- return { ...base(MSG.CHANGE_ROOM), room };
115
+ // roomAuthPk (optional): Ed25519 verifier public key — present only when
116
+ // CREATING a private room; the server stores it in memory as the room's
117
+ // password verifier (see crypto/RoomKey.js).
118
+ export function createChangeRoom(room, roomAuthPkB64 = null) {
119
+ const msg = { ...base(MSG.CHANGE_ROOM), room };
120
+ if (roomAuthPkB64) {
121
+ msg.roomAuthPk = roomAuthPkB64;
122
+ }
123
+ return msg;
124
+ }
125
+
126
+ export function createRoomChanged(room, peers, isPrivate = false) {
127
+ const msg = { ...base(MSG.ROOM_CHANGED), room, peers };
128
+ if (isPrivate) {
129
+ msg.private = true;
130
+ }
131
+ return msg;
132
+ }
133
+
134
+ // Server → client: the target room is private; prove password knowledge by
135
+ // signing this nonce. The password itself never travels.
136
+ export function createRoomChallenge(room, nonceB64) {
137
+ return { ...base(MSG.ROOM_CHALLENGE), room, nonce: nonceB64 };
113
138
  }
114
139
 
115
- export function createRoomChanged(room, peers) {
116
- return { ...base(MSG.ROOM_CHANGED), room, peers };
140
+ // Client → server: Ed25519 signature over (room, nonce, sessionId) with the
141
+ // password-derived key (see crypto/RoomKey.js#signRoomChallenge).
142
+ export function createRoomAuth(room, nonceB64, signatureB64) {
143
+ return { ...base(MSG.ROOM_AUTH), room, nonce: nonceB64, signature: signatureB64 };
117
144
  }
118
145
 
119
146
  export function createListRooms() {
@@ -3,6 +3,9 @@ import {
3
3
  MAX_NICKNAME_LENGTH,
4
4
  MAX_PAYLOAD_SIZE,
5
5
  PUBLIC_KEY_SIZE,
6
+ ROOM_AUTH_PK_SIZE,
7
+ ROOM_AUTH_SIG_SIZE,
8
+ ROOM_CHALLENGE_NONCE_SIZE,
6
9
  } from '../shared/constants.js';
7
10
 
8
11
  // ── Helpers ────────────────────────────────────────────────────
@@ -115,7 +118,25 @@ export function validateChangeRoom(msg) {
115
118
  if (!/^[a-zA-Z0-9_-]+$/.test(msg.room)) {
116
119
  return { valid: false, error: 'Room name must be alphanumeric, dash or underscore' };
117
120
  }
118
- return { valid: true, room: msg.room.toLowerCase() };
121
+ // Optional: Ed25519 verifier public key, present only when creating a
122
+ // private room.
123
+ if (msg.roomAuthPk !== undefined && !isValidBase64(msg.roomAuthPk, ROOM_AUTH_PK_SIZE)) {
124
+ return { valid: false, error: 'Invalid room verifier key' };
125
+ }
126
+ return { valid: true, room: msg.room.toLowerCase(), roomAuthPk: msg.roomAuthPk || null };
127
+ }
128
+
129
+ export function validateRoomAuth(msg) {
130
+ if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
131
+ return { valid: false, error: 'Invalid room name (1-30 chars)' };
132
+ }
133
+ if (!isValidBase64(msg.nonce, ROOM_CHALLENGE_NONCE_SIZE)) {
134
+ return { valid: false, error: 'Invalid challenge nonce' };
135
+ }
136
+ if (!isValidBase64(msg.signature, ROOM_AUTH_SIG_SIZE)) {
137
+ return { valid: false, error: 'Invalid challenge signature' };
138
+ }
139
+ return { valid: true, room: msg.room.toLowerCase(), nonce: msg.nonce, signature: msg.signature };
119
140
  }
120
141
 
121
142
  export function validateListRooms() {
@@ -11,6 +11,7 @@ export class SessionManager {
11
11
  #roomOwners; // Map<roomName, sessionId>
12
12
  #muteState; // Map<sessionId, { until: timestamp }>
13
13
  #banList; // Map<roomName, Set<nickname_lower>>
14
+ #roomMeta; // Map<roomName, { authPk: base64 }> — private-room verifiers (memory only)
14
15
 
15
16
  constructor() {
16
17
  this.#sessions = new Map();
@@ -20,6 +21,7 @@ export class SessionManager {
20
21
  this.#roomOwners = new Map();
21
22
  this.#muteState = new Map();
22
23
  this.#banList = new Map();
24
+ this.#roomMeta = new Map();
23
25
  // Ensure default room exists
24
26
  this.#rooms.set('general', new Set());
25
27
  }
@@ -167,9 +169,12 @@ export class SessionManager {
167
169
  if (members.size === 0 && room !== 'general') {
168
170
  // Empty non-general room — drop it and all associated moderation state
169
171
  // (otherwise a recreated room stays owner-less and unmoderatable).
172
+ // The private-room verifier dies here too: the room and its password
173
+ // exist only while someone is inside.
170
174
  this.#rooms.delete(room);
171
175
  this.#roomOwners.delete(room);
172
176
  this.#banList.delete(room);
177
+ this.#roomMeta.delete(room);
173
178
  } else if (this.#roomOwners.get(room) === sessionId) {
174
179
  // Owner left but room still has members — transfer ownership so the
175
180
  // room keeps a moderator instead of becoming owner-less.
@@ -207,16 +212,35 @@ export class SessionManager {
207
212
  const rooms = [];
208
213
  for (const [name, members] of this.#rooms) {
209
214
  if (members.size > 0) {
210
- rooms.push({ name, memberCount: members.size });
215
+ rooms.push({ name, memberCount: members.size, private: this.#roomMeta.has(name) });
211
216
  }
212
217
  }
213
218
  // Always include 'general' even if empty
214
219
  if (!rooms.some((r) => r.name === 'general')) {
215
- rooms.unshift({ name: 'general', memberCount: 0 });
220
+ rooms.unshift({ name: 'general', memberCount: 0, private: false });
216
221
  }
217
222
  return rooms.sort((a, b) => a.name.localeCompare(b.name));
218
223
  }
219
224
 
225
+ // ── Private rooms ────────────────────────────────────────────
226
+
227
+ roomHasMembers(room) {
228
+ const members = this.#rooms.get(room);
229
+ return !!members && members.size > 0;
230
+ }
231
+
232
+ setRoomPrivate(room, authPkB64) {
233
+ this.#roomMeta.set(room, { authPk: authPkB64 });
234
+ }
235
+
236
+ isRoomPrivate(room) {
237
+ return this.#roomMeta.has(room);
238
+ }
239
+
240
+ getRoomAuthPk(room) {
241
+ return this.#roomMeta.get(room)?.authPk || null;
242
+ }
243
+
220
244
  getSessionRoom(sessionId) {
221
245
  const session = this.#sessions.get(sessionId);
222
246
  return session?.room || null;