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.
@@ -12,6 +12,7 @@ import {
12
12
  createSealedMessage,
13
13
  createKeyUpdate,
14
14
  createChangeRoom,
15
+ createRoomAuth,
15
16
  createListRooms,
16
17
  createKickPeer,
17
18
  createMutePeer,
@@ -19,6 +20,14 @@ import {
19
20
  ERR,
20
21
  } from '../protocol/messages.js';
21
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';
22
31
  import { KEY_ROTATION_INTERVAL_MS, EMOJI_MAP, COVER_CONSTANT_MS } from '../shared/constants.js';
23
32
  import { KeyManager } from '../crypto/KeyManager.js';
24
33
  import { Handshake } from '../crypto/Handshake.js';
@@ -43,10 +52,12 @@ import { setTheme, getThemeName, themeNames } from '../shared/themes.js';
43
52
  import { panicWipe } from '../shared/panic.js';
44
53
  import { farewellBanner } from '../shared/banner.js';
45
54
  import { parseDndWindow, shouldNotify, nowMinutes, mentionsMe } from '../shared/dnd.js';
55
+ import { saveLastSession } from '../shared/lastSession.js';
46
56
  import { COMMANDS } from './UI.js';
47
57
 
48
58
  const TYPING_SEND_INTERVAL = 2000; // debounce: max 1 typing event per 2s
49
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)
50
61
 
51
62
  export class ChatController {
52
63
  #nickname;
@@ -98,6 +109,13 @@ export class ChatController {
98
109
  #autoAwayMs = 0; // idle timeout in ms (0 = off)
99
110
  #autoAwayTimer = null;
100
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
101
119
 
102
120
  constructor(
103
121
  nickname,
@@ -239,6 +257,16 @@ export class ChatController {
239
257
  this.destroy();
240
258
  process.exit(0);
241
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
+ });
242
270
  }
243
271
 
244
272
  // ── Auto-away (idle) ────────────────────────────────────────
@@ -250,9 +278,52 @@ export class ChatController {
250
278
  this.#autoAwaySet = false;
251
279
  this.#ui.removeHeaderIndicator('away');
252
280
  this.#ui.addSystemMessage("You're back (auto)");
281
+ this.#reportAwayUnread();
253
282
  this.#broadcastPresence();
254
283
  }
255
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;
256
327
  }
257
328
 
258
329
  #armAutoAway() {
@@ -275,6 +346,8 @@ export class ChatController {
275
346
  this.#away = true;
276
347
  this.#awayReason = 'away (idle)';
277
348
  this.#autoAwaySet = true;
349
+ this.#awayUnread = 0;
350
+ this.#awayMentions = 0;
278
351
  this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
279
352
  this.#ui.addSystemMessage('Auto-away: marked as away due to inactivity');
280
353
  this.#broadcastPresence();
@@ -350,6 +423,10 @@ export class ChatController {
350
423
  this.#onRoomChanged(msg);
351
424
  break;
352
425
 
426
+ case MSG.ROOM_CHALLENGE:
427
+ this.#onRoomChallenge(msg);
428
+ break;
429
+
353
430
  case MSG.ROOM_LIST:
354
431
  this.#onRoomList(msg);
355
432
  break;
@@ -367,6 +444,11 @@ export class ChatController {
367
444
  this.#ui.addErrorMessage(
368
445
  `${msg.message}. Use /nick <other> to pick a different nickname.`,
369
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})`);
370
452
  } else {
371
453
  this.#ui.addErrorMessage(`Error: ${msg.message} (${msg.code})`);
372
454
  }
@@ -410,6 +492,14 @@ export class ChatController {
410
492
  this.#ui.setRoom(this.#currentRoom);
411
493
  this.#currentRoomOwner = msg.roomOwner || null;
412
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
+
413
503
  // Build map of old sessionIds by nickname for ratchet migration
414
504
  const oldSessionByNick = new Map();
415
505
  for (const [sid, peer] of this.#peers) {
@@ -455,6 +545,17 @@ export class ChatController {
455
545
  this.#connection.send(createChangeRoom(this.#inviteRoom));
456
546
  this.#inviteRoom = null;
457
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
+ });
458
559
  }
459
560
 
460
561
  // ── New peer arrived ──────────────────────────────────────────
@@ -612,7 +713,20 @@ export class ChatController {
612
713
  }
613
714
 
614
715
  try {
615
- 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
+ }
616
730
 
617
731
  // Cover traffic: a decoy — drop it silently (no UI, no history, no receipt).
618
732
  if (isCover(data)) {
@@ -843,6 +957,28 @@ export class ChatController {
843
957
  }
844
958
 
845
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
+ }
846
982
  const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
847
983
  const trust = trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
848
984
  const { lineIndex } = this.#ui.addMessage(
@@ -927,11 +1063,18 @@ export class ChatController {
927
1063
  this.#ui.addInfoMessage(' /users - List online users');
928
1064
  this.#ui.addInfoMessage(' /msg <nick> <text> - Send a private message (DM)');
929
1065
  this.#ui.addInfoMessage(' /reply <text> - Reply to the last received message');
930
- 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
+ );
931
1071
  this.#ui.addInfoMessage(' /back - Clear the away status');
932
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');
933
1075
  this.#ui.addInfoMessage(' /status <text|off> - Set a status (accepts :emoji:)');
934
- 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 🔒');
935
1078
  this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
936
1079
  this.#ui.addInfoMessage(' /rooms - List available rooms');
937
1080
  this.#ui.addInfoMessage(' /room - Show the current room');
@@ -984,6 +1127,10 @@ export class ChatController {
984
1127
  case '/users': {
985
1128
  const names = [...this.#peers.values()].map((p) => {
986
1129
  let label = p.nickname;
1130
+ const alias = this.#trustStore.getAlias(p.nickname);
1131
+ if (alias) {
1132
+ label += ` (${alias})`;
1133
+ }
987
1134
  if (p.away) {
988
1135
  label += ` [away${p.awayReason ? `: ${p.awayReason}` : ''}]`;
989
1136
  }
@@ -1194,10 +1341,38 @@ export class ChatController {
1194
1341
  case '/join': {
1195
1342
  const roomName = parts[1];
1196
1343
  if (!roomName) {
1197
- this.#ui.addErrorMessage('Usage: /join <room>');
1344
+ this.#ui.addErrorMessage('Usage: /join <room> [password]');
1345
+ break;
1346
+ }
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));
1198
1369
  break;
1199
1370
  }
1200
- this.#connection.send(createChangeRoom(roomName));
1371
+ this.#prepareRoomSecrets(roomName, createPassword, (secrets) => {
1372
+ this.#connection.send(
1373
+ createChangeRoom(roomName, secrets.authPublicKey.toString('base64')),
1374
+ );
1375
+ });
1201
1376
  break;
1202
1377
  }
1203
1378
 
@@ -1234,7 +1409,9 @@ export class ChatController {
1234
1409
  }
1235
1410
 
1236
1411
  case '/room':
1237
- this.#ui.addInfoMessage(`Current room: #${this.#currentRoom}`);
1412
+ this.#ui.addInfoMessage(
1413
+ `Current room: #${this.#currentRoom}${this.#roomSecrets ? ' 🔒 (private)' : ''}`,
1414
+ );
1238
1415
  break;
1239
1416
 
1240
1417
  case '/tips': {
@@ -1265,6 +1442,8 @@ export class ChatController {
1265
1442
  case '/away': {
1266
1443
  this.#away = true;
1267
1444
  this.#autoAwaySet = false; // an explicit /away is not auto
1445
+ this.#awayUnread = 0;
1446
+ this.#awayMentions = 0;
1268
1447
  this.#awayReason = applyShortcodes(parts.slice(1).join(' ')).slice(0, 60) || null;
1269
1448
  this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
1270
1449
  this.#ui.addInfoMessage(
@@ -1284,10 +1463,124 @@ export class ChatController {
1284
1463
  this.#autoAwaySet = false;
1285
1464
  this.#ui.removeHeaderIndicator('away');
1286
1465
  this.#ui.addInfoMessage("You're back");
1466
+ this.#reportAwayUnread();
1287
1467
  this.#broadcastPresence();
1288
1468
  break;
1289
1469
  }
1290
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
+
1291
1584
  case '/autoaway': {
1292
1585
  const aaArg = parts[1]?.toLowerCase();
1293
1586
  if (aaArg === 'off' || aaArg === '0') {
@@ -1915,7 +2208,15 @@ export class ChatController {
1915
2208
  if (this.#pluginManager) {
1916
2209
  const result = this.#pluginManager.handleCommand(cmd, parts.slice(1));
1917
2210
  if (result) {
1918
- 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
+ }
1919
2220
  break;
1920
2221
  }
1921
2222
  }
@@ -2004,12 +2305,68 @@ export class ChatController {
2004
2305
  this.#ui.addSystemMessage(`${peer.nickname} updated key (via server — unauthenticated)`);
2005
2306
  }
2006
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
+
2007
2337
  // ── Handle ROOM_CHANGED (after /join) ──────────────────────
2008
2338
  #onRoomChanged(msg) {
2009
2339
  this.#currentRoom = msg.room;
2010
2340
  this.#ui.setRoom(this.#currentRoom);
2011
2341
  this.#currentRoomOwner = msg.roomOwner || null;
2012
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
+
2013
2370
  // Clear old peers and pins
2014
2371
  this.#peers.clear();
2015
2372
  this.#pinnedMessages = [];
@@ -2033,7 +2390,12 @@ export class ChatController {
2033
2390
  this.#ui.setOnlineCount(this.#peers.size + 1);
2034
2391
  this.#ui.setPeerNames(peerNames);
2035
2392
  this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room });
2036
- 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
+ }
2037
2399
 
2038
2400
  if (peerNames.length > 0) {
2039
2401
  this.#ui.addSystemMessage(`Online: ${peerNames.join(', ')}`);
@@ -2043,6 +2405,8 @@ export class ChatController {
2043
2405
  if (this.#away || this.#statusText) {
2044
2406
  this.#broadcastPresence();
2045
2407
  }
2408
+
2409
+ this.#saveLastSession(!!msg.private);
2046
2410
  }
2047
2411
 
2048
2412
  // ── Handle ROOM_LIST ───────────────────────────────────────
@@ -2050,7 +2414,8 @@ export class ChatController {
2050
2414
  this.#ui.addInfoMessage('Available rooms:');
2051
2415
  for (const room of msg.rooms) {
2052
2416
  const current = room.name === this.#currentRoom ? ' (current)' : '';
2053
- 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}`);
2054
2419
  }
2055
2420
  }
2056
2421
 
@@ -2165,6 +2530,11 @@ export class ChatController {
2165
2530
  return;
2166
2531
  }
2167
2532
 
2533
+ // Private room: extra symmetric layer under the pairwise encryption.
2534
+ if (this.#roomSecrets) {
2535
+ payload = encryptRoomPayload(payload, this.#roomSecrets.roomKey);
2536
+ }
2537
+
2168
2538
  const ratchet = this.#handshake.getRatchet(peerId);
2169
2539
  if (ratchet && ratchet.isInitialized) {
2170
2540
  try {
@@ -2282,6 +2652,12 @@ export class ChatController {
2282
2652
 
2283
2653
  // ── Broadcast encrypted payload to all peers ───────────────────
2284
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
+
2285
2661
  for (const [peerId] of this.#peers) {
2286
2662
  const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
2287
2663
  if (!peerPublicKey) {