ciphermesh 2.1.0 → 2.3.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.
@@ -11,7 +11,8 @@ import {
11
11
  createRatchetedMessage,
12
12
  createSealedMessage,
13
13
  createKeyUpdate,
14
- createChangeRoom,
14
+ createJoinRoom,
15
+ createLeaveRoom,
15
16
  createRoomAuth,
16
17
  createListRooms,
17
18
  createKickPeer,
@@ -114,8 +115,12 @@ export class ChatController {
114
115
  #awayMentions = 0; // …of which mentioned me
115
116
  #autoLockMs = 0; // idle screen-lock timeout (0 = off)
116
117
  #autoLockTimer = null;
117
- #roomSecrets = null; // active private-room secrets { room, authSecretKey, roomKey, … }
118
- #pendingRoomSecrets = null; // derived while joining/creating, promoted on ROOM_CHANGED
118
+ // Multi-room buffers (IRC style): lines live in the UI; membership, unread
119
+ // counters and private-room secrets live here, one entry per joined room.
120
+ #buffers = new Map(); // room → { unread, mentions, private, owner, secrets, pins }
121
+ #bufferOrder = []; // Alt+1..9 order
122
+ #allPeers = new Map(); // sessionId → { nickname, publicKey, rooms: Set } (all my rooms)
123
+ #pendingRoomSecrets = null; // derived while joining/creating, promoted on join
119
124
 
120
125
  constructor(
121
126
  nickname,
@@ -201,7 +206,9 @@ export class ChatController {
201
206
  // attached the listener).
202
207
  #onConnected() {
203
208
  this.#ui.setConnectionState('online');
204
- this.#connection.send(createJoin(this.#nickname, this.#keyManager.publicKeyB64));
209
+ this.#connection.send(
210
+ createJoin(this.#nickname, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
211
+ );
205
212
  }
206
213
 
207
214
  // ── Connection event handlers ─────────────────────────────────
@@ -267,6 +274,13 @@ export class ChatController {
267
274
  this.#ui.on('lock-failed', () => {
268
275
  this.#auditLog.log(AuditEvent.SCREEN_UNLOCK_FAILED, {});
269
276
  });
277
+
278
+ this.#ui.on('buffer-switch', (idx) => {
279
+ const room = this.#bufferOrder[idx];
280
+ if (room) {
281
+ this.#switchToBuffer(room);
282
+ }
283
+ });
270
284
  }
271
285
 
272
286
  // ── Auto-away (idle) ────────────────────────────────────────
