ciphermesh 1.2.1 → 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 ────────────────────────────────────────────
@@ -81,6 +85,13 @@ export function createRatchetedMessage(from, to, payload) {
81
85
  };
82
86
  }
83
87
 
88
+ // Sealed-sender envelope (protocol v2): the relay sees only the recipient and an
89
+ // opaque blob. The sender's identity + the inner payload are sealed to the
90
+ // recipient's key (see crypto/SealedSender.js). There is deliberately no `from`.
91
+ export function createSealedMessage(to, sealedB64) {
92
+ return { ...base(MSG.ENCRYPTED_MESSAGE), to, sealed: sealedB64 };
93
+ }
94
+
84
95
  export function createError(code, message) {
85
96
  return { ...base(MSG.ERROR), code, message };
86
97
  }
@@ -101,12 +112,35 @@ export function createPong() {
101
112
  return base(MSG.PONG);
102
113
  }
103
114
 
104
- export function createChangeRoom(room) {
105
- 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 };
106
138
  }
107
139
 
108
- export function createRoomChanged(room, peers) {
109
- 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 };
110
144
  }
111
145
 
112
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 ────────────────────────────────────────────────────
@@ -86,33 +89,18 @@ export function validateJoin(msg) {
86
89
  return { valid: true, nickname: nick };
87
90
  }
88
91
 
92
+ // Sealed sender (protocol v2): the relay only ever sees the recipient and an
93
+ // opaque sealed blob — never the sender or the inner payload. So there is
94
+ // nothing to validate here beyond routing target + a non-empty base64 envelope
95
+ // (the overall size is already capped in validateMessage). The recipient's
96
+ // unseal is fail-safe on garbage, so the relay needn't inspect further.
89
97
  export function validateEncryptedMessage(msg) {
90
- if (!isString(msg.from) || !isString(msg.to)) {
91
- return { valid: false, error: 'Missing from/to fields' };
98
+ if (!isString(msg.to)) {
99
+ return { valid: false, error: 'Missing to field' };
92
100
  }
93
- if (!isObject(msg.payload)) {
94
- return { valid: false, error: 'Missing payload' };
101
+ if (!isValidBase64(msg.sealed)) {
102
+ return { valid: false, error: 'Missing or invalid sealed envelope' };
95
103
  }
96
- if (!isString(msg.payload.ciphertext) || !isString(msg.payload.nonce)) {
97
- return { valid: false, error: 'Invalid payload structure' };
98
- }
99
- if (!isValidBase64(msg.payload.nonce, 24)) {
100
- return { valid: false, error: 'Invalid nonce' };
101
- }
102
-
103
- // Ratcheted message: validate extra fields
104
- if (msg.payload.ephemeralPublicKey !== undefined) {
105
- if (!isValidBase64(msg.payload.ephemeralPublicKey, PUBLIC_KEY_SIZE)) {
106
- return { valid: false, error: 'Invalid ephemeral public key' };
107
- }
108
- if (!Number.isInteger(msg.payload.counter) || msg.payload.counter < 0) {
109
- return { valid: false, error: 'Invalid counter' };
110
- }
111
- if (!Number.isInteger(msg.payload.previousCounter) || msg.payload.previousCounter < 0) {
112
- return { valid: false, error: 'Invalid previousCounter' };
113
- }
114
- }
115
-
116
104
  return { valid: true };
117
105
  }
118
106
 
@@ -130,7 +118,25 @@ export function validateChangeRoom(msg) {
130
118
  if (!/^[a-zA-Z0-9_-]+$/.test(msg.room)) {
131
119
  return { valid: false, error: 'Room name must be alphanumeric, dash or underscore' };
132
120
  }
133
- 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 };
134
140
  }
135
141
 
136
142
  export function validateListRooms() {
@@ -53,7 +53,9 @@ export class MessageRouter {
53
53
 
54
54
  if (recipientSession.ws.readyState === 1) {
55
55
  recipientSession.ws.send(JSON.stringify(msg));
56
- log.debug(`${senderSessionId.slice(0, 8)} -> ${msg.to.slice(0, 8)}`);
56
+ // Sealed sender: never log who sent it — only that something was routed to
57
+ // the recipient. Correlating sender->recipient in logs would defeat it.
58
+ log.debug(`routed -> ${msg.to.slice(0, 8)}`);
57
59
  }
58
60
  }
59
61