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.
@@ -9,14 +9,25 @@ import {
9
9
  createJoin,
10
10
  createEncryptedMessage,
11
11
  createRatchetedMessage,
12
+ createSealedMessage,
12
13
  createKeyUpdate,
13
14
  createChangeRoom,
15
+ createRoomAuth,
14
16
  createListRooms,
15
17
  createKickPeer,
16
18
  createMutePeer,
17
19
  createBanPeer,
18
20
  ERR,
19
21
  } from '../protocol/messages.js';
22
+ import { sealEnvelope, openEnvelope } from '../crypto/SealedSender.js';
23
+ import {
24
+ deriveRoomSecrets,
25
+ signRoomChallenge,
26
+ encryptRoomPayload,
27
+ decryptRoomPayload,
28
+ isRoomWrapped,
29
+ freeRoomSecrets,
30
+ } from '../crypto/RoomKey.js';
20
31
  import { KEY_ROTATION_INTERVAL_MS, EMOJI_MAP, COVER_CONSTANT_MS } from '../shared/constants.js';
21
32
  import { KeyManager } from '../crypto/KeyManager.js';
22
33
  import { Handshake } from '../crypto/Handshake.js';
@@ -41,10 +52,12 @@ import { setTheme, getThemeName, themeNames } from '../shared/themes.js';
41
52
  import { panicWipe } from '../shared/panic.js';
42
53
  import { farewellBanner } from '../shared/banner.js';
43
54
  import { parseDndWindow, shouldNotify, nowMinutes, mentionsMe } from '../shared/dnd.js';
55
+ import { saveLastSession } from '../shared/lastSession.js';
44
56
  import { COMMANDS } from './UI.js';
45
57
 
46
58
  const TYPING_SEND_INTERVAL = 2000; // debounce: max 1 typing event per 2s
47
59
  const TYPING_EXPIRE_TIMEOUT = 3000; // hide indicator after 3s of silence
60
+ const MENTIONS_MAX = 50; // session mention log cap (memory only, never persisted)
48
61
 
49
62
  export class ChatController {
50
63
  #nickname;
@@ -96,6 +109,13 @@ export class ChatController {
96
109
  #autoAwayMs = 0; // idle timeout in ms (0 = off)
97
110
  #autoAwayTimer = null;
98
111
  #autoAwaySet = false; // whether the current away was set automatically
112
+ #mentions = []; // session mention log: { nickname, text, room, at }
113
+ #awayUnread = 0; // messages received while away
114
+ #awayMentions = 0; // …of which mentioned me
115
+ #autoLockMs = 0; // idle screen-lock timeout (0 = off)
116
+ #autoLockTimer = null;
117
+ #roomSecrets = null; // active private-room secrets { room, authSecretKey, roomKey, … }
118
+ #pendingRoomSecrets = null; // derived while joining/creating, promoted on ROOM_CHANGED
99
119
 
100
120
  constructor(
101
121
  nickname,
@@ -237,6 +257,16 @@ export class ChatController {
237
257
  this.destroy();
238
258
  process.exit(0);
239
259
  });
260
+
261
+ this.#ui.on('unlocked', () => {
262
+ this.#auditLog.log(AuditEvent.SCREEN_UNLOCKED, {});
263
+ this.#ui.addSystemMessage('Screen unlocked');
264
+ this.#noteActive();
265
+ });
266
+
267
+ this.#ui.on('lock-failed', () => {
268
+ this.#auditLog.log(AuditEvent.SCREEN_UNLOCK_FAILED, {});
269
+ });
240
270
  }
241
271
 
242
272
  // ── Auto-away (idle) ────────────────────────────────────────
@@ -248,9 +278,52 @@ export class ChatController {
248
278
  this.#autoAwaySet = false;
249
279
  this.#ui.removeHeaderIndicator('away');
250
280
  this.#ui.addSystemMessage("You're back (auto)");
281
+ this.#reportAwayUnread();
251
282
  this.#broadcastPresence();
252
283
  }
253
284
  this.#armAutoAway();