@@ -423,6 +437,14 @@ export class ChatController {
423
437
  this.#onRoomChanged(msg);
424
438
  break;
425
439
 
440
+ case MSG.ROOM_JOINED:
441
+ this.#onRoomJoined(msg);
442
+ break;
443
+
444
+ case MSG.ROOM_LEFT:
445
+ this.#onRoomLeft(msg);
446
+ break;
447
+
426
448
  case MSG.ROOM_CHALLENGE:
427
449
  this.#onRoomChallenge(msg);
428
450
  break;
@@ -488,29 +510,27 @@ export class ChatController {
488
510
  // ── JOIN_ACK: registered with server ──────────────────────────
489
511
  #onJoinAck(msg) {
490
512
  this.#sessionId = msg.sessionId;
491
- this.#currentRoom = msg.room || 'general';
492
- this.#ui.setRoom(this.#currentRoom);
493
- this.#currentRoomOwner = msg.roomOwner || null;
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
- }
513
+ const room = msg.room || 'general';
514
+ const hadPrivateBuffers = [...this.#buffers.values()].some((b) => b.secrets);
502
515
 
503
516
  // Build map of old sessionIds by nickname for ratchet migration
504
517
  const oldSessionByNick = new Map();
505
- for (const [sid, peer] of this.#peers) {
518
+ for (const [sid, peer] of this.#allPeers) {
506
519
  oldSessionByNick.set(peer.nickname.toLowerCase(), sid);
507
520
  }
508
- this.#peers.clear();
521
+
522
+ // (Re)connecting always starts over in a single public room.
523
+ this.#resetBuffersTo(room);
524
+ this.#currentRoomOwner = msg.roomOwner || null;
525
+ if (hadPrivateBuffers) {
526
+ this.#ui.addInfoMessage('Reconnected outside your private room(s) — /join them again.');
527
+ }
509
528
 
510
529
  for (const peer of msg.peers) {
511
- this.#peers.set(peer.sessionId, {
530
+ this.#allPeers.set(peer.sessionId, {
512
531
  nickname: peer.nickname,
513
532
  publicKey: peer.publicKey,
533
+ rooms: new Set([room]),
514
534
  });
515
535
 
516
536
  const oldSid = oldSessionByNick.get(peer.nickname.toLowerCase());
@@ -518,7 +538,7 @@ export class ChatController {
518
538
  // Migrate ratchet from old sessionId to new sessionId
519
539
  this.#handshake.migrateRatchet(oldSid, peer.sessionId);
520
540
  } else if (!oldSid) {
521
- this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
541
+ this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
522
542
  }
523
543
 
524
544
  this.#checkTrust(peer.nickname, peer.publicKey);
@@ -527,9 +547,8 @@ export class ChatController {
527
547
  // Initialize ratchets now that we have our session ID
528
548
  this.#handshake.setMySessionId(msg.sessionId);
529
549
 
550
+ this.#rebuildActivePeers();
530
551
  const peerNames = [...this.#peers.values()].map((p) => p.nickname);
531
- this.#ui.setOnlineCount(this.#peers.size + 1);
532
- this.#ui.setPeerNames(peerNames);
533
552
  this.#ui.addSystemMessage('Connected to server with E2E encryption active');
534
553
 
535
554
  if (peerNames.length > 0) {
@@ -542,7 +561,7 @@ export class ChatController {
542
561
 
543
562
  // Invite included a room — join it once after the first connect
544
563
  if (this.#inviteRoom && this.#inviteRoom !== this.#currentRoom) {
545
- this.#connection.send(createChangeRoom(this.#inviteRoom));
564
+ this.#connection.send(createJoinRoom(this.#inviteRoom));
546
565
  this.#inviteRoom = null;
547
566
  }
548
567
 
@@ -554,25 +573,189 @@ export class ChatController {
554
573
  #saveLastSession(isPrivate = false) {
555
574
  saveLastSession({
556
575
  server: (this.#connection.url || '').replace(/^wss?:\/\//, ''),
557
- room: isPrivate || this.#roomSecrets ? undefined : this.#currentRoom,
576
+ room: isPrivate || this.#activeSecrets ? undefined : this.#currentRoom,
558
577
  });
559
578
  }
560
579
 
580
+ // ── Multi-room buffer plumbing ───────────────────────────────
581
+
582
+ get #activeSecrets() {
583
+ return this.#buffers.get(this.#currentRoom)?.secrets || null;
584
+ }
585
+
586
+ #ensureBuffer(room, { isPrivate = false, owner = null, secrets = null } = {}) {
587
+ if (!this.#buffers.has(room)) {
588
+ this.#buffers.set(room, {
589
+ unread: 0,
590
+ mentions: 0,
591
+ private: isPrivate,
592
+ owner,
593
+ secrets,
594
+ pins: [],
595
+ });
596
+ this.#bufferOrder.push(room);
597
+ }
598
+ return this.#buffers.get(room);
599
+ }
600
+
601
+ #dropBufferState(room) {
602
+ const buf = this.#buffers.get(room);
603
+ if (buf) {
604
+ freeRoomSecrets(buf.secrets);
605
+ this.#buffers.delete(room);
606
+ }
607
+ this.#bufferOrder = this.#bufferOrder.filter((r) => r !== room);
608
+ this.#ui.dropBuffer(room);
609
+ }
610
+
611
+ // Forget every buffer and exist only in `room` (connect, reconnect, or a
612
+ // legacy full switch — including being kicked).
613
+ #resetBuffersTo(room, opts = {}) {
614
+ for (const buf of this.#buffers.values()) {
615
+ freeRoomSecrets(buf.secrets);
616
+ }
617
+ this.#buffers.clear();
618
+ this.#bufferOrder = [];
619
+ this.#allPeers.clear();
620
+ this.#peers.clear();
621
+ this.#currentRoom = room;
622
+ const buf = this.#ensureBuffer(room, opts);
623
+ this.#pinnedMessages = buf.pins;
624
+ this.#currentRoomOwner = buf.owner;
625
+ this.#ui.resetBuffers(room);
626
+ this.#updateBufferBar();
627
+ this.#updatePrivateIndicator();
628
+ }
629
+
630
+ #switchToBuffer(room) {
631
+ if (room === this.#currentRoom || !this.#buffers.has(room)) {
632
+ return;
633
+ }
634
+ // Sync the active-view aliases back before leaving the buffer.
635
+ const cur = this.#buffers.get(this.#currentRoom);
636
+ if (cur) {
637
+ cur.pins = this.#pinnedMessages;
638
+ cur.owner = this.#currentRoomOwner;
639
+ }
640
+ this.#currentRoom = room;
641
+ const buf = this.#buffers.get(room);
642
+ buf.unread = 0;
643
+ buf.mentions = 0;
644
+ this.#pinnedMessages = buf.pins;
645
+ this.#currentRoomOwner = buf.owner;
646
+ this.#ui.switchBuffer(room);
647
+ this.#rebuildActivePeers();
648
+ this.#updateBufferBar();
649
+ this.#updatePrivateIndicator();
650
+ this.#saveLastSession(buf.private);
651
+ }
652
+
653
+ // #peers is always "the active room's peers" so every send path stays
654
+ // room-scoped without changes. Rebuilt from the global map on switches.
655
+ #rebuildActivePeers() {
656
+ this.#peers.clear();
657
+ for (const [sid, p] of this.#allPeers) {
658
+ if (p.rooms.has(this.#currentRoom)) {
659
+ this.#peers.set(sid, { nickname: p.nickname, publicKey: p.publicKey });
660
+ }
661
+ }
662
+ this.#ui.setOnlineCount(this.#peers.size + 1);
663
+ this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
664
+ }
665
+
666
+ #updateBufferBar() {
667
+ this.#ui.setBufferBar(
668
+ this.#bufferOrder.map((room) => {
669
+ const b = this.#buffers.get(room);
670
+ return {
671
+ room,
672
+ active: room === this.#currentRoom,
673
+ unread: b?.unread || 0,
674
+ private: !!b?.private,
675
+ };
676
+ }),
677
+ );
678
+ }
679
+
680
+ #updatePrivateIndicator() {
681
+ if (this.#activeSecrets) {
682
+ this.#ui.setHeaderIndicator('private', '{green-fg}[🔒]{/green-fg}');
683
+ } else {
684
+ this.#ui.removeHeaderIndicator('private');
685
+ }
686
+ }
687
+
688
+ // Which buffer an incoming payload belongs to: its own E2EE `room` tag when
689
+ // it names a room we're in; otherwise a room shared with the sender.
690
+ #roomForIncoming(data, fromSid) {
691
+ if (typeof data.room === 'string' && this.#buffers.has(data.room)) {
692
+ return data.room;
693
+ }
694
+ const peer = this.#allPeers.get(fromSid);
695
+ if (peer?.rooms.has(this.#currentRoom)) {
696
+ return this.#currentRoom;
697
+ }
698
+ return peer?.rooms.values().next().value || this.#currentRoom;
699
+ }
700
+
701
+ // Count a message that landed in an inactive buffer.
702
+ #noteBufferUnread(room, mentioned) {
703
+ if (room === this.#currentRoom) {
704
+ return;
705
+ }
706
+ const buf = this.#buffers.get(room);
707
+ if (buf) {
708
+ buf.unread++;
709
+ if (mentioned) {
710
+ buf.mentions++;
711
+ }
712
+ this.#updateBufferBar();
713
+ }
714
+ }
715
+
716
+ // Tag an outgoing payload with the room it belongs to. Travels INSIDE the
717
+ // E2EE envelope — the relay never sees it.
718
+ #tagRoom(payloadStr) {
719
+ try {
720
+ const obj = JSON.parse(payloadStr);
721
+ if (obj && typeof obj === 'object' && !obj.room) {
722
+ obj.room = this.#currentRoom;
723
+ return JSON.stringify(obj);
724
+ }
725
+ } catch {
726
+ /* not JSON — send as is */
727
+ }
728
+ return payloadStr;
729
+ }
730
+
561
731
  // ── New peer arrived ──────────────────────────────────────────
562
732
  #onPeerJoined(msg) {
563
733
  const { peer } = msg;
564
- this.#peers.set(peer.sessionId, {
565
- nickname: peer.nickname,
566
- publicKey: peer.publicKey,
567
- });
568
- this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
734
+ const room = msg.room && this.#buffers.has(msg.room) ? msg.room : this.#currentRoom;
735
+
736
+ const existing = this.#allPeers.get(peer.sessionId);
737
+ if (existing) {
738
+ existing.rooms.add(room);
739
+ } else {
740
+ this.#allPeers.set(peer.sessionId, {
741
+ nickname: peer.nickname,
742
+ publicKey: peer.publicKey,
743
+ rooms: new Set([room]),
744
+ });
745
+ this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
746
+ }
569
747
  this.#checkTrust(peer.nickname, peer.publicKey);
748
+ this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname: peer.nickname, room });
570
749
 
571
- this.#ui.setOnlineCount(this.#peers.size + 1);
572
- this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
573
- this.#ui.handshakeConnect(peer.nickname);
574
- this.#nudgeVerify(peer.nickname);
575
- this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname: peer.nickname });
750
+ if (room === this.#currentRoom) {
751
+ this.#rebuildActivePeers();
752
+ this.#ui.handshakeConnect(peer.nickname);
753
+ this.#nudgeVerify(peer.nickname);
754
+ } else {
755
+ this.#ui.toBuffer(room, () => {
756
+ this.#ui.addSystemMessage(`${peer.nickname} joined #${room}`);
757
+ });
758
+ }
576
759
 
577
760
  // A newcomer doesn't know my presence — send only to them
578
761
  if (this.#away || this.#statusText) {
@@ -592,19 +775,32 @@ export class ChatController {
592
775
  );
593
776
  }
594
777
 
595
- // ── Peer left ─────────────────────────────────────────────────
778
+ // ── Peer left (one room, or entirely when untagged) ──────────
596
779
  #onPeerLeft(msg) {
597
- const peer = this.#peers.get(msg.sessionId);
598
- const nickname = peer?.nickname || msg.nickname || 'Unknown';
780
+ const entry = this.#allPeers.get(msg.sessionId);
781
+ const nickname = entry?.nickname || msg.nickname || 'Unknown';
782
+ const room = msg.room && this.#buffers.has(msg.room) ? msg.room : null;
599
783
 
600
- this.#hidePeerTyping(msg.sessionId, nickname);
601
- this.#handshake.removePeer(msg.sessionId);
602
- this.#nonceManager.removePeer(msg.sessionId);
603
- this.#peers.delete(msg.sessionId);
784
+ if (entry && room) {
785
+ entry.rooms.delete(room);
786
+ }
787
+ const goneEntirely = !entry || !room || entry.rooms.size === 0;
604
788
 
605
- this.#ui.setOnlineCount(this.#peers.size + 1);
606
- this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
607
- this.#ui.handshakeDisconnect(nickname);
789
+ if (goneEntirely) {
790
+ this.#hidePeerTyping(msg.sessionId, nickname);
791
+ this.#handshake.removePeer(msg.sessionId);
792
+ this.#nonceManager.removePeer(msg.sessionId);
793
+ this.#allPeers.delete(msg.sessionId);
794
+ }
795
+
796
+ if (!room || room === this.#currentRoom) {
797
+ this.#rebuildActivePeers();
798
+ this.#ui.handshakeDisconnect(nickname);
799
+ } else {
800
+ this.#ui.toBuffer(room, () => {
801
+ this.#ui.addSystemMessage(`${nickname} left #${room}`);
802
+ });
803
+ }
608
804
  this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
609
805
  }
610
806
 
@@ -623,7 +819,8 @@ export class ChatController {
623
819
  msg = { ...msg, from: opened.from, payload: opened.payload };
624
820
  }
625
821
 
626
- const peer = this.#peers.get(msg.from);
822
+ // Multi-room: the sender may live in any of my rooms, not just the active one.
823
+ const peer = this.#allPeers.get(msg.from);
627
824
  if (!peer) {
628
825
  this.#ui.addErrorMessage('Message from unknown peer');
629
826
  return;
@@ -669,6 +866,7 @@ export class ChatController {
669
866
  ephPub,
670
867
  msg.payload.counter,
671
868
  msg.payload.previousCounter,
869
+ msg.payload.pqCiphertext ? Buffer.from(msg.payload.pqCiphertext, 'base64') : null,
672
870
  );
673
871
  }
674
872
 
@@ -715,13 +913,22 @@ export class ChatController {
715
913
  try {
716
914
  let data = JSON.parse(plaintext.toString('utf-8'));
717
915
 
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.
916
+ // Private-room layer: try each private buffer's key (active room first).
917
+ // Content we can't read (no key, or stale key) is dropped silently.
720
918
  if (isRoomWrapped(data)) {
721
- if (!this.#roomSecrets) {
722
- return;
919
+ let inner = this.#activeSecrets
920
+ ? decryptRoomPayload(data, this.#activeSecrets.roomKey)
921
+ : null;
922
+ if (!inner) {
923
+ for (const buf of this.#buffers.values()) {
924
+ if (buf.secrets) {
925
+ inner = decryptRoomPayload(data, buf.secrets.roomKey);
926
+ if (inner) {
927
+ break;
928
+ }
929
+ }
930
+ }
723
931
  }
724
- const inner = decryptRoomPayload(data, this.#roomSecrets.roomKey);
725
932
  if (!inner) {
726
933
  return;
727
934
  }
@@ -733,18 +940,25 @@ export class ChatController {
733
940
  return;
734
941
  }
735
942
 
943
+ // Which buffer this belongs to (the tag rides inside the E2EE envelope).
944
+ const msgRoom = this.#roomForIncoming(data, msg.from);
945
+ const roomActive = msgRoom === this.#currentRoom;
946
+
736
947
  if (data.action === 'clear') {
737
- this.#ui.clearChat();
948
+ this.#ui.clearBuffer(msgRoom);
738
949
  return;
739
950
  }
740
951
 
741
952
  if (data.action === 'typing') {
742
- this.#showPeerTyping(msg.from, peer.nickname);
953
+ if (roomActive) {
954
+ this.#showPeerTyping(msg.from, peer.nickname);
955
+ }
743
956
  return;
744
957
  }
745
958
 
746
959
  if (data.action === 'key_rotation') {
747
960
  this.#handshake.updatePeerKey(msg.from, data.newPublicKey);
961
+ peer.publicKey = data.newPublicKey;
748
962
  const p = this.#peers.get(msg.from);
749
963
  if (p) {
750
964
  p.publicKey = data.newPublicKey;
@@ -871,13 +1085,21 @@ export class ChatController {
871
1085
  }
872
1086
 
873
1087
  if (data.action === 'presence') {
874
- const p = this.#peers.get(msg.from);
875
- if (p) {
1088
+ // Presence lives on the global peer entry so it survives buffer
1089
+ // switches; the active view (#peers) mirrors it.
1090
+ const p = peer;
1091
+ {
876
1092
  const wasAway = !!p.away;
877
1093
  const oldStatus = p.status || null;
878
1094
  p.away = !!data.away;
879
1095
  p.awayReason = typeof data.reason === 'string' ? data.reason.slice(0, 60) : null;
880
1096
  p.status = typeof data.status === 'string' ? data.status.slice(0, 60) : null;
1097
+ const view = this.#peers.get(msg.from);
1098
+ if (view) {
1099
+ view.away = p.away;
1100
+ view.awayReason = p.awayReason;
1101
+ view.status = p.status;
1102
+ }
881
1103
 
882
1104
  if (p.away && !wasAway) {
883
1105
  const why = p.awayReason ? ` (${p.awayReason})` : '';
@@ -893,15 +1115,21 @@ export class ChatController {
893
1115
  }
894
1116
 
895
1117
  if (data.action === 'reaction') {
896
- this.#ui.addSystemMessage(`${data.emoji} ${peer.nickname} reacted to a message`);
897
- this.#ui.playNotification();
1118
+ this.#ui.toBuffer(msgRoom, () => {
1119
+ this.#ui.addSystemMessage(`${data.emoji} ${peer.nickname} reacted to a message`);
1120
+ });
1121
+ if (roomActive) {
1122
+ this.#ui.playNotification();
1123
+ }
898
1124
  return;
899
1125
  }
900
1126
 
901
1127
  if (data.action === 'edit_message') {
902
1128
  const author = this.#messageAuthors.get(data.messageId);
903
1129
  if (author && author === peer.nickname) {
904
- this.#ui.addSystemMessage(`${peer.nickname} edited: ${data.newText} (edited)`);
1130
+ this.#ui.toBuffer(msgRoom, () => {
1131
+ this.#ui.addSystemMessage(`${peer.nickname} edited: ${data.newText} (edited)`);
1132
+ });
905
1133
  }
906
1134
  return;
907
1135
  }
@@ -909,28 +1137,42 @@ export class ChatController {
909
1137
  if (data.action === 'delete_message') {
910
1138
  const author = this.#messageAuthors.get(data.messageId);
911
1139
  if (author && author === peer.nickname) {
912
- this.#ui.addSystemMessage(`${peer.nickname} deleted a message`);
1140
+ this.#ui.toBuffer(msgRoom, () => {
1141
+ this.#ui.addSystemMessage(`${peer.nickname} deleted a message`);
1142
+ });
913
1143
  }
914
1144
  return;
915
1145
  }
916
1146
 
917
1147
  if (data.action === 'pin_message') {
918
- this.#pinnedMessages.push({
1148
+ const pins = roomActive ? this.#pinnedMessages : this.#buffers.get(msgRoom)?.pins;
1149
+ pins?.push({
919
1150
  messageId: data.messageId,
920
1151
  nickname: data.nickname,
921
1152
  text: data.text,
922
1153
  pinnedBy: peer.nickname,
923
1154
  pinnedAt: Date.now(),
924
1155
  });
925
- this.#ui.addSystemMessage(
926
- `\uD83D\uDCCC ${peer.nickname} pinned: "${data.text}" \u2014 ${data.nickname}`,
927
- );
1156
+ this.#ui.toBuffer(msgRoom, () => {
1157
+ this.#ui.addSystemMessage(
1158
+ `\uD83D\uDCCC ${peer.nickname} pinned: "${data.text}" \u2014 ${data.nickname}`,
1159
+ );
1160
+ });
928
1161
  return;
929
1162
  }
930
1163
 
931
1164
  if (data.action === 'unpin_message') {
932
- this.#pinnedMessages = this.#pinnedMessages.filter((p) => p.messageId !== data.messageId);
933
- this.#ui.addSystemMessage(`${peer.nickname} removed a pin`);
1165
+ if (roomActive) {
1166
+ this.#pinnedMessages = this.#pinnedMessages.filter((p) => p.messageId !== data.messageId);
1167
+ } else {
1168
+ const buf = this.#buffers.get(msgRoom);
1169
+ if (buf) {
1170
+ buf.pins = buf.pins.filter((p) => p.messageId !== data.messageId);
1171
+ }
1172
+ }
1173
+ this.#ui.toBuffer(msgRoom, () => {
1174
+ this.#ui.addSystemMessage(`${peer.nickname} removed a pin`);
1175
+ });
934
1176
  return;
935
1177
  }
936
1178
 
@@ -945,23 +1187,19 @@ export class ChatController {
945
1187
  // Persist to encrypted history — never ephemeral or deniable messages
946
1188
  if (this.#historyStore?.isOpen && !data.ephemeral && !isDeniable && !data.deniable) {
947
1189
  this.#historyStore.append({
948
- room: this.#currentRoom,
1190
+ room: msgRoom,
949
1191
  nickname: peer.nickname,
950
1192
  text: data.text,
951
1193
  isDM: !!data.isDM,
952
1194
  });
953
1195
  }
954
1196
 
955
- if (data.replyTo?.nickname && typeof data.replyTo.excerpt === 'string') {
956
- this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
957
- }
958
-
959
1197
  const mentioned = this.#mentionsMe(data.text) && !data.isDM;
960
1198
  if (mentioned) {
961
1199
  this.#mentions.push({
962
1200
  nickname: peer.nickname,
963
1201
  text: data.text,
964
- room: this.#currentRoom,
1202
+ room: msgRoom,
965
1203
  at: Date.now(),
966
1204
  });
967
1205
  if (this.#mentions.length > MENTIONS_MAX) {
@@ -981,21 +1219,31 @@ export class ChatController {
981
1219
  }
982
1220
  const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
983
1221
  const trust = trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
984
- const { lineIndex } = this.#ui.addMessage(
985
- peer.nickname,
986
- data.text,
987
- !!data.isDM,
988
- ephLabel,
989
- isDeniable || !!data.deniable,
990
- mentioned,
991
- trust,
992
- );
1222
+ // File the message into its buffer (live log when active, stored otherwise).
1223
+ let lineIndex = -1;
1224
+ this.#ui.toBuffer(msgRoom, () => {
1225
+ if (data.replyTo?.nickname && typeof data.replyTo.excerpt === 'string') {
1226
+ this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
1227
+ }
1228
+ ({ lineIndex } = this.#ui.addMessage(
1229
+ peer.nickname,
1230
+ data.text,
1231
+ !!data.isDM,
1232
+ ephLabel,
1233
+ isDeniable || !!data.deniable,
1234
+ mentioned,
1235
+ trust,
1236
+ ));
1237
+ });
1238
+ this.#noteBufferUnread(msgRoom, mentioned);
993
1239
  const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
994
1240
  if (notify) {
995
1241
  this.#ui.playNotification();
996
1242
  }
997
1243
 
998
- if (data.ephemeral && data.ephemeral > 0) {
1244
+ // Ephemeral burn animation only makes sense on the live log; in an
1245
+ // inactive buffer the message simply expires in place.
1246
+ if (data.ephemeral && data.ephemeral > 0 && roomActive) {
999
1247
  this.#scheduleEphemeralRemoval(lineIndex, data.ephemeral, peer.nickname);
1000
1248
  }
1001
1249
 
@@ -1073,7 +1321,8 @@ export class ChatController {
1073
1321
  this.#ui.addInfoMessage(' /lock - Lock the screen (session passphrase)');
1074
1322
  this.#ui.addInfoMessage(' /autolock <min|off> - Auto-lock on inactivity');
1075
1323
  this.#ui.addInfoMessage(' /status <text|off> - Set a status (accepts :emoji:)');
1076
- this.#ui.addInfoMessage(' /join <room> [pass] - Join a room (password if private)');
1324
+ this.#ui.addInfoMessage(' /join <room> [pass] - Join a room as a new buffer (Alt+1..9)');
1325
+ this.#ui.addInfoMessage(' /leave [room] - Leave a room (its buffer closes)');
1077
1326
  this.#ui.addInfoMessage(' /create <room> <pass> - Create a private room 🔒');
1078
1327
  this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
1079
1328
  this.#ui.addInfoMessage(' /rooms - List available rooms');
@@ -1270,7 +1519,7 @@ export class ChatController {
1270
1519
  break;
1271
1520
  }
1272
1521
  this.#ui.addInfoMessage('Trust status:');
1273
- for (const p of peerList) {
1522
+ for (const [sid, p] of this.#peers) {
1274
1523
  const record = this.#trustStore.getPeerRecord(p.nickname);
1275
1524
  let status;
1276
1525
  if (!record) {
@@ -1280,8 +1529,11 @@ export class ChatController {
1280
1529
  } else {
1281
1530
  status = 'trusted (TOFU)';
1282
1531
  }
1283
- this.#ui.addInfoMessage(` ${p.nickname}: ${status}`);
1532
+ // [PQ] = this session's ratchet root also includes an ML-KEM secret.
1533
+ const pq = this.#handshake.isHybrid(sid) ? ' {green-fg}[PQ]{/green-fg}' : '';
1534
+ this.#ui.addInfoMessage(` ${p.nickname}: ${status}${pq}`);
1284
1535
  }
1536
+ this.#ui.addInfoMessage(' [PQ] = hybrid post-quantum session (X25519 + ML-KEM-768)');
1285
1537
  break;
1286
1538
  }
1287
1539
 
@@ -1339,43 +1591,64 @@ export class ChatController {
1339
1591
  }
1340
1592
 
1341
1593
  case '/join': {
1342
- const roomName = parts[1];
1594
+ const roomName = parts[1]?.toLowerCase();
1343
1595
  if (!roomName) {
1344
1596
  this.#ui.addErrorMessage('Usage: /join <room> [password]');
1345
1597
  break;
1346
1598
  }
1599
+ // Already have that buffer? Just focus it.
1600
+ if (this.#buffers.has(roomName)) {
1601
+ this.#switchToBuffer(roomName);
1602
+ break;
1603
+ }
1347
1604
  const joinPassword = parts.slice(2).join(' ');
1348
1605
  if (joinPassword) {
1349
1606
  // Derive now so we can answer the server's challenge immediately.
1350
1607
  this.#prepareRoomSecrets(roomName, joinPassword, () => {
1351
- this.#connection.send(createChangeRoom(roomName));
1608
+ this.#connection.send(createJoinRoom(roomName));
1352
1609
  });
1353
1610
  } else {
1354
- this.#connection.send(createChangeRoom(roomName));
1611
+ this.#connection.send(createJoinRoom(roomName));
1355
1612
  }
1356
1613
  break;
1357
1614
  }
1358
1615
 
1359
1616
  case '/create': {
1360
- const roomName = parts[1];
1617
+ const roomName = parts[1]?.toLowerCase();
1361
1618
  if (!roomName) {
1362
1619
  this.#ui.addErrorMessage('Usage: /create <room> <password>');
1363
1620
  break;
1364
1621
  }
1622
+ if (this.#buffers.has(roomName)) {
1623
+ this.#ui.addErrorMessage(`You are already in #${roomName}`);
1624
+ break;
1625
+ }
1365
1626
  const createPassword = parts.slice(2).join(' ');
1366
1627
  if (!createPassword) {
1367
1628
  // No password — same as joining/creating a public room.
1368
- this.#connection.send(createChangeRoom(roomName));
1629
+ this.#connection.send(createJoinRoom(roomName));
1369
1630
  break;
1370
1631
  }
1371
1632
  this.#prepareRoomSecrets(roomName, createPassword, (secrets) => {
1372
- this.#connection.send(
1373
- createChangeRoom(roomName, secrets.authPublicKey.toString('base64')),
1374
- );
1633
+ this.#connection.send(createJoinRoom(roomName, secrets.authPublicKey.toString('base64')));
1375
1634
  });
1376
1635
  break;
1377
1636
  }
1378
1637
 
1638
+ case '/leave': {
1639
+ const target = (parts[1] || this.#currentRoom).toLowerCase();
1640
+ if (!this.#buffers.has(target)) {
1641
+ this.#ui.addErrorMessage(`You are not in #${target}`);
1642
+ break;
1643
+ }
1644
+ if (this.#bufferOrder.length === 1) {
1645
+ this.#ui.addErrorMessage('Cannot leave your last room');
1646
+ break;
1647
+ }
1648
+ this.#connection.send(createLeaveRoom(target));
1649
+ break;
1650
+ }
1651
+
1379
1652
  case '/rooms':
1380
1653
  this.#connection.send(createListRooms());
1381
1654
  break;
@@ -1408,11 +1681,23 @@ export class ChatController {
1408
1681
  break;
1409
1682
  }
1410
1683
 
1411
- case '/room':
1684
+ case '/room': {
1412
1685
  this.#ui.addInfoMessage(
1413
- `Current room: #${this.#currentRoom}${this.#roomSecrets ? ' 🔒 (private)' : ''}`,
1686
+ `Current room: #${this.#currentRoom}${this.#activeSecrets ? ' 🔒 (private)' : ''}`,
1414
1687
  );
1688
+ if (this.#bufferOrder.length > 1) {
1689
+ const list = this.#bufferOrder
1690
+ .map((r, i) => {
1691
+ const b = this.#buffers.get(r);
1692
+ const mark = r === this.#currentRoom ? '*' : ' ';
1693
+ const unread = b?.unread ? ` (${b.unread} unread)` : '';
1694
+ return ` ${mark}${i + 1}. #${r}${b?.private ? ' 🔒' : ''}${unread}`;
1695
+ })
1696
+ .join('\n');
1697
+ this.#ui.addInfoMessage(`Buffers (Alt+1..9):\n${list}`);
1698
+ }
1415
1699
  break;
1700
+ }
1416
1701
 