285
+ this.#armAutoLock();
286
+ }
287
+
288
+ // ── Screen lock (privacy, not duress — that's /panic) ────────
289
+ #lockNow() {
290
+ if (!this.#passphrase) {
291
+ this.#ui.addErrorMessage(
292
+ 'No session passphrase — /lock needs one (set it at startup to enable locking)',
293
+ );
294
+ return;
295
+ }
296
+ if (this.#ui.isLocked) {
297
+ return;
298
+ }
299
+ this.#auditLog.log(AuditEvent.SCREEN_LOCKED, {});
300
+ this.#ui.showLock((attempt) => attempt === this.#passphrase);
301
+ }
302
+
303
+ #armAutoLock() {
304
+ if (this.#autoLockTimer) {
305
+ clearTimeout(this.#autoLockTimer);
306
+ this.#autoLockTimer = null;
307
+ }
308
+ if (this.#autoLockMs > 0) {
309
+ this.#autoLockTimer = setTimeout(() => this.#lockNow(), this.#autoLockMs);
310
+ if (this.#autoLockTimer.unref) {
311
+ this.#autoLockTimer.unref();
312
+ }
313
+ }
314
+ }
315
+
316
+ // Summarize what arrived while away, then reset the counters.
317
+ #reportAwayUnread() {
318
+ if (this.#awayUnread > 0) {
319
+ const mentions =
320
+ this.#awayMentions > 0 ? ` — ${this.#awayMentions} mention(s), see /mentions` : '';
321
+ this.#ui.addSystemMessage(
322
+ `While you were away: ${this.#awayUnread} new message(s)${mentions}`,
323
+ );
324
+ }
325
+ this.#awayUnread = 0;
326
+ this.#awayMentions = 0;
254
327
  }
255
328
 
256
329
  #armAutoAway() {
@@ -273,6 +346,8 @@ export class ChatController {
273
346
  this.#away = true;
274
347
  this.#awayReason = 'away (idle)';
275
348
  this.#autoAwaySet = true;
349
+ this.#awayUnread = 0;
350
+ this.#awayMentions = 0;
276
351
  this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
277
352
  this.#ui.addSystemMessage('Auto-away: marked as away due to inactivity');
278
353
  this.#broadcastPresence();
@@ -348,6 +423,10 @@ export class ChatController {
348
423
  this.#onRoomChanged(msg);
349
424
  break;
350
425
 
426
+ case MSG.ROOM_CHALLENGE:
427
+ this.#onRoomChallenge(msg);
428
+ break;
429
+
351
430
  case MSG.ROOM_LIST:
352
431
  this.#onRoomList(msg);
353
432
  break;
@@ -365,6 +444,11 @@ export class ChatController {
365
444
  this.#ui.addErrorMessage(
366
445
  `${msg.message}. Use /nick <other> to pick a different nickname.`,
367
446
  );
447
+ } else if (msg.code === ERR.ROOM_AUTH_FAILED || msg.code === ERR.ROOM_EXISTS) {
448
+ // Join/create refused — drop the derived secrets for that attempt.
449
+ freeRoomSecrets(this.#pendingRoomSecrets);
450
+ this.#pendingRoomSecrets = null;
451
+ this.#ui.addErrorMessage(`Error: ${msg.message} (${msg.code})`);
368
452
  } else {
369
453
  this.#ui.addErrorMessage(`Error: ${msg.message} (${msg.code})`);
370
454
  }
@@ -408,6 +492,14 @@ export class ChatController {
408
492
  this.#ui.setRoom(this.#currentRoom);
409
493
  this.#currentRoomOwner = msg.roomOwner || null;
410
494
 
495
+ // (Re)joining always lands in a public room — drop any private-room keys.
496
+ if (this.#roomSecrets && this.#roomSecrets.room !== this.#currentRoom) {
497
+ freeRoomSecrets(this.#roomSecrets);
498
+ this.#roomSecrets = null;
499
+ this.#ui.removeHeaderIndicator('private');
500
+ this.#ui.addInfoMessage('Reconnected outside the private room — /join it again.');
501
+ }
502
+
411
503
  // Build map of old sessionIds by nickname for ratchet migration
412
504
  const oldSessionByNick = new Map();
413
505
  for (const [sid, peer] of this.#peers) {
@@ -453,6 +545,17 @@ export class ChatController {
453
545
  this.#connection.send(createChangeRoom(this.#inviteRoom));
454
546
  this.#inviteRoom = null;
455
547
  }
548
+
549
+ this.#saveLastSession();
550
+ }
551
+
552
+ // Remember where we are for the next launch. Privacy: in a private room only
553
+ // the server is written — the room name never touches disk.
554
+ #saveLastSession(isPrivate = false) {
555
+ saveLastSession({
556
+ server: (this.#connection.url || '').replace(/^wss?:\/\//, ''),
557
+ room: isPrivate || this.#roomSecrets ? undefined : this.#currentRoom,
558
+ });
456
559
  }
457
560
 
458
561
  // ── New peer arrived ──────────────────────────────────────────
@@ -507,6 +610,19 @@ export class ChatController {
507
610
 
508
611
  // ── Received encrypted message ────────────────────────────────
509
612
  #onEncryptedMessage(msg) {
613
+ // Sealed sender: the relay handed us only `to` + an opaque blob. Open it
614
+ // with our identity key to recover the real sender + payload; from here the
615
+ // rest of the handler is unchanged. A blob that isn't for us (or is tampered)
616
+ // simply fails to open and is dropped.
617
+ if (typeof msg.sealed === 'string') {
618
+ const opened = this.#openSealed(msg.sealed);
619
+ if (!opened || typeof opened.from !== 'string' || !opened.payload) {
620
+ return;
621
+ }
622
+ // Rebind to a fresh object — never mutate the received message.
623
+ msg = { ...msg, from: opened.from, payload: opened.payload };
624
+ }
625
+
510
626
  const peer = this.#peers.get(msg.from);
511
627
  if (!peer) {
512
628
  this.#ui.addErrorMessage('Message from unknown peer');
@@ -597,7 +713,20 @@ export class ChatController {
597
713
  }
598
714
 
599
715
  try {
600
- const data = JSON.parse(plaintext.toString('utf-8'));
716
+ let data = JSON.parse(plaintext.toString('utf-8'));
717
+
718
+ // Private-room layer: unwrap with the room key. Content we can't read
719
+ // (no key, or stale key after a room switch) is dropped silently.
720
+ if (isRoomWrapped(data)) {
721
+ if (!this.#roomSecrets) {
722
+ return;
723
+ }
724
+ const inner = decryptRoomPayload(data, this.#roomSecrets.roomKey);
725
+ if (!inner) {
726
+ return;
727
+ }
728
+ data = JSON.parse(inner);
729
+ }
601
730
 
602
731
  // Cover traffic: a decoy — drop it silently (no UI, no history, no receipt).
603
732
  if (isCover(data)) {
@@ -828,6 +957,28 @@ export class ChatController {
828
957
  }
829
958
 
830
959
  const mentioned = this.#mentionsMe(data.text) && !data.isDM;
960
+ if (mentioned) {
961
+ this.#mentions.push({
962
+ nickname: peer.nickname,
963
+ text: data.text,
964
+ room: this.#currentRoom,
965
+ at: Date.now(),
966
+ });
967
+ if (this.#mentions.length > MENTIONS_MAX) {
968
+ this.#mentions.shift();
969
+ }
970
+ }
971
+ // Away: count what's arriving and keep the header badge live.
972
+ if (this.#away) {
973
+ this.#awayUnread++;
974
+ if (mentioned) {
975
+ this.#awayMentions++;
976
+ }
977
+ this.#ui.setHeaderIndicator(
978
+ 'away',
979
+ `{yellow-fg}[away · ${this.#awayUnread} new]{/yellow-fg}`,
980
+ );
981
+ }
831
982
  const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
832
983
  const trust = trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
833
984
  const { lineIndex } = this.#ui.addMessage(
@@ -912,11 +1063,18 @@ export class ChatController {
912
1063
  this.#ui.addInfoMessage(' /users - List online users');
913
1064
  this.#ui.addInfoMessage(' /msg <nick> <text> - Send a private message (DM)');
914
1065
  this.#ui.addInfoMessage(' /reply <text> - Reply to the last received message');
915
- this.#ui.addInfoMessage(' /away [reason] - Mark yourself as away');
1066
+ this.#ui.addInfoMessage(' /mentions [n] - Recent mentions of you (this session)');
1067
+ this.#ui.addInfoMessage(' /contacts [add|remove|all] - Contact book (aliases for peers)');
1068
+ this.#ui.addInfoMessage(
1069
+ ' /away [reason] - Mark yourself as away (unreads are counted)',
1070
+ );
916
1071
  this.#ui.addInfoMessage(' /back - Clear the away status');
917
1072
  this.#ui.addInfoMessage(' /autoaway <min|off> - Auto-away on inactivity');
1073
+ this.#ui.addInfoMessage(' /lock - Lock the screen (session passphrase)');
1074
+ this.#ui.addInfoMessage(' /autolock <min|off> - Auto-lock on inactivity');
918
1075
  this.#ui.addInfoMessage(' /status <text|off> - Set a status (accepts :emoji:)');
919
- this.#ui.addInfoMessage(' /join <room> - Join a room');
1076
+ this.#ui.addInfoMessage(' /join <room> [pass] - Join a room (password if private)');
1077
+ this.#ui.addInfoMessage(' /create <room> <pass> - Create a private room 🔒');
920
1078
  this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
921
1079
  this.#ui.addInfoMessage(' /rooms - List available rooms');
922
1080
  this.#ui.addInfoMessage(' /room - Show the current room');
@@ -969,6 +1127,10 @@ export class ChatController {
969
1127
  case '/users': {
970
1128
  const names = [...this.#peers.values()].map((p) => {
971
1129
  let label = p.nickname;
1130
+ const alias = this.#trustStore.getAlias(p.nickname);
1131
+ if (alias) {
1132
+ label += ` (${alias})`;
1133
+ }
972
1134
  if (p.away) {
973
1135
  label += ` [away${p.awayReason ? `: ${p.awayReason}` : ''}]`;
974
1136
  }
@@ -1179,10 +1341,38 @@ export class ChatController {
1179
1341
  case '/join': {
1180
1342
  const roomName = parts[1];
1181
1343
  if (!roomName) {
1182
- this.#ui.addErrorMessage('Usage: /join <room>');
1344
+ this.#ui.addErrorMessage('Usage: /join <room> [password]');
1183
1345
  break;
1184
1346
  }
1185
- this.#connection.send(createChangeRoom(roomName));
1347
+ const joinPassword = parts.slice(2).join(' ');
1348
+ if (joinPassword) {
1349
+ // Derive now so we can answer the server's challenge immediately.
1350
+ this.#prepareRoomSecrets(roomName, joinPassword, () => {
1351
+ this.#connection.send(createChangeRoom(roomName));
1352
+ });
1353
+ } else {
1354
+ this.#connection.send(createChangeRoom(roomName));
1355
+ }
1356
+ break;
1357
+ }
1358
+
1359
+ case '/create': {
1360
+ const roomName = parts[1];
1361
+ if (!roomName) {
1362
+ this.#ui.addErrorMessage('Usage: /create <room> <password>');
1363
+ break;
1364
+ }
1365
+ const createPassword = parts.slice(2).join(' ');
1366
+ if (!createPassword) {
1367
+ // No password — same as joining/creating a public room.
1368
+ this.#connection.send(createChangeRoom(roomName));
1369
+ break;
1370
+ }
1371
+ this.#prepareRoomSecrets(roomName, createPassword, (secrets) => {
1372
+ this.#connection.send(
1373
+ createChangeRoom(roomName, secrets.authPublicKey.toString('base64')),
1374
+ );
1375
+ });
1186
1376
  break;
1187
1377
  }
1188
1378
 
@@ -1219,7 +1409,9 @@ export class ChatController {
1219
1409
  }
1220
1410
 
1221
1411
  case '/room':
1222
- this.#ui.addInfoMessage(`Current room: #${this.#currentRoom}`);
1412
+ this.#ui.addInfoMessage(
1413
+ `Current room: #${this.#currentRoom}${this.#roomSecrets ? ' 🔒 (private)' : ''}`,
1414
+ );
1223
1415
  break;
1224
1416
 
1225
1417
  case '/tips': {
@@ -1250,6 +1442,8 @@ export class ChatController {
1250
1442
  case '/away': {
1251
1443
  this.#away = true;
1252
1444
  this.#autoAwaySet = false; // an explicit /away is not auto
1445
+ this.#awayUnread = 0;
1446
+ this.#awayMentions = 0;
1253
1447
  this.#awayReason = applyShortcodes(parts.slice(1).join(' ')).slice(0, 60) || null;
1254
1448
  this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
1255
1449
  this.#ui.addInfoMessage(
@@ -1269,10 +1463,124 @@ export class ChatController {
1269
1463
  this.#autoAwaySet = false;
1270
1464
  this.#ui.removeHeaderIndicator('away');
1271
1465
  this.#ui.addInfoMessage("You're back");
1466
+ this.#reportAwayUnread();
1272
1467
  this.#broadcastPresence();
1273
1468
  break;
1274
1469
  }
1275
1470
 
1471
+ case '/contacts': {
1472
+ const sub = (parts[1] || 'list').toLowerCase();
1473
+
1474
+ if (sub === 'add') {
1475
+ const nick = parts[2];
1476
+ const alias = parts.slice(3).join(' ').trim();
1477
+ if (!nick || !alias) {
1478
+ this.#ui.addErrorMessage('Usage: /contacts add <nick> <alias>');
1479
+ break;
1480
+ }
1481
+ if (this.#trustStore.setAlias(nick, alias)) {
1482
+ this.#ui.addInfoMessage(`Contact saved: ${nick} → "${alias.slice(0, 30)}"`);
1483
+ } else {
1484
+ this.#ui.addErrorMessage(
1485
+ `"${nick}" was never seen on this identity — no trust record to alias`,
1486
+ );
1487
+ }
1488
+ break;
1489
+ }
1490
+
1491
+ if (sub === 'remove') {
1492
+ const nick = parts[2];
1493
+ if (!nick) {
1494
+ this.#ui.addErrorMessage('Usage: /contacts remove <nick>');
1495
+ break;
1496
+ }
1497
+ if (this.#trustStore.clearAlias(nick)) {
1498
+ this.#ui.addInfoMessage(`Alias removed from ${nick}`);
1499
+ } else {
1500
+ this.#ui.addErrorMessage(`${nick} has no alias`);
1501
+ }
1502
+ break;
1503
+ }
1504
+
1505
+ if (sub !== 'list' && sub !== 'all') {
1506
+ this.#ui.addErrorMessage('Usage: /contacts [add <nick> <alias> | remove <nick> | all]');
1507
+ break;
1508
+ }
1509
+
1510
+ const contacts = this.#trustStore.listContacts(sub === 'all');
1511
+ if (contacts.length === 0) {
1512
+ this.#ui.addInfoMessage(
1513
+ sub === 'all'
1514
+ ? 'No peers known yet.'
1515
+ : 'No contacts yet. Use /contacts add <nick> <alias>',
1516
+ );
1517
+ break;
1518
+ }
1519
+ this.#ui.addInfoMessage(sub === 'all' ? 'Known peers:' : 'Contacts:');
1520
+ for (const c of contacts) {
1521
+ const badge = c.verified ? ' ✓' : '';
1522
+ const alias = c.alias ? ` (${c.alias})` : '';
1523
+ const seen = c.lastSeen
1524
+ ? ` — last seen ${new Date(c.lastSeen).toLocaleString('en-US', {
1525
+ day: '2-digit',
1526
+ month: '2-digit',
1527
+ hour: '2-digit',
1528
+ minute: '2-digit',
1529
+ })}`
1530
+ : '';
1531
+ this.#ui.addInfoMessage(` ${c.nickname}${alias}${badge}${seen}`);
1532
+ }
1533
+ break;
1534
+ }
1535
+
1536
+ case '/mentions': {
1537
+ if (this.#mentions.length === 0) {
1538
+ this.#ui.addInfoMessage('No mentions in this session yet.');
1539
+ break;
1540
+ }
1541
+ const count = Math.min(parseInt(parts[1], 10) || 10, this.#mentions.length);
1542
+ this.#ui.addInfoMessage(`Last ${count} mention(s) of you:`);
1543
+ for (const m of this.#mentions.slice(-count)) {
1544
+ const when = new Date(m.at).toLocaleString('en-US', {
1545
+ hour: '2-digit',
1546
+ minute: '2-digit',
1547
+ });
1548
+ this.#ui.addInfoMessage(` [${when}] [#${m.room}] ${m.nickname}: ${m.text.slice(0, 80)}`);
1549
+ }
1550
+ break;
1551
+ }
1552
+
1553
+ case '/lock':
1554
+ this.#lockNow();
1555
+ break;
1556
+
1557
+ case '/autolock': {
1558
+ const alArg = parts[1]?.toLowerCase();
1559
+ if (alArg === 'off' || alArg === '0') {
1560
+ this.#autoLockMs = 0;
1561
+ this.#armAutoLock();
1562
+ this.#ui.addInfoMessage('Auto-lock disabled');
1563
+ break;
1564
+ }
1565
+ const alMin = parseInt(alArg, 10);
1566
+ if (!Number.isInteger(alMin) || alMin < 1 || alMin > 240) {
1567
+ this.#ui.addInfoMessage(
1568
+ `Auto-lock: ${this.#autoLockMs ? `${this.#autoLockMs / 60000}min` : 'off'}. Usage: /autolock <minutes|off>`,
1569
+ );
1570
+ break;
1571
+ }
1572
+ if (!this.#passphrase) {
1573
+ this.#ui.addErrorMessage(
1574
+ 'No session passphrase — auto-lock needs one (set it at startup)',
1575
+ );
1576
+ break;
1577
+ }
1578
+ this.#autoLockMs = alMin * 60_000;
1579
+ this.#armAutoLock();
1580
+ this.#ui.addInfoMessage(`Auto-lock after ${alMin}min of inactivity`);
1581
+ break;
1582
+ }
1583
+
1276
1584
  case '/autoaway': {
1277
1585
  const aaArg = parts[1]?.toLowerCase();
1278
1586
  if (aaArg === 'off' || aaArg === '0') {
@@ -1900,7 +2208,15 @@ export class ChatController {
1900
2208
  if (this.#pluginManager) {
1901
2209
  const result = this.#pluginManager.handleCommand(cmd, parts.slice(1));
1902
2210
  if (result) {
1903
- this.#ui.addInfoMessage(result);
2211
+ // Plugin API: `{ send }` goes to the room as a normal E2EE
2212
+ // message; `{ info }` or a plain string stays local.
2213
+ if (typeof result === 'object' && typeof result.send === 'string' && result.send) {
2214
+ this.#sendMessageToAll(result.send);
2215
+ } else if (typeof result === 'object' && typeof result.info === 'string') {
2216
+ this.#ui.addInfoMessage(result.info);
2217
+ } else if (typeof result === 'string') {
2218
+ this.#ui.addInfoMessage(result);
2219
+ }
1904
2220
  break;
1905
2221
  }
1906
2222
  }
@@ -1989,12 +2305,68 @@ export class ChatController {
1989
2305
  this.#ui.addSystemMessage(`${peer.nickname} updated key (via server — unauthenticated)`);
1990
2306
  }
1991
2307
 
2308
+ // ── Private rooms: derive secrets off the input handler ─────
2309
+ // Argon2id (MODERATE) blocks for ~1s — let the UI paint the notice first.
2310
+ #prepareRoomSecrets(roomName, password, onReady) {
2311
+ const room = roomName.toLowerCase();
2312
+ this.#ui.addInfoMessage('Deriving room key (Argon2id)…');
2313
+ setImmediate(() => {
2314
+ freeRoomSecrets(this.#pendingRoomSecrets);
2315
+ const secrets = deriveRoomSecrets(room, password);
2316
+ this.#pendingRoomSecrets = { room, ...secrets };
2317
+ onReady(secrets);
2318
+ });
2319
+ }
2320
+
2321
+ // ── Handle ROOM_CHALLENGE (target room is private) ──────────
2322
+ #onRoomChallenge(msg) {
2323
+ const pending = this.#pendingRoomSecrets;
2324
+ if (!pending || pending.room !== msg.room) {
2325
+ this.#ui.addErrorMessage(`Room #${msg.room} is private. Usage: /join ${msg.room} <password>`);
2326
+ return;
2327
+ }
2328
+ const signature = signRoomChallenge(
2329
+ pending.authSecretKey,
2330
+ msg.room,
2331
+ msg.nonce,
2332
+ this.#sessionId,
2333
+ );
2334
+ this.#connection.send(createRoomAuth(msg.room, msg.nonce, signature.toString('base64')));
2335
+ }
2336
+
1992
2337
  // ── Handle ROOM_CHANGED (after /join) ──────────────────────
1993
2338
  #onRoomChanged(msg) {
1994
2339
  this.#currentRoom = msg.room;
1995
2340
  this.#ui.setRoom(this.#currentRoom);
1996
2341
  this.#currentRoomOwner = msg.roomOwner || null;
1997
2342
 
2343
+ // Rotate private-room secrets: drop the old room's, promote the pending
2344
+ // ones when the server confirms the new room is private.
2345
+ if (this.#roomSecrets) {
2346
+ freeRoomSecrets(this.#roomSecrets);
2347
+ this.#roomSecrets = null;
2348
+ }
2349
+ if (this.#pendingRoomSecrets?.room === msg.room) {
2350
+ if (msg.private) {
2351
+ this.#roomSecrets = this.#pendingRoomSecrets;
2352
+ } else {
2353
+ // Old server without private-room support silently made it public.
2354
+ freeRoomSecrets(this.#pendingRoomSecrets);
2355
+ this.#ui.addErrorMessage(
2356
+ 'WARNING: this server does not support private rooms — the room is PUBLIC and anyone can join.',
2357
+ );
2358
+ }
2359
+ this.#pendingRoomSecrets = null;
2360
+ } else if (this.#pendingRoomSecrets) {
2361
+ freeRoomSecrets(this.#pendingRoomSecrets);
2362
+ this.#pendingRoomSecrets = null;
2363
+ }
2364
+ if (this.#roomSecrets) {
2365
+ this.#ui.setHeaderIndicator('private', '{green-fg}[🔒]{/green-fg}');
2366
+ } else {
2367
+ this.#ui.removeHeaderIndicator('private');
2368
+ }
2369
+
1998
2370
  // Clear old peers and pins
1999
2371
  this.#peers.clear();
2000
2372
  this.#pinnedMessages = [];
@@ -2018,7 +2390,12 @@ export class ChatController {
2018
2390
  this.#ui.setOnlineCount(this.#peers.size + 1);
2019
2391
  this.#ui.setPeerNames(peerNames);
2020
2392
  this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room });
2021
- this.#ui.addSystemMessage(`You joined room #${msg.room}`);
2393
+ if (this.#roomSecrets) {
2394
+ this.#ui.addSystemMessage(`You joined private room #${msg.room} 🔒`);
2395
+ this.#ui.addInfoMessage('Messages here get an extra layer encrypted with the room key.');
2396
+ } else {
2397
+ this.#ui.addSystemMessage(`You joined room #${msg.room}`);
2398
+ }
2022
2399
 
2023
2400
  if (peerNames.length > 0) {
2024
2401
  this.#ui.addSystemMessage(`Online: ${peerNames.join(', ')}`);
@@ -2028,6 +2405,8 @@ export class ChatController {
2028
2405
  if (this.#away || this.#statusText) {
2029
2406
  this.#broadcastPresence();
2030
2407
  }
2408
+
2409
+ this.#saveLastSession(!!msg.private);
2031
2410
  }
2032
2411
 
2033
2412
  // ── Handle ROOM_LIST ───────────────────────────────────────
@@ -2035,7 +2414,8 @@ export class ChatController {
2035
2414
  this.#ui.addInfoMessage('Available rooms:');
2036
2415
  for (const room of msg.rooms) {
2037
2416
  const current = room.name === this.#currentRoom ? ' (current)' : '';
2038
- this.#ui.addInfoMessage(` #${room.name} ${room.memberCount} member(s)${current}`);
2417
+ const lock = room.private ? ' 🔒' : '';
2418
+ this.#ui.addInfoMessage(` #${room.name}${lock} — ${room.memberCount} member(s)${current}`);
2039
2419
  }
2040
2420
  }
2041
2421
 
@@ -2121,6 +2501,28 @@ export class ChatController {
2121
2501
  }
2122
2502
  }
2123
2503
 
2504
+ // Sealed sender: wrap an outgoing wire message so the relay sees only `to` and
2505
+ // an opaque blob. The sender identity (`from`) + the payload are sealed to the
2506
+ // recipient's key — only they can open it.
2507
+ #sealAndSend(recipientPublicKey, wireMsg) {
2508
+ const sealed = sealEnvelope(wireMsg.from, wireMsg.payload, recipientPublicKey);
2509
+ this.#connection.send(createSealedMessage(wireMsg.to, sealed));
2510
+ }
2511
+
2512
+ // Open a sealed envelope with our identity key, falling back to the previous
2513
+ // key during the post-rotation grace window. Returns { from, payload } or null.
2514
+ #openSealed(sealedB64) {
2515
+ let opened = openEnvelope(sealedB64, this.#keyManager.publicKey, this.#keyManager.secretKey);
2516
+ if (!opened && this.#keyManager.previousPublicKey) {
2517
+ opened = openEnvelope(
2518
+ sealedB64,
2519
+ this.#keyManager.previousPublicKey,
2520
+ this.#keyManager.previousSecretKey,
2521
+ );
2522
+ }
2523
+ return opened;
2524
+ }
2525
+
2124
2526
  // ── Send encrypted payload to a single peer ────────────────────
2125
2527
  #sendPayloadToPeer(peerId, payload) {
2126
2528
  const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
@@ -2128,11 +2530,16 @@ export class ChatController {
2128
2530
  return;
2129
2531
  }
2130
2532
 
2533
+ // Private room: extra symmetric layer under the pairwise encryption.
2534
+ if (this.#roomSecrets) {
2535
+ payload = encryptRoomPayload(payload, this.#roomSecrets.roomKey);
2536
+ }
2537
+
2131
2538
  const ratchet = this.#handshake.getRatchet(peerId);
2132
2539
  if (ratchet && ratchet.isInitialized) {
2133
2540
  try {
2134
2541
  const result = ratchet.encrypt(payload);
2135
- this.#connection.send(createRatchetedMessage(this.#sessionId, peerId, result));
2542
+ this.#sealAndSend(peerPublicKey, createRatchetedMessage(this.#sessionId, peerId, result));
2136
2543
  return;
2137
2544
  } catch {
2138
2545
  // Fall through to static path
@@ -2146,7 +2553,8 @@ export class ChatController {
2146
2553
  peerPublicKey,
2147
2554
  this.#handshake.secretKey,
2148
2555
  );
2149
- this.#connection.send(
2556
+ this.#sealAndSend(
2557
+ peerPublicKey,
2150
2558
  createEncryptedMessage(
2151
2559
  this.#sessionId,
2152
2560
  peerId,
@@ -2244,6 +2652,12 @@ export class ChatController {
2244
2652
 
2245
2653
  // ── Broadcast encrypted payload to all peers ───────────────────
2246
2654
  #broadcastPayload(payload, deniable = false) {
2655
+ // Private room: extra symmetric layer under the pairwise encryption, so
2656
+ // even a relay-injected member can't read the room without the password.
2657
+ if (this.#roomSecrets) {
2658
+ payload = encryptRoomPayload(payload, this.#roomSecrets.roomKey);
2659
+ }
2660
+
2247
2661
  for (const [peerId] of this.#peers) {
2248
2662
  const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
2249
2663
  if (!peerPublicKey) {
@@ -2262,7 +2676,7 @@ export class ChatController {
2262
2676
  nonce.toString('base64'),
2263
2677
  );
2264
2678
  msg.payload.deniable = true;
2265
- this.#connection.send(msg);
2679
+ this.#sealAndSend(peerPublicKey, msg);
2266
2680
  continue;
2267
2681
  }
2268
2682
 
@@ -2271,7 +2685,7 @@ export class ChatController {
2271
2685
  if (ratchet && ratchet.isInitialized) {
2272
2686
  try {
2273
2687
  const result = ratchet.encrypt(payload);
2274
- this.#connection.send(createRatchetedMessage(this.#sessionId, peerId, result));
2688
+ this.#sealAndSend(peerPublicKey, createRatchetedMessage(this.#sessionId, peerId, result));
2275
2689
  continue;
2276
2690
  } catch {
2277
2691
  // Ratchet failed — fall through to static path
@@ -2287,7 +2701,8 @@ export class ChatController {
2287
2701
  this.#handshake.secretKey,
2288
2702
  );
2289
2703
 
2290
- this.#connection.send(
2704
+ this.#sealAndSend(
2705
+ peerPublicKey,
2291
2706
  createEncryptedMessage(
2292
2707
  this.#sessionId,
2293
2708
  peerId,