1417
1702
  case '/tips': {
1418
1703
  this.#tipIndex = (this.#tipIndex + 1) % TIPS.length;
@@ -2161,7 +2446,9 @@ export class ChatController {
2161
2446
  // "nickname taken"): the server still accepts a JOIN on this socket.
2162
2447
  this.#nickname = newNick;
2163
2448
  this.#ui.setNickname(newNick);
2164
- this.#connection.send(createJoin(newNick, this.#keyManager.publicKeyB64));
2449
+ this.#connection.send(
2450
+ createJoin(newNick, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
2451
+ );
2165
2452
  this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
2166
2453
  break;
2167
2454
  }
@@ -2334,26 +2621,17 @@ export class ChatController {
2334
2621
  this.#connection.send(createRoomAuth(msg.room, msg.nonce, signature.toString('base64')));
2335
2622
  }
2336
2623
 
2337
- // ── Handle ROOM_CHANGED (after /join) ──────────────────────
2338
- #onRoomChanged(msg) {
2339
- this.#currentRoom = msg.room;
2340
- this.#ui.setRoom(this.#currentRoom);
2341
- this.#currentRoomOwner = msg.roomOwner || null;
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;
2624
+ // Promote pending password-derived secrets once the server confirms the
2625
+ // room really is private; warn when it isn't (old server or needless password).
2626
+ #promotePendingSecrets(room, isPrivate) {
2627
+ let secrets = null;
2628
+ if (this.#pendingRoomSecrets?.room === room) {
2629
+ if (isPrivate) {
2630
+ secrets = this.#pendingRoomSecrets;
2352
2631
  } else {
2353
- // Old server without private-room support silently made it public.
2354
2632
  freeRoomSecrets(this.#pendingRoomSecrets);
2355
2633
  this.#ui.addErrorMessage(
2356
- 'WARNING: this server does not support private rooms — the room is PUBLIC and anyone can join.',
2634
+ 'WARNING: the server treated this room as PUBLIC anyone can join.',
2357
2635
  );
2358
2636
  }
2359
2637
  this.#pendingRoomSecrets = null;
@@ -2361,52 +2639,119 @@ export class ChatController {
2361
2639
  freeRoomSecrets(this.#pendingRoomSecrets);
2362
2640
  this.#pendingRoomSecrets = null;
2363
2641
  }
2364
- if (this.#roomSecrets) {
2365
- this.#ui.setHeaderIndicator('private', '{green-fg}[🔒]{/green-fg}');
2642
+ return secrets;
2643
+ }
2644
+
2645
+ #announceJoinedRoom(room, isPrivate, peerNames) {
2646
+ if (isPrivate) {
2647
+ this.#ui.addSystemMessage(`You joined private room #${room} 🔒`);
2648
+ this.#ui.addInfoMessage('Messages here get an extra layer encrypted with the room key.');
2366
2649
  } else {
2367
- this.#ui.removeHeaderIndicator('private');
2650
+ this.#ui.addSystemMessage(`You joined room #${room}`);
2368
2651
  }
2652
+ if (peerNames.length > 0) {
2653
+ this.#ui.addSystemMessage(`Online: ${peerNames.join(', ')}`);
2654
+ }
2655
+ if (this.#away || this.#statusText) {
2656
+ this.#broadcastPresence();
2657
+ }
2658
+ }
2369
2659
 
2370
- // Clear old peers and pins
2371
- this.#peers.clear();
2372
- this.#pinnedMessages = [];
2660
+ // ── Handle ROOM_CHANGED (legacy full switch — also how a kick lands) ──
2661
+ // change_room semantics: every buffer is dropped, we exist only in msg.room.
2662
+ #onRoomChanged(msg) {
2663
+ const secrets = this.#promotePendingSecrets(msg.room, !!msg.private);
2664
+
2665
+ this.#resetBuffersTo(msg.room, {
2666
+ isPrivate: !!msg.private,
2667
+ owner: msg.roomOwner || null,
2668
+ secrets,
2669
+ });
2670
+ this.#currentRoomOwner = msg.roomOwner || null;
2373
2671
 
2374
- // Populate with new room peers
2375
2672
  for (const peer of msg.peers) {
2376
- this.#peers.set(peer.sessionId, {
2673
+ this.#allPeers.set(peer.sessionId, {
2377
2674
  nickname: peer.nickname,
2378
2675
  publicKey: peer.publicKey,
2676
+ rooms: new Set([msg.room]),
2379
2677
  });
2380
-
2381
- // Register ratchet if new peer
2382
2678
  if (!this.#handshake.getRatchet(peer.sessionId)) {
2383
- this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
2679
+ this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
2384
2680
  }
2385
-
2386
2681
  this.#checkTrust(peer.nickname, peer.publicKey);
2387
2682
  }
2388
2683
 
2389
- const peerNames = [...this.#peers.values()].map((p) => p.nickname);
2390
- this.#ui.setOnlineCount(this.#peers.size + 1);
2391
- this.#ui.setPeerNames(peerNames);
2684
+ this.#rebuildActivePeers();
2392
2685
  this.#auditLog.log(AuditEvent.ROOM_CHANGED, { 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
- }
2686
+ this.#announceJoinedRoom(
2687
+ msg.room,
2688
+ !!secrets,
2689
+ [...this.#peers.values()].map((p) => p.nickname),
2690
+ );
2691
+ this.#saveLastSession(!!msg.private);
2692
+ }
2399
2693
 
2400
- if (peerNames.length > 0) {
2401
- this.#ui.addSystemMessage(`Online: ${peerNames.join(', ')}`);
2694
+ // ── Handle ROOM_JOINED (additive join — new buffer, focused) ──
2695
+ #onRoomJoined(msg) {
2696
+ const secrets = this.#promotePendingSecrets(msg.room, !!msg.private);
2697
+ this.#ensureBuffer(msg.room, {
2698
+ isPrivate: !!msg.private,
2699
+ owner: msg.roomOwner || null,
2700
+ secrets,
2701
+ });
2702
+
2703
+ for (const peer of msg.peers || []) {
2704
+ const existing = this.#allPeers.get(peer.sessionId);
2705
+ if (existing) {
2706
+ existing.rooms.add(msg.room);
2707
+ } else {
2708
+ this.#allPeers.set(peer.sessionId, {
2709
+ nickname: peer.nickname,
2710
+ publicKey: peer.publicKey,
2711
+ rooms: new Set([msg.room]),
2712
+ });
2713
+ if (!this.#handshake.getRatchet(peer.sessionId)) {
2714
+ this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
2715
+ }
2716
+ }
2717
+ this.#checkTrust(peer.nickname, peer.publicKey);
2402
2718
  }
2403
2719
 
2404
- // The new room doesn't know my presence
2405
- if (this.#away || this.#statusText) {
2406
- this.#broadcastPresence();
2720
+ this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room, additive: true });
2721
+ this.#switchToBuffer(msg.room);
2722
+ this.#announceJoinedRoom(
2723
+ msg.room,
2724
+ !!secrets,
2725
+ [...this.#peers.values()].map((p) => p.nickname),
2726
+ );
2727
+ this.#updateBufferBar();
2728
+ }
2729
+
2730
+ // ── Handle ROOM_LEFT (we left one room; buffer dies) ─────────
2731
+ #onRoomLeft(msg) {
2732
+ const room = msg.room;
2733
+ const wasActive = room === this.#currentRoom;
2734
+ this.#dropBufferState(room);
2735
+
2736
+ // Peers we only shared that room with are gone for us now.
2737
+ for (const [sid, p] of [...this.#allPeers]) {
2738
+ p.rooms.delete(room);
2739
+ if (p.rooms.size === 0) {
2740
+ this.#hidePeerTyping(sid, p.nickname);
2741
+ this.#handshake.removePeer(sid);
2742
+ this.#nonceManager.removePeer(sid);
2743
+ this.#allPeers.delete(sid);
2744
+ }
2407
2745
  }
2408
2746
 
2409
- this.#saveLastSession(!!msg.private);
2747
+ if (wasActive && this.#bufferOrder.length > 0) {
2748
+ this.#switchToBuffer(this.#bufferOrder[0]);
2749
+ } else {
2750
+ this.#rebuildActivePeers();
2751
+ this.#updateBufferBar();
2752
+ }
2753
+ this.#ui.addSystemMessage(`You left #${room}`);
2754
+ this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room, left: true });
2410
2755
  }
2411
2756
 
2412
2757
  // ── Handle ROOM_LIST ───────────────────────────────────────
@@ -2471,6 +2816,11 @@ export class ChatController {
2471
2816
  if (!tracked) {
2472
2817
  return;
2473
2818
  }
2819
+ // Line indexes are only valid on the live log — skip the ✓✓ update when
2820
+ // the message's buffer isn't on screen (multi-room v1 limitation).
2821
+ if (tracked.room && tracked.room !== this.#currentRoom) {
2822
+ return;
2823
+ }
2474
2824
 
2475
2825
  let readers = this.#messageReaders.get(messageId);
2476
2826
  if (!readers) {
@@ -2491,7 +2841,7 @@ export class ChatController {
2491
2841
  if (baseLine === null || baseLine === undefined) {
2492
2842
  return;
2493
2843
  }
2494
- this.#sentMessageLines.set(messageId, { lineIndex, baseLine });
2844
+ this.#sentMessageLines.set(messageId, { lineIndex, baseLine, room: this.#currentRoom });
2495
2845
 
2496
2846
  // Bound memory: keep only the most recent 200 tracked messages
2497
2847
  if (this.#sentMessageLines.size > 200) {
@@ -2530,9 +2880,11 @@ export class ChatController {
2530
2880
  return;
2531
2881
  }
2532
2882
 
2533
- // Private room: extra symmetric layer under the pairwise encryption.
2534
- if (this.#roomSecrets) {
2535
- payload = encryptRoomPayload(payload, this.#roomSecrets.roomKey);
2883
+ // Tag with the active room (inside the E2EE envelope), then the private
2884
+ // room's extra symmetric layer when there is one.
2885
+ payload = this.#tagRoom(payload);
2886
+ if (this.#activeSecrets) {
2887
+ payload = encryptRoomPayload(payload, this.#activeSecrets.roomKey);
2536
2888
  }
2537
2889
 
2538
2890
  const ratchet = this.#handshake.getRatchet(peerId);
@@ -2652,10 +3004,12 @@ export class ChatController {
2652
3004
 
2653
3005
  // ── Broadcast encrypted payload to all peers ───────────────────
2654
3006
  #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);
3007
+ // Tag with the active room (inside the E2EE envelope), then the private
3008
+ // room's extra symmetric layer, so even a relay-injected member can't
3009
+ // read the room without the password.
3010
+ payload = this.#tagRoom(payload);
3011
+ if (this.#activeSecrets) {
3012
+ payload = encryptRoomPayload(payload, this.#activeSecrets.roomKey);
2659
3013
  }
2660
3014
 
2661
3015
  for (const [peerId] of this.#peers) {