ciphermesh 1.0.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/README.pt-BR.md +253 -0
  4. package/bin/ciphermesh.js +34 -0
  5. package/docs/ARCHITECTURE.md +1188 -0
  6. package/docs/SETUP.md +305 -0
  7. package/docs/demo.svg +46 -0
  8. package/package.json +87 -0
  9. package/src/client/ChatController.js +2476 -0
  10. package/src/client/Connection.js +129 -0
  11. package/src/client/FileTransfer.js +488 -0
  12. package/src/client/ImagePreview.js +88 -0
  13. package/src/client/UI.js +1830 -0
  14. package/src/client/index.js +231 -0
  15. package/src/crypto/CertPinStore.js +79 -0
  16. package/src/crypto/DeniableEncrypt.js +53 -0
  17. package/src/crypto/DoubleRatchet.js +574 -0
  18. package/src/crypto/Handshake.js +219 -0
  19. package/src/crypto/HistoryStore.js +241 -0
  20. package/src/crypto/IdentityBackup.js +70 -0
  21. package/src/crypto/KeyManager.js +134 -0
  22. package/src/crypto/MessageCrypto.js +181 -0
  23. package/src/crypto/NonceManager.js +72 -0
  24. package/src/crypto/SealedSender.js +58 -0
  25. package/src/crypto/SenderKey.js +204 -0
  26. package/src/crypto/StateManager.js +138 -0
  27. package/src/crypto/TrustStore.js +216 -0
  28. package/src/p2p/Discovery.js +80 -0
  29. package/src/p2p/P2PChatController.js +1856 -0
  30. package/src/p2p/PeerConnectionManager.js +252 -0
  31. package/src/p2p/PeerServer.js +68 -0
  32. package/src/p2p/index.js +219 -0
  33. package/src/protocol/messages.js +138 -0
  34. package/src/protocol/validators.js +175 -0
  35. package/src/server/CertManager.js +173 -0
  36. package/src/server/MessageRouter.js +80 -0
  37. package/src/server/OfflineQueue.js +124 -0
  38. package/src/server/SessionManager.js +296 -0
  39. package/src/server/WebSocketServer.js +632 -0
  40. package/src/server/index.js +89 -0
  41. package/src/shared/AuditLog.js +91 -0
  42. package/src/shared/PluginManager.js +83 -0
  43. package/src/shared/banner.js +271 -0
  44. package/src/shared/commandSuggest.js +59 -0
  45. package/src/shared/config.js +90 -0
  46. package/src/shared/constants.js +126 -0
  47. package/src/shared/coverTraffic.js +34 -0
  48. package/src/shared/dnd.js +60 -0
  49. package/src/shared/emoji.js +17 -0
  50. package/src/shared/fuzzy.js +40 -0
  51. package/src/shared/invite.js +61 -0
  52. package/src/shared/keyArt.js +66 -0
  53. package/src/shared/logger.js +38 -0
  54. package/src/shared/panic.js +38 -0
  55. package/src/shared/prompt.js +31 -0
  56. package/src/shared/terminalGraphics.js +72 -0
  57. package/src/shared/themes.js +36 -0
  58. package/src/shared/voiceNote.js +128 -0
@@ -0,0 +1,2476 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import sodium from 'sodium-native';
5
+ import notifier from 'node-notifier';
6
+ import qrcode from 'qrcode-terminal';
7
+ import {
8
+ MSG,
9
+ createJoin,
10
+ createEncryptedMessage,
11
+ createRatchetedMessage,
12
+ createKeyUpdate,
13
+ createChangeRoom,
14
+ createListRooms,
15
+ createKickPeer,
16
+ createMutePeer,
17
+ createBanPeer,
18
+ ERR,
19
+ } from '../protocol/messages.js';
20
+ import { KEY_ROTATION_INTERVAL_MS, EMOJI_MAP, COVER_CONSTANT_MS } from '../shared/constants.js';
21
+ import { KeyManager } from '../crypto/KeyManager.js';
22
+ import { Handshake } from '../crypto/Handshake.js';
23
+ import { NonceManager } from '../crypto/NonceManager.js';
24
+ import * as MessageCrypto from '../crypto/MessageCrypto.js';
25
+ import { TrustStore, TrustResult } from '../crypto/TrustStore.js';
26
+ import { FileTransfer } from './FileTransfer.js';
27
+ import { AuditLog, AuditEvent } from '../shared/AuditLog.js';
28
+ import { deriveSharedKey, encryptDeniable, decryptDeniable } from '../crypto/DeniableEncrypt.js';
29
+ import { buildInvite } from '../shared/invite.js';
30
+ import { exportBackup } from '../crypto/IdentityBackup.js';
31
+ import { keyArt } from '../shared/keyArt.js';
32
+ import { applyShortcodes } from '../shared/emoji.js';
33
+ import { isImageFile, renderImagePreview, loadImageBuffers } from './ImagePreview.js';
34
+ import { detectImageProtocol, encodeInlineImage } from '../shared/terminalGraphics.js';
35
+ import { suggestCommand } from '../shared/commandSuggest.js';
36
+ import { nextCoverDelay, coverPayload, isCover } from '../shared/coverTraffic.js';
37
+ import { recordVoiceNote, playVoiceNote, isAudioFile } from '../shared/voiceNote.js';
38
+ import { setTheme, getThemeName, themeNames } from '../shared/themes.js';
39
+ import { panicWipe } from '../shared/panic.js';
40
+ import { farewellBanner } from '../shared/banner.js';
41
+ import { parseDndWindow, shouldNotify, nowMinutes, mentionsMe } from '../shared/dnd.js';
42
+ import { COMMANDS } from './UI.js';
43
+
44
+ const TYPING_SEND_INTERVAL = 2000; // debounce: max 1 typing event per 2s
45
+ const TYPING_EXPIRE_TIMEOUT = 3000; // hide indicator after 3s of silence
46
+
47
+ export class ChatController {
48
+ #nickname;
49
+ #connection;
50
+ #ui;
51
+ #keyManager;
52
+ #handshake;
53
+ #nonceManager;
54
+ #sessionId;
55
+ #peers; // Map<sessionId, { nickname, publicKey }>
56
+ #lastTypingSent;
57
+ #peerTypingTimers; // Map<sessionId, timeoutId>
58
+ #fileTransfer;
59
+ #pendingFileOffers = new Map(); // transferId -> { from, data, nickname }
60
+ #lastImagePath = null; // last received image (for /img full-res render)
61
+ #lastAudioPath = null; // last received voice note (for /play)
62
+ #keyRotationTimer;
63
+ #trustStore;
64
+ #passphrase;
65
+ #currentRoom;
66
+ #auditLog;
67
+ #ephemeralMode;
68
+ #ephemeralDurationMs;
69
+ #ephemeralTimers;
70
+ #lastReceivedMessageId;
71
+ #lastReceivedNickname;
72
+ #lastSentMessageId;
73
+ #messageAuthors;
74
+ #pinnedMessages;
75
+ #lastReceivedText;
76
+ #deniableMode;
77
+ #pluginManager;
78
+ #currentRoomOwner;
79
+ #inviteRoom;
80
+ #historyStore;
81
+ #receiptsEnabled;
82
+ #sentMessageLines; // Map<messageId, { lineIndex, baseLine }>
83
+ #messageReaders; // Map<messageId, Set<nickname>>
84
+ #away;
85
+ #awayReason;
86
+ #statusText;
87
+ #coverMode; // 'off' | 'jitter' | 'constant'
88
+ #coverTimer;
89
+ #paceQueue;
90
+ #dndMode = 'off'; // 'off' | 'mentions' | 'on'
91
+ #dndWindow = null; // quiet-hours { start, end } in minutes, or null
92
+ #autoAwayMs = 0; // idle timeout in ms (0 = off)
93
+ #autoAwayTimer = null;
94
+ #autoAwaySet = false; // whether the current away was set automatically
95
+
96
+ constructor(
97
+ nickname,
98
+ connection,
99
+ ui,
100
+ restoredState = null,
101
+ pluginManager = null,
102
+ inviteRoom = null,
103
+ historyStore = null,
104
+ ) {
105
+ this.#nickname = nickname;
106
+ this.#connection = connection;
107
+ this.#ui = ui;
108
+ this.#passphrase = restoredState?.passphrase || null;
109
+
110
+ if (restoredState?.keyManager) {
111
+ this.#keyManager = KeyManager.deserialize(restoredState.keyManager);
112
+ } else {
113
+ this.#keyManager = new KeyManager();
114
+ }
115
+
116
+ this.#handshake = new Handshake(this.#keyManager);
117
+ if (restoredState?.handshake) {
118
+ this.#handshake.restoreState(restoredState.handshake);
119
+ }
120
+
121
+ this.#nonceManager = new NonceManager();
122
+ this.#sessionId = null;
123
+ this.#peers = new Map();
124
+
125
+ if (restoredState?.peers) {
126
+ for (const [sid, peer] of Object.entries(restoredState.peers)) {
127
+ this.#peers.set(sid, peer);
128
+ }
129
+ }
130
+
131
+ this.#lastTypingSent = 0;
132
+ this.#peerTypingTimers = new Map();
133
+ this.#fileTransfer = new FileTransfer();
134
+ this.#keyRotationTimer = null;
135
+ this.#trustStore = new TrustStore();
136
+ if (restoredState?.trust) {
137
+ this.#trustStore.importData(restoredState.trust);
138
+ }
139
+ this.#currentRoom = 'general';
140
+ this.#auditLog = new AuditLog();
141
+ this.#ephemeralMode = false;
142
+ this.#ephemeralDurationMs = 0;
143
+ this.#ephemeralTimers = [];
144
+ this.#lastReceivedMessageId = null;
145
+ this.#lastReceivedNickname = null;
146
+ this.#lastSentMessageId = null;
147
+ this.#messageAuthors = new Map(); // Map<messageId, nickname>
148
+ this.#pinnedMessages = [];
149
+ this.#lastReceivedText = null;
150
+ this.#deniableMode = false;
151
+ this.#pluginManager = pluginManager;
152
+ this.#currentRoomOwner = null;
153
+ this.#inviteRoom = inviteRoom;
154
+ this.#historyStore = historyStore;
155
+ this.#receiptsEnabled = true;
156
+ this.#sentMessageLines = new Map();
157
+ this.#messageReaders = new Map();
158
+ this.#away = false;
159
+ this.#awayReason = null;
160
+ this.#statusText = null;
161
+ this.#coverMode = 'off';
162
+ this.#coverTimer = null;
163
+ this.#paceQueue = [];
164
+
165
+ this.#setupConnectionHandlers();
166
+ this.#setupUIHandlers();
167
+ this.#startKeyRotation();
168
+ }
169
+
170
+ get fingerprint() {
171
+ return this.#keyManager.fingerprint;
172
+ }
173
+
174
+ // Runs when the socket opens. Extracted so it can also fire once at setup
175
+ // time when the boot sequence already established the connection (otherwise
176
+ // the initial JOIN would be lost — the 'connected' event fired before we
177
+ // attached the listener).
178
+ #onConnected() {
179
+ this.#ui.setConnectionState('online');
180
+ this.#connection.send(createJoin(this.#nickname, this.#keyManager.publicKeyB64));
181
+ }
182
+
183
+ // ── Connection event handlers ─────────────────────────────────
184
+ #setupConnectionHandlers() {
185
+ this.#connection.on('connected', () => this.#onConnected());
186
+
187
+ // The boot sequence may have already opened the socket before this
188
+ // controller existed — replay the connect so the JOIN still goes out.
189
+ if (this.#connection.connected) {
190
+ this.#onConnected();
191
+ }
192
+
193
+ this.#connection.on('disconnected', () => {
194
+ this.#ui.setConnectionState('offline');
195
+ this.#ui.setOnlineCount(0);
196
+ this.#ui.addErrorMessage('Connection lost to the server');
197
+ });
198
+
199
+ this.#connection.on('reconnecting', (delay) => {
200
+ this.#ui.setConnectionState('reconnecting');
201
+ this.#ui.addSystemMessage(`Reconnecting in ${delay / 1000}s...`);
202
+ });
203
+
204
+ this.#connection.on('cert-pinned', ({ fingerprint }) => {
205
+ const fp = fingerprint ? fingerprint.slice(0, 17) + '...' : '?';
206
+ this.#ui.addSystemMessage(`Server certificate pinned (trust on first use): ${fp}`);
207
+ });
208
+
209
+ this.#connection.on('cert-mismatch', ({ got }) => {
210
+ this.#ui.addErrorMessage(
211
+ 'ALERT: the server TLS certificate CHANGED since the last connection ' +
212
+ `(possible MITM). Current fingerprint: ${got || '?'}. ` +
213
+ 'E2E verification (/verify) remains the definitive protection.',
214
+ );
215
+ });
216
+
217
+ this.#connection.on('message', (msg) => {
218
+ this.#handleServerMessage(msg);
219
+ });
220
+ }
221
+
222
+ // ── UI event handlers ─────────────────────────────────────────
223
+ #setupUIHandlers() {
224
+ this.#ui.on('input', (text) => {
225
+ this.#handleUserInput(text);
226
+ });
227
+
228
+ this.#ui.on('activity', () => {
229
+ this.#handleTypingActivity();
230
+ });
231
+
232
+ this.#ui.on('quit', () => {
233
+ this.destroy();
234
+ process.exit(0);
235
+ });
236
+ }
237
+
238
+ // ── Auto-away (idle) ────────────────────────────────────────
239
+ #noteActive() {
240
+ // Coming back from an auto-set away → auto-return.
241
+ if (this.#autoAwaySet && this.#away) {
242
+ this.#away = false;
243
+ this.#awayReason = null;
244
+ this.#autoAwaySet = false;
245
+ this.#ui.removeHeaderIndicator('away');
246
+ this.#ui.addSystemMessage("You're back (auto)");
247
+ this.#broadcastPresence();
248
+ }
249
+ this.#armAutoAway();
250
+ }
251
+
252
+ #armAutoAway() {
253
+ if (this.#autoAwayTimer) {
254
+ clearTimeout(this.#autoAwayTimer);
255
+ this.#autoAwayTimer = null;
256
+ }
257
+ if (this.#autoAwayMs > 0) {
258
+ this.#autoAwayTimer = setTimeout(() => this.#triggerAutoAway(), this.#autoAwayMs);
259
+ if (this.#autoAwayTimer.unref) {
260
+ this.#autoAwayTimer.unref();
261
+ }
262
+ }
263
+ }
264
+
265
+ #triggerAutoAway() {
266
+ if (this.#away) {
267
+ return; // already away (manual) — leave it
268
+ }
269
+ this.#away = true;
270
+ this.#awayReason = 'away (idle)';
271
+ this.#autoAwaySet = true;
272
+ this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
273
+ this.#ui.addSystemMessage('Auto-away: marked as away due to inactivity');
274
+ this.#broadcastPresence();
275
+ }
276
+
277
+ // ── Typing indicator (outgoing) ─────────────────────────────
278
+ #handleTypingActivity() {
279
+ this.#noteActive();
280
+ const now = Date.now();
281
+ if (now - this.#lastTypingSent < TYPING_SEND_INTERVAL) {
282
+ return;
283
+ }
284
+ if (this.#peers.size === 0) {
285
+ return;
286
+ }
287
+
288
+ this.#lastTypingSent = now;
289
+ this.#sendCommandToAll('typing');
290
+ }
291
+
292
+ // ── Typing indicator (incoming) ─────────────────────────────
293
+ #showPeerTyping(sessionId, nickname) {
294
+ // Clear existing timer for this peer
295
+ const existing = this.#peerTypingTimers.get(sessionId);
296
+ if (existing) {
297
+ clearTimeout(existing);
298
+ }
299
+
300
+ this.#ui.showTyping(nickname);
301
+
302
+ // Auto-hide after timeout
303
+ const timer = setTimeout(() => {
304
+ this.#ui.hideTyping(nickname);
305
+ this.#peerTypingTimers.delete(sessionId);
306
+ }, TYPING_EXPIRE_TIMEOUT);
307
+
308
+ this.#peerTypingTimers.set(sessionId, timer);
309
+ }
310
+
311
+ #hidePeerTyping(sessionId, nickname) {
312
+ const timer = this.#peerTypingTimers.get(sessionId);
313
+ if (timer) {
314
+ clearTimeout(timer);
315
+ this.#peerTypingTimers.delete(sessionId);
316
+ }
317
+ this.#ui.hideTyping(nickname);
318
+ }
319
+
320
+ // ── Route server messages ─────────────────────────────────────
321
+ #handleServerMessage(msg) {
322
+ switch (msg.type) {
323
+ case MSG.JOIN_ACK:
324
+ this.#onJoinAck(msg);
325
+ break;
326
+
327
+ case MSG.PEER_JOINED:
328
+ this.#onPeerJoined(msg);
329
+ break;
330
+
331
+ case MSG.PEER_LEFT:
332
+ this.#onPeerLeft(msg);
333
+ break;
334
+
335
+ case MSG.ENCRYPTED_MESSAGE:
336
+ this.#onEncryptedMessage(msg);
337
+ break;
338
+
339
+ case MSG.PEER_KEY_UPDATED:
340
+ this.#onPeerKeyUpdated(msg);
341
+ break;
342
+
343
+ case MSG.ROOM_CHANGED:
344
+ this.#onRoomChanged(msg);
345
+ break;
346
+
347
+ case MSG.ROOM_LIST:
348
+ this.#onRoomList(msg);
349
+ break;
350
+
351
+ case MSG.PEER_KICKED:
352
+ this.#onPeerKicked(msg);
353
+ break;
354
+
355
+ case MSG.PEER_MUTED:
356
+ this.#onPeerMuted(msg);
357
+ break;
358
+
359
+ case MSG.ERROR:
360
+ if (msg.code === ERR.NICKNAME_TAKEN) {
361
+ this.#ui.addErrorMessage(
362
+ `${msg.message}. Use /nick <other> to pick a different nickname.`,
363
+ );
364
+ } else {
365
+ this.#ui.addErrorMessage(`Error: ${msg.message} (${msg.code})`);
366
+ }
367
+ break;
368
+ }
369
+ }
370
+
371
+ // ── TOFU: Trust On First Use ──────────────────────────────────
372
+ #checkTrust(nickname, publicKey) {
373
+ const result = this.#trustStore.checkPeer(nickname, publicKey);
374
+
375
+ switch (result) {
376
+ case TrustResult.NEW_PEER:
377
+ this.#trustStore.recordPeer(nickname, publicKey);
378
+ this.#auditLog.log(AuditEvent.TRUST_NEW_PEER, { nickname });
379
+ break;
380
+
381
+ case TrustResult.TRUSTED:
382
+ break;
383
+
384
+ case TrustResult.MISMATCH:
385
+ this.#auditLog.log(AuditEvent.TRUST_MISMATCH, { nickname });
386
+ this.#ui.addErrorMessage(
387
+ `WARNING: ${nickname}'s key changed! Possible MITM attack. Use /trust ${nickname} to accept or /verify ${nickname} to verify.`,
388
+ );
389
+ break;
390
+
391
+ case TrustResult.VERIFIED_MISMATCH:
392
+ this.#auditLog.log(AuditEvent.TRUST_VERIFIED_MISMATCH, { nickname });
393
+ this.#ui.addErrorMessage(
394
+ `ALERT: ${nickname}'s VERIFIED key changed! This may indicate an attack. Use /verify ${nickname} to re-verify.`,
395
+ );
396
+ break;
397
+ }
398
+ }
399
+
400
+ // ── JOIN_ACK: registered with server ──────────────────────────
401
+ #onJoinAck(msg) {
402
+ this.#sessionId = msg.sessionId;
403
+ this.#currentRoom = msg.room || 'general';
404
+ this.#ui.setRoom(this.#currentRoom);
405
+ this.#currentRoomOwner = msg.roomOwner || null;
406
+
407
+ // Build map of old sessionIds by nickname for ratchet migration
408
+ const oldSessionByNick = new Map();
409
+ for (const [sid, peer] of this.#peers) {
410
+ oldSessionByNick.set(peer.nickname.toLowerCase(), sid);
411
+ }
412
+ this.#peers.clear();
413
+
414
+ for (const peer of msg.peers) {
415
+ this.#peers.set(peer.sessionId, {
416
+ nickname: peer.nickname,
417
+ publicKey: peer.publicKey,
418
+ });
419
+
420
+ const oldSid = oldSessionByNick.get(peer.nickname.toLowerCase());
421
+ if (oldSid && oldSid !== peer.sessionId) {
422
+ // Migrate ratchet from old sessionId to new sessionId
423
+ this.#handshake.migrateRatchet(oldSid, peer.sessionId);
424
+ } else if (!oldSid) {
425
+ this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
426
+ }
427
+
428
+ this.#checkTrust(peer.nickname, peer.publicKey);
429
+ }
430
+
431
+ // Initialize ratchets now that we have our session ID
432
+ this.#handshake.setMySessionId(msg.sessionId);
433
+
434
+ const peerNames = [...this.#peers.values()].map((p) => p.nickname);
435
+ this.#ui.setOnlineCount(this.#peers.size + 1);
436
+ this.#ui.setPeerNames(peerNames);
437
+ this.#ui.addSystemMessage('Connected to server with E2E encryption active');
438
+
439
+ if (peerNames.length > 0) {
440
+ this.#ui.addSystemMessage(`Online: ${peerNames.join(', ')}`);
441
+ }
442
+
443
+ if (msg.queuedCount > 0) {
444
+ this.#ui.addSystemMessage(`${msg.queuedCount} pending message(s) being delivered`);
445
+ }
446
+
447
+ // Invite included a room — join it once after the first connect
448
+ if (this.#inviteRoom && this.#inviteRoom !== this.#currentRoom) {
449
+ this.#connection.send(createChangeRoom(this.#inviteRoom));
450
+ this.#inviteRoom = null;
451
+ }
452
+ }
453
+
454
+ // ── New peer arrived ──────────────────────────────────────────
455
+ #onPeerJoined(msg) {
456
+ const { peer } = msg;
457
+ this.#peers.set(peer.sessionId, {
458
+ nickname: peer.nickname,
459
+ publicKey: peer.publicKey,
460
+ });
461
+ this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
462
+ this.#checkTrust(peer.nickname, peer.publicKey);
463
+
464
+ this.#ui.setOnlineCount(this.#peers.size + 1);
465
+ this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
466
+ this.#ui.handshakeConnect(peer.nickname);
467
+ this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname: peer.nickname });
468
+
469
+ // A newcomer doesn't know my presence — send only to them
470
+ if (this.#away || this.#statusText) {
471
+ this.#sendPayloadToPeer(peer.sessionId, this.#presencePayload());
472
+ }
473
+ }
474
+
475
+ // ── Peer left ─────────────────────────────────────────────────
476
+ #onPeerLeft(msg) {
477
+ const peer = this.#peers.get(msg.sessionId);
478
+ const nickname = peer?.nickname || msg.nickname || 'Unknown';
479
+
480
+ this.#hidePeerTyping(msg.sessionId, nickname);
481
+ this.#handshake.removePeer(msg.sessionId);
482
+ this.#nonceManager.removePeer(msg.sessionId);
483
+ this.#peers.delete(msg.sessionId);
484
+
485
+ this.#ui.setOnlineCount(this.#peers.size + 1);
486
+ this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
487
+ this.#ui.handshakeDisconnect(nickname);
488
+ this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
489
+ }
490
+
491
+ // ── Received encrypted message ────────────────────────────────
492
+ #onEncryptedMessage(msg) {
493
+ const peer = this.#peers.get(msg.from);
494
+ if (!peer) {
495
+ this.#ui.addErrorMessage('Message from unknown peer');
496
+ return;
497
+ }
498
+
499
+ const senderPublicKey = this.#handshake.getPeerPublicKey(msg.from);
500
+ if (!senderPublicKey) {
501
+ this.#ui.addErrorMessage(`Public key not found for ${peer.nickname}`);
502
+ return;
503
+ }
504
+
505
+ const ciphertext = Buffer.from(msg.payload.ciphertext, 'base64');
506
+ const nonce = Buffer.from(msg.payload.nonce, 'base64');
507
+
508
+ let plaintext = null;
509
+ const isDeniable = !!msg.payload.deniable;
510
+
511
+ // Deniable message path (symmetric crypto_secretbox)
512
+ if (isDeniable) {
513
+ // Anti-replay: deniable sends already use a structured NonceManager nonce.
514
+ if (!this.#nonceManager.validate(msg.from, nonce)) {
515
+ this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname, deniable: true });
516
+ this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
517
+ return;
518
+ }
519
+ const sharedKey = deriveSharedKey(this.#handshake.secretKey, senderPublicKey);
520
+ plaintext = decryptDeniable(ciphertext, nonce, sharedKey);
521
+ if (!plaintext) {
522
+ this.#auditLog.log(AuditEvent.DECRYPT_FAILURE, { nickname: peer.nickname, deniable: true });
523
+ this.#ui.addErrorMessage(`Failed to decrypt deniable message from ${peer.nickname}`);
524
+ return;
525
+ }
526
+ }
527
+
528
+ // Ratcheted message path (has ephemeralPublicKey)
529
+ if (!isDeniable && msg.payload.ephemeralPublicKey) {
530
+ const ratchet = this.#handshake.getRatchet(msg.from);
531
+ if (ratchet) {
532
+ const ephPub = Buffer.from(msg.payload.ephemeralPublicKey, 'base64');
533
+ plaintext = ratchet.decrypt(
534
+ ciphertext,
535
+ nonce,
536
+ ephPub,
537
+ msg.payload.counter,
538
+ msg.payload.previousCounter,
539
+ );
540
+ }
541
+
542
+ // Fallback to static decrypt if ratchet failed
543
+ if (!plaintext) {
544
+ if (!this.#nonceManager.validate(msg.from, nonce)) {
545
+ this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
546
+ this.#ui.addErrorMessage(`Failed to decrypt message from ${peer.nickname}`);
547
+ return;
548
+ }
549
+ plaintext = MessageCrypto.decryptWithFallback(
550
+ ciphertext,
551
+ nonce,
552
+ senderPublicKey,
553
+ this.#handshake.secretKey,
554
+ this.#handshake.getPreviousPeerPublicKey(msg.from),
555
+ this.#handshake.previousSecretKey,
556
+ );
557
+ }
558
+ } else if (!isDeniable) {
559
+ // Static message path (no ephemeralPublicKey)
560
+ if (!this.#nonceManager.validate(msg.from, nonce)) {
561
+ this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: peer.nickname });
562
+ this.#ui.addErrorMessage(`Invalid nonce from ${peer.nickname} (possible replay)`);
563
+ return;
564
+ }
565
+
566
+ plaintext = MessageCrypto.decryptWithFallback(
567
+ ciphertext,
568
+ nonce,
569
+ senderPublicKey,
570
+ this.#handshake.secretKey,
571
+ this.#handshake.getPreviousPeerPublicKey(msg.from),
572
+ this.#handshake.previousSecretKey,
573
+ );
574
+ }
575
+
576
+ if (!plaintext) {
577
+ this.#auditLog.log(AuditEvent.DECRYPT_FAILURE, { nickname: peer.nickname });
578
+ this.#ui.addErrorMessage(`Failed to decrypt message from ${peer.nickname} (invalid MAC)`);
579
+ return;
580
+ }
581
+
582
+ try {
583
+ const data = JSON.parse(plaintext.toString('utf-8'));
584
+
585
+ // Cover traffic: a decoy — drop it silently (no UI, no history, no receipt).
586
+ if (isCover(data)) {
587
+ return;
588
+ }
589
+
590
+ if (data.action === 'clear') {
591
+ this.#ui.clearChat();
592
+ return;
593
+ }
594
+
595
+ if (data.action === 'typing') {
596
+ this.#showPeerTyping(msg.from, peer.nickname);
597
+ return;
598
+ }
599
+
600
+ if (data.action === 'key_rotation') {
601
+ this.#handshake.updatePeerKey(msg.from, data.newPublicKey);
602
+ const p = this.#peers.get(msg.from);
603
+ if (p) {
604
+ p.publicKey = data.newPublicKey;
605
+ }
606
+ // E2E authenticated rotation — preserve verified status
607
+ this.#trustStore.autoUpdatePeer(peer.nickname, data.newPublicKey);
608
+ this.#auditLog.log(AuditEvent.KEY_ROTATION_PEER, { nickname: peer.nickname });
609
+ this.#ui.addSystemMessage(`${peer.nickname} rotated keys`);
610
+ return;
611
+ }
612
+
613
+ if (data.action === 'file_offer') {
614
+ // Require explicit consent — do NOT start receiving automatically.
615
+ this.#pendingFileOffers.set(data.transferId, {
616
+ from: msg.from,
617
+ data,
618
+ nickname: peer.nickname,
619
+ });
620
+ const kb = (data.fileSize / 1024).toFixed(0);
621
+ this.#ui.addSystemMessage(
622
+ `${peer.nickname} wants to send "${data.fileName}" (${kb}KB). ` +
623
+ `Use /accept ${data.transferId} or /reject ${data.transferId}.`,
624
+ );
625
+ this.#ui.playNotification();
626
+ return;
627
+ }
628
+
629
+ if (data.action === 'file_accept') {
630
+ this.#fileTransfer.handleFileAccept(msg.from, data);
631
+ return;
632
+ }
633
+
634
+ if (data.action === 'file_reject') {
635
+ this.#fileTransfer.handleFileReject(msg.from, data);
636
+ this.#ui.finishProgress();
637
+ return;
638
+ }
639
+
640
+ if (data.action === 'file_have') {
641
+ this.#fileTransfer.handleFileHave(msg.from, data);
642
+ return;
643
+ }
644
+
645
+ if (data.action === 'file_resume_request') {
646
+ const resend = this.#fileTransfer.getChunksForResend(data.transferId, data.missing);
647
+ if (resend && resend.length > 0) {
648
+ for (const c of resend) {
649
+ this.#sendPayloadToPeer(
650
+ msg.from,
651
+ JSON.stringify({
652
+ action: 'file_chunk',
653
+ transferId: data.transferId,
654
+ chunkIndex: c.index,
655
+ data: c.data,
656
+ sentAt: Date.now(),
657
+ }),
658
+ );
659
+ }
660
+ this.#sendPayloadToPeer(
661
+ msg.from,
662
+ JSON.stringify({
663
+ action: 'file_complete',
664
+ transferId: data.transferId,
665
+ sentAt: Date.now(),
666
+ }),
667
+ );
668
+ this.#ui.addSystemMessage(
669
+ `${peer.nickname} requested resend of ${resend.length} chunk(s) — resending`,
670
+ );
671
+ }
672
+ return;
673
+ }
674
+
675
+ if (data.action === 'file_chunk') {
676
+ const progress = this.#fileTransfer.handleFileChunk(msg.from, data);
677
+ if (progress && progress.percent % 10 === 0) {
678
+ this.#ui.updateProgress(progress.text, progress.percent);
679
+ }
680
+ return;
681
+ }
682
+
683
+ if (data.action === 'file_complete') {
684
+ this.#fileTransfer.handleFileComplete(msg.from, data).then(async (result) => {
685
+ this.#ui.finishProgress();
686
+ if (result.success) {
687
+ this.#ui.addSystemMessage(result.message);
688
+ if (result.savePath && isImageFile(result.savePath)) {
689
+ this.#lastImagePath = result.savePath;
690
+ try {
691
+ const preview = await renderImagePreview(result.savePath);
692
+ this.#ui.addImagePreview(preview);
693
+ } catch {
694
+ // Preview is best-effort — the file is already saved in downloads/
695
+ }
696
+ if (detectImageProtocol()) {
697
+ this.#ui.addInfoMessage('Tip: /img to view this image in high resolution');
698
+ }
699
+ } else if (result.savePath && isAudioFile(result.savePath)) {
700
+ this.#lastAudioPath = result.savePath;
701
+ this.#ui.addInfoMessage('🔊 Voice note received — /play to listen');
702
+ }
703
+ } else if (result.resume) {
704
+ // Lost chunks — request only what's missing
705
+ this.#ui.addSystemMessage(result.message);
706
+ this.#sendPayloadToPeer(
707
+ msg.from,
708
+ JSON.stringify({
709
+ action: 'file_resume_request',
710
+ transferId: data.transferId,
711
+ missing: result.missing,
712
+ sentAt: Date.now(),
713
+ }),
714
+ );
715
+ } else {
716
+ this.#ui.addErrorMessage(result.message);
717
+ }
718
+ });
719
+ return;
720
+ }
721
+
722
+ if (data.action === 'read_receipt') {
723
+ this.#onReadReceipt(peer.nickname, data.messageId);
724
+ return;
725
+ }
726
+
727
+ if (data.action === 'presence') {
728
+ const p = this.#peers.get(msg.from);
729
+ if (p) {
730
+ const wasAway = !!p.away;
731
+ const oldStatus = p.status || null;
732
+ p.away = !!data.away;
733
+ p.awayReason = typeof data.reason === 'string' ? data.reason.slice(0, 60) : null;
734
+ p.status = typeof data.status === 'string' ? data.status.slice(0, 60) : null;
735
+
736
+ if (p.away && !wasAway) {
737
+ const why = p.awayReason ? ` (${p.awayReason})` : '';
738
+ this.#ui.addSystemMessage(`${peer.nickname} is away${why}`);
739
+ } else if (!p.away && wasAway) {
740
+ this.#ui.addSystemMessage(`${peer.nickname} is back`);
741
+ }
742
+ if (p.status && p.status !== oldStatus) {
743
+ this.#ui.addSystemMessage(`${peer.nickname} set status: ${p.status}`);
744
+ }
745
+ }
746
+ return;
747
+ }
748
+
749
+ if (data.action === 'reaction') {
750
+ this.#ui.addSystemMessage(`${data.emoji} ${peer.nickname} reacted to a message`);
751
+ this.#ui.playNotification();
752
+ return;
753
+ }
754
+
755
+ if (data.action === 'edit_message') {
756
+ const author = this.#messageAuthors.get(data.messageId);
757
+ if (author && author === peer.nickname) {
758
+ this.#ui.addSystemMessage(`${peer.nickname} edited: ${data.newText} (edited)`);
759
+ }
760
+ return;
761
+ }
762
+
763
+ if (data.action === 'delete_message') {
764
+ const author = this.#messageAuthors.get(data.messageId);
765
+ if (author && author === peer.nickname) {
766
+ this.#ui.addSystemMessage(`${peer.nickname} deleted a message`);
767
+ }
768
+ return;
769
+ }
770
+
771
+ if (data.action === 'pin_message') {
772
+ this.#pinnedMessages.push({
773
+ messageId: data.messageId,
774
+ nickname: data.nickname,
775
+ text: data.text,
776
+ pinnedBy: peer.nickname,
777
+ pinnedAt: Date.now(),
778
+ });
779
+ this.#ui.addSystemMessage(
780
+ `\uD83D\uDCCC ${peer.nickname} pinned: "${data.text}" \u2014 ${data.nickname}`,
781
+ );
782
+ return;
783
+ }
784
+
785
+ if (data.action === 'unpin_message') {
786
+ this.#pinnedMessages = this.#pinnedMessages.filter((p) => p.messageId !== data.messageId);
787
+ this.#ui.addSystemMessage(`${peer.nickname} removed a pin`);
788
+ return;
789
+ }
790
+
791
+ // Text message received — hide typing indicator for this peer
792
+ this.#hidePeerTyping(msg.from, peer.nickname);
793
+ if (data.messageId) {
794
+ this.#lastReceivedMessageId = data.messageId;
795
+ this.#lastReceivedNickname = peer.nickname;
796
+ this.#lastReceivedText = data.text;
797
+ this.#messageAuthors.set(data.messageId, peer.nickname);
798
+ }
799
+ // Persist to encrypted history — never ephemeral or deniable messages
800
+ if (this.#historyStore?.isOpen && !data.ephemeral && !isDeniable && !data.deniable) {
801
+ this.#historyStore.append({
802
+ room: this.#currentRoom,
803
+ nickname: peer.nickname,
804
+ text: data.text,
805
+ isDM: !!data.isDM,
806
+ });
807
+ }
808
+
809
+ if (data.replyTo?.nickname && typeof data.replyTo.excerpt === 'string') {
810
+ this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
811
+ }
812
+
813
+ const mentioned = this.#mentionsMe(data.text) && !data.isDM;
814
+ const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
815
+ const { lineIndex } = this.#ui.addMessage(
816
+ peer.nickname,
817
+ data.text,
818
+ !!data.isDM,
819
+ ephLabel,
820
+ isDeniable || !!data.deniable,
821
+ mentioned,
822
+ );
823
+ const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
824
+ if (notify) {
825
+ this.#ui.playNotification();
826
+ }
827
+
828
+ if (data.ephemeral && data.ephemeral > 0) {
829
+ this.#scheduleEphemeralRemoval(lineIndex, data.ephemeral, peer.nickname);
830
+ }
831
+
832
+ // Confirm read to the author — E2EE payload, the server can't tell it from a message
833
+ if (
834
+ this.#receiptsEnabled &&
835
+ data.messageId &&
836
+ !data.ephemeral &&
837
+ !isDeniable &&
838
+ !data.deniable
839
+ ) {
840
+ this.#sendPayloadToPeer(
841
+ msg.from,
842
+ JSON.stringify({ action: 'read_receipt', messageId: data.messageId, sentAt: Date.now() }),
843
+ );
844
+ }
845
+
846
+ // DND / mentions-only gates desktop notifications too.
847
+ if (notify && (this.#ui.notifyEnabled || mentioned)) {
848
+ notifier.notify({
849
+ title: mentioned
850
+ ? `🔔 ${peer.nickname} mentioned you`
851
+ : data.isDM
852
+ ? `DM from ${peer.nickname}`
853
+ : `${peer.nickname} — CipherMesh`,
854
+ message: data.text.slice(0, 100),
855
+ sound: mentioned,
856
+ });
857
+ }
858
+ } catch {
859
+ this.#ui.addErrorMessage(`Invalid decrypted payload from ${peer.nickname}`);
860
+ } finally {
861
+ // Wipe plaintext buffer from memory (V8 strings from JSON.parse cannot be wiped)
862
+ if (plaintext && Buffer.isBuffer(plaintext)) {
863
+ sodium.sodium_memzero(plaintext);
864
+ }
865
+ }
866
+ }
867
+
868
+ // True if an incoming message references my nickname (@nick or standalone word).
869
+ #mentionsMe(text) {
870
+ return mentionsMe(text, this.#nickname);
871
+ }
872
+
873
+ // ── User input handling ───────────────────────────────────────
874
+ #handleUserInput(text) {
875
+ this.#noteActive();
876
+ if (text.startsWith('/')) {
877
+ this.#handleCommand(text);
878
+ return;
879
+ }
880
+
881
+ this.#sendMessageToAll(text);
882
+ }
883
+
884
+ #handleCommand(text) {
885
+ const parts = text.split(/\s+/);
886
+ const cmd = parts[0].toLowerCase();
887
+
888
+ switch (cmd) {
889
+ case '/help':
890
+ this.#ui.addInfoMessage('Available commands:');
891
+ this.#ui.addInfoMessage(' /help - Show this help');
892
+ this.#ui.addInfoMessage(' /users - List online users');
893
+ this.#ui.addInfoMessage(' /msg <nick> <text> - Send a private message (DM)');
894
+ this.#ui.addInfoMessage(' /reply <text> - Reply to the last received message');
895
+ this.#ui.addInfoMessage(' /away [reason] - Mark yourself as away');
896
+ this.#ui.addInfoMessage(' /back - Clear the away status');
897
+ this.#ui.addInfoMessage(' /autoaway <min|off> - Auto-away on inactivity');
898
+ this.#ui.addInfoMessage(' /status <text|off> - Set a status (accepts :emoji:)');
899
+ this.#ui.addInfoMessage(' /join <room> - Join a room');
900
+ this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
901
+ this.#ui.addInfoMessage(' /rooms - List available rooms');
902
+ this.#ui.addInfoMessage(' /room - Show the current room');
903
+ this.#ui.addInfoMessage(' /fingerprint - Show your fingerprint');
904
+ this.#ui.addInfoMessage(" /fingerprint <nick> - Another user's fingerprint");
905
+ this.#ui.addInfoMessage(' /verify <nick> - Show SAS code for verification');
906
+ this.#ui.addInfoMessage(' /verify-confirm <nick> - Confirm peer verification');
907
+ this.#ui.addInfoMessage(" /trust <nick> - Accept a peer's new key");
908
+ this.#ui.addInfoMessage(" /trustlist - Peers' trust status");
909
+ this.#ui.addInfoMessage(' /clear - Clear the chat');
910
+ this.#ui.addInfoMessage(' /file <path> - Send a file (max 50MB)');
911
+ this.#ui.addInfoMessage(
912
+ ' /voice [sec] - Record and send a voice note (default 10s)',
913
+ );
914
+ this.#ui.addInfoMessage(' /play [path] - Play the last received voice note');
915
+ this.#ui.addInfoMessage(' /sound [on|off] - Sound notifications');
916
+ this.#ui.addInfoMessage(' /notify [on|off] - Desktop notifications');
917
+ this.#ui.addInfoMessage(
918
+ ' /dnd [on|off|mentions|HH:MM-HH:MM] - Do not disturb / mentions only',
919
+ );
920
+ this.#ui.addInfoMessage(' /search <term> - Search the encrypted local history');
921
+ this.#ui.addInfoMessage(' /history [n] - Last n messages from history');
922
+ this.#ui.addInfoMessage(' /export [path] - Export the history (.txt or .json)');
923
+ this.#ui.addInfoMessage(' /audit [N] - Show the last N audit events');
924
+ this.#ui.addInfoMessage(' /ephemeral <time|off> - Ephemeral messages (e.g. 30s, 5m, 1h)');
925
+ this.#ui.addInfoMessage(' /react <emoji> - React to the last received message');
926
+ this.#ui.addInfoMessage(' /edit <new text> - Edit the last sent message');
927
+ this.#ui.addInfoMessage(' /delete - Delete the last sent message');
928
+ this.#ui.addInfoMessage(' /pin - Pin the last received message');
929
+ this.#ui.addInfoMessage(' /unpin - Remove the last pin');
930
+ this.#ui.addInfoMessage(' /pins - List pinned messages');
931
+ this.#ui.addInfoMessage(' /deniable [on|off] - Deniable mode (symmetric crypto)');
932
+ this.#ui.addInfoMessage(' /receipts [on|off] - Read receipts (✓✓)');
933
+ this.#ui.addInfoMessage(' /cover [on|constant|off] - Cover traffic (masks timing/volume)');
934
+ this.#ui.addInfoMessage(' /kick <nick> [reason] - Kick a user from the room (owner)');
935
+ this.#ui.addInfoMessage(' /mute <nick> [time] - Mute a user (owner, default 5m)');
936
+ this.#ui.addInfoMessage(' /ban <nick> [reason] - Ban a user from the room (owner)');
937
+ this.#ui.addInfoMessage(' /owner - Show the current room owner');
938
+ this.#ui.addInfoMessage(' /theme [name] - Nick color theme');
939
+ this.#ui.addInfoMessage(
940
+ ' /panic [yes] - Wipe EVERYTHING from disk and exit (duress)',
941
+ );
942
+ this.#ui.addInfoMessage(' /plugins - List loaded plugins');
943
+ this.#ui.addInfoMessage(' /quit - Leave the chat');
944
+ this.#ui.addInfoMessage('Tip: PageUp/PageDown scroll the chat history');
945
+ this.#ui.addInfoMessage('Tip: shortcodes like :fire: become emoji — Tab autocompletes');
946
+ break;
947
+
948
+ case '/users': {
949
+ const names = [...this.#peers.values()].map((p) => {
950
+ let label = p.nickname;
951
+ if (p.away) {
952
+ label += ` [away${p.awayReason ? `: ${p.awayReason}` : ''}]`;
953
+ }
954
+ if (p.status) {
955
+ label += ` — ${p.status}`;
956
+ }
957
+ return label;
958
+ });
959
+ let me = `${this.#nickname} (you)`;
960
+ if (this.#away) {
961
+ me += ` [away${this.#awayReason ? `: ${this.#awayReason}` : ''}]`;
962
+ }
963
+ if (this.#statusText) {
964
+ me += ` — ${this.#statusText}`;
965
+ }
966
+ this.#ui.addInfoMessage(
967
+ `Online (${names.length + 1}): ${me}, ${names.join(', ') || 'no one else'}`,
968
+ );
969
+ break;
970
+ }
971
+
972
+ case '/fingerprint': {
973
+ const targetNick = parts[1];
974
+ if (!targetNick) {
975
+ this.#ui.addInfoMessage(`Your fingerprint: ${this.#keyManager.fingerprint}`);
976
+ this.#ui.addPlainLines(
977
+ keyArt(Buffer.from(this.#keyManager.publicKeyB64, 'base64'), this.#nickname).split(
978
+ '\n',
979
+ ),
980
+ );
981
+ } else {
982
+ const found = [...this.#peers.values()].find(
983
+ (p) => p.nickname.toLowerCase() === targetNick.toLowerCase(),
984
+ );
985
+ if (found) {
986
+ const fp = KeyManager.computeFingerprint(Buffer.from(found.publicKey, 'base64'));
987
+ this.#ui.addInfoMessage(`${found.nickname}'s fingerprint: ${fp}`);
988
+ this.#ui.addPlainLines(
989
+ keyArt(Buffer.from(found.publicKey, 'base64'), found.nickname).split('\n'),
990
+ );
991
+ } else {
992
+ this.#ui.addErrorMessage(`User "${targetNick}" not found`);
993
+ }
994
+ }
995
+ break;
996
+ }
997
+
998
+ case '/clear':
999
+ this.#sendCommandToAll('clear');
1000
+ this.#ui.clearChat();
1001
+ break;
1002
+
1003
+ case '/sound': {
1004
+ const arg = parts[1]?.toLowerCase();
1005
+ if (arg === 'off') {
1006
+ this.#ui.setSoundEnabled(false);
1007
+ this.#ui.addInfoMessage('Sound notifications disabled');
1008
+ } else if (arg === 'on') {
1009
+ this.#ui.setSoundEnabled(true);
1010
+ this.#ui.addInfoMessage('Sound notifications enabled');
1011
+ } else {
1012
+ const status = this.#ui.soundEnabled ? 'enabled' : 'disabled';
1013
+ this.#ui.addInfoMessage(`Sound: ${status}. Use /sound on or /sound off`);
1014
+ }
1015
+ break;
1016
+ }
1017
+
1018
+ case '/verify': {
1019
+ const verifyNick = parts[1];
1020
+ if (!verifyNick) {
1021
+ this.#ui.addErrorMessage('Usage: /verify <nickname>');
1022
+ break;
1023
+ }
1024
+ const verifyPeer = [...this.#peers.values()].find(
1025
+ (p) => p.nickname.toLowerCase() === verifyNick.toLowerCase(),
1026
+ );
1027
+ if (!verifyPeer) {
1028
+ this.#ui.addErrorMessage(`User "${verifyNick}" not found`);
1029
+ break;
1030
+ }
1031
+ const sas = TrustStore.computeSAS(this.#keyManager.publicKeyB64, verifyPeer.publicKey);
1032
+ this.#auditLog.log(AuditEvent.SAS_VERIFY, { nickname: verifyPeer.nickname });
1033
+ this.#ui.addInfoMessage(`SAS code for ${verifyPeer.nickname}: ${sas}`);
1034
+ this.#ui.addPlainLines(
1035
+ keyArt(Buffer.from(verifyPeer.publicKey, 'base64'), verifyPeer.nickname).split('\n'),
1036
+ );
1037
+ this.#ui.addInfoMessage(
1038
+ 'Compare the code (or the art) with the peer by voice or another channel. If it matches, use /verify-confirm ' +
1039
+ verifyPeer.nickname,
1040
+ );
1041
+ qrcode.generate(sas, { small: true }, (qr) => {
1042
+ this.#ui.addPlainLines(qr.split('\n'));
1043
+ });
1044
+ break;
1045
+ }
1046
+
1047
+ case '/verify-confirm': {
1048
+ const confirmNick = parts[1];
1049
+ if (!confirmNick) {
1050
+ this.#ui.addErrorMessage('Usage: /verify-confirm <nickname>');
1051
+ break;
1052
+ }
1053
+ const confirmed = this.#trustStore.markVerified(confirmNick);
1054
+ if (confirmed) {
1055
+ this.#auditLog.log(AuditEvent.SAS_CONFIRM, { nickname: confirmNick });
1056
+ this.#ui.addSystemMessage(`${confirmNick} marked as verified`);
1057
+ } else {
1058
+ this.#ui.addErrorMessage(
1059
+ `Peer "${confirmNick}" not found in the trust store. The peer must be online first.`,
1060
+ );
1061
+ }
1062
+ break;
1063
+ }
1064
+
1065
+ case '/trust': {
1066
+ const trustNick = parts[1];
1067
+ if (!trustNick) {
1068
+ this.#ui.addErrorMessage('Usage: /trust <nickname>');
1069
+ break;
1070
+ }
1071
+ const trustPeer = [...this.#peers.values()].find(
1072
+ (p) => p.nickname.toLowerCase() === trustNick.toLowerCase(),
1073
+ );
1074
+ if (!trustPeer) {
1075
+ this.#ui.addErrorMessage(`User "${trustNick}" is not online`);
1076
+ break;
1077
+ }
1078
+ this.#trustStore.updatePeer(trustPeer.nickname, trustPeer.publicKey);
1079
+ this.#ui.addSystemMessage(`${trustPeer.nickname}'s key accepted (verification reset)`);
1080
+ break;
1081
+ }
1082
+
1083
+ case '/trustlist': {
1084
+ const peerList = [...this.#peers.values()];
1085
+ if (peerList.length === 0) {
1086
+ this.#ui.addInfoMessage('No peers online');
1087
+ break;
1088
+ }
1089
+ this.#ui.addInfoMessage('Trust status:');
1090
+ for (const p of peerList) {
1091
+ const record = this.#trustStore.getPeerRecord(p.nickname);
1092
+ let status;
1093
+ if (!record) {
1094
+ status = 'unknown';
1095
+ } else if (record.verified) {
1096
+ status = 'verified';
1097
+ } else {
1098
+ status = 'trusted (TOFU)';
1099
+ }
1100
+ this.#ui.addInfoMessage(` ${p.nickname}: ${status}`);
1101
+ }
1102
+ break;
1103
+ }
1104
+
1105
+ case '/file': {
1106
+ const filePath = parts.slice(1).join(' ');
1107
+ if (!filePath) {
1108
+ this.#ui.addErrorMessage('Usage: /file <path>');
1109
+ break;
1110
+ }
1111
+ if (this.#peers.size === 0) {
1112
+ this.#ui.addSystemMessage('No peers online to receive files');
1113
+ break;
1114
+ }
1115
+ this.#sendFile(filePath);
1116
+ break;
1117
+ }
1118
+
1119
+ case '/notify': {
1120
+ const notifyArg = parts[1]?.toLowerCase();
1121
+ if (notifyArg === 'off') {
1122
+ this.#ui.setNotifyEnabled(false);
1123
+ this.#ui.addInfoMessage('Desktop notifications disabled');
1124
+ } else if (notifyArg === 'on') {
1125
+ this.#ui.setNotifyEnabled(true);
1126
+ this.#ui.addInfoMessage('Desktop notifications enabled');
1127
+ } else {
1128
+ const status = this.#ui.notifyEnabled ? 'enabled' : 'disabled';
1129
+ this.#ui.addInfoMessage(
1130
+ `Desktop notifications: ${status}. Use /notify on or /notify off`,
1131
+ );
1132
+ }
1133
+ break;
1134
+ }
1135
+
1136
+ case '/msg': {
1137
+ const msgNick = parts[1];
1138
+ if (!msgNick) {
1139
+ this.#ui.addErrorMessage('Usage: /msg <nick> <text>');
1140
+ break;
1141
+ }
1142
+ const msgText = parts.slice(2).join(' ');
1143
+ if (!msgText) {
1144
+ this.#ui.addErrorMessage('Usage: /msg <nick> <text>');
1145
+ break;
1146
+ }
1147
+ const msgPeer = [...this.#peers.entries()].find(
1148
+ ([, p]) => p.nickname.toLowerCase() === msgNick.toLowerCase(),
1149
+ );
1150
+ if (!msgPeer) {
1151
+ this.#ui.addErrorMessage(`User "${msgNick}" not found`);
1152
+ break;
1153
+ }
1154
+ this.#sendMessageToPeer(msgPeer[0], msgPeer[1].nickname, msgText);
1155
+ break;
1156
+ }
1157
+
1158
+ case '/join': {
1159
+ const roomName = parts[1];
1160
+ if (!roomName) {
1161
+ this.#ui.addErrorMessage('Usage: /join <room>');
1162
+ break;
1163
+ }
1164
+ this.#connection.send(createChangeRoom(roomName));
1165
+ break;
1166
+ }
1167
+
1168
+ case '/rooms':
1169
+ this.#connection.send(createListRooms());
1170
+ break;
1171
+
1172
+ case '/invite': {
1173
+ let hostPort = parts[1];
1174
+ if (!hostPort) {
1175
+ hostPort = (this.#connection.url || '').replace(/^wss?:\/\//, '');
1176
+ }
1177
+ const inviteUri = buildInvite(hostPort, this.#currentRoom);
1178
+ if (!inviteUri) {
1179
+ this.#ui.addErrorMessage('Invalid address. Usage: /invite [host:port]');
1180
+ break;
1181
+ }
1182
+ if (/^(localhost|127\.)/.test(hostPort)) {
1183
+ this.#ui.addErrorMessage(
1184
+ 'You are connected via localhost — this invite only works on your own machine.',
1185
+ );
1186
+ this.#ui.addInfoMessage(
1187
+ 'Pass the address the peer can reach: /invite <ip>:<port> (e.g. Tailscale IP)',
1188
+ );
1189
+ }
1190
+ this.#ui.addInfoMessage(`Invite: ${inviteUri}`);
1191
+ this.#ui.addInfoMessage(
1192
+ 'The peer pastes this string (or scans the QR) at the "Server" prompt',
1193
+ );
1194
+ qrcode.generate(inviteUri, { small: true }, (qr) => {
1195
+ this.#ui.addPlainLines(qr.split('\n'));
1196
+ });
1197
+ break;
1198
+ }
1199
+
1200
+ case '/room':
1201
+ this.#ui.addInfoMessage(`Current room: #${this.#currentRoom}`);
1202
+ break;
1203
+
1204
+ case '/deniable': {
1205
+ const denArg = parts[1]?.toLowerCase();
1206
+ if (denArg === 'off') {
1207
+ this.#deniableMode = false;
1208
+ this.#ui.removeHeaderIndicator('deniable');
1209
+ this.#ui.addInfoMessage('Deniable mode disabled');
1210
+ } else if (denArg === 'on') {
1211
+ this.#deniableMode = true;
1212
+ this.#ui.setHeaderIndicator('deniable', '{magenta-fg}[D]{/magenta-fg}');
1213
+ this.#ui.addInfoMessage(
1214
+ 'Deniable mode enabled (symmetric crypto — plausible deniability)',
1215
+ );
1216
+ } else {
1217
+ const status = this.#deniableMode ? 'enabled' : 'disabled';
1218
+ this.#ui.addInfoMessage(`Deniable mode: ${status}. Use /deniable on or /deniable off`);
1219
+ }
1220
+ break;
1221
+ }
1222
+
1223
+ case '/away': {
1224
+ this.#away = true;
1225
+ this.#autoAwaySet = false; // an explicit /away is not auto
1226
+ this.#awayReason = applyShortcodes(parts.slice(1).join(' ')).slice(0, 60) || null;
1227
+ this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
1228
+ this.#ui.addInfoMessage(
1229
+ this.#awayReason ? `You are away: ${this.#awayReason}` : 'You are away',
1230
+ );
1231
+ this.#broadcastPresence();
1232
+ break;
1233
+ }
1234
+
1235
+ case '/back': {
1236
+ if (!this.#away) {
1237
+ this.#ui.addInfoMessage('You are not away');
1238
+ break;
1239
+ }
1240
+ this.#away = false;
1241
+ this.#awayReason = null;
1242
+ this.#autoAwaySet = false;
1243
+ this.#ui.removeHeaderIndicator('away');
1244
+ this.#ui.addInfoMessage("You're back");
1245
+ this.#broadcastPresence();
1246
+ break;
1247
+ }
1248
+
1249
+ case '/autoaway': {
1250
+ const aaArg = parts[1]?.toLowerCase();
1251
+ if (aaArg === 'off' || aaArg === '0') {
1252
+ this.#autoAwayMs = 0;
1253
+ this.#armAutoAway();
1254
+ this.#ui.addInfoMessage('Auto-away disabled');
1255
+ } else {
1256
+ const min = parseInt(aaArg, 10);
1257
+ if (!Number.isInteger(min) || min < 1 || min > 240) {
1258
+ this.#ui.addInfoMessage(
1259
+ `Auto-away: ${this.#autoAwayMs ? `${this.#autoAwayMs / 60000}min` : 'off'}. Usage: /autoaway <minutes|off>`,
1260
+ );
1261
+ break;
1262
+ }
1263
+ this.#autoAwayMs = min * 60_000;
1264
+ this.#armAutoAway();
1265
+ this.#ui.addInfoMessage(`Auto-away after ${min}min of inactivity`);
1266
+ }
1267
+ break;
1268
+ }
1269
+
1270
+ case '/status': {
1271
+ const statusArg = parts.slice(1).join(' ');
1272
+ if (!statusArg || statusArg.toLowerCase() === 'off') {
1273
+ this.#statusText = null;
1274
+ this.#ui.addInfoMessage('Status cleared');
1275
+ } else {
1276
+ this.#statusText = applyShortcodes(statusArg).slice(0, 60);
1277
+ this.#ui.addInfoMessage(`Status: ${this.#statusText}`);
1278
+ }
1279
+ this.#broadcastPresence();
1280
+ break;
1281
+ }
1282
+
1283
+ case '/reply': {
1284
+ const replyText = parts.slice(1).join(' ');
1285
+ if (!replyText) {
1286
+ this.#ui.addErrorMessage('Usage: /reply <text>');
1287
+ break;
1288
+ }
1289
+ if (!this.#lastReceivedMessageId || !this.#lastReceivedText) {
1290
+ this.#ui.addErrorMessage('No message to reply to');
1291
+ break;
1292
+ }
1293
+ const excerpt =
1294
+ this.#lastReceivedText.length > 60
1295
+ ? `${this.#lastReceivedText.slice(0, 57)}...`
1296
+ : this.#lastReceivedText;
1297
+ this.#sendMessageToAll(replyText, {
1298
+ messageId: this.#lastReceivedMessageId,
1299
+ nickname: this.#lastReceivedNickname,
1300
+ excerpt,
1301
+ });
1302
+ break;
1303
+ }
1304
+
1305
+ case '/receipts': {
1306
+ const receiptsArg = parts[1]?.toLowerCase();
1307
+ if (receiptsArg === 'off') {
1308
+ this.#receiptsEnabled = false;
1309
+ this.#ui.addInfoMessage('Read receipts disabled — you no longer send read confirmations');
1310
+ } else if (receiptsArg === 'on') {
1311
+ this.#receiptsEnabled = true;
1312
+ this.#ui.addInfoMessage('Read receipts enabled');
1313
+ } else {
1314
+ const receiptsStatus = this.#receiptsEnabled ? 'enabled' : 'disabled';
1315
+ this.#ui.addInfoMessage(
1316
+ `Read receipts: ${receiptsStatus}. Use /receipts on or /receipts off`,
1317
+ );
1318
+ }
1319
+ break;
1320
+ }
1321
+
1322
+ case '/dnd': {
1323
+ const dndArg = parts[1]?.toLowerCase();
1324
+ if (!dndArg) {
1325
+ const win = this.#dndWindow ? ' + quiet window' : '';
1326
+ this.#ui.addInfoMessage(
1327
+ `DND: ${this.#dndMode}${win}. Usage: /dnd on | off | mentions | HH:MM-HH:MM`,
1328
+ );
1329
+ } else if (dndArg === 'on' || dndArg === 'off' || dndArg === 'mentions') {
1330
+ this.#dndMode = dndArg;
1331
+ if (dndArg === 'off' && !this.#dndWindow) {
1332
+ this.#ui.removeHeaderIndicator('dnd');
1333
+ } else {
1334
+ this.#ui.setHeaderIndicator('dnd', '{yellow-fg}[🔕]{/yellow-fg}');
1335
+ }
1336
+ this.#ui.addInfoMessage(
1337
+ dndArg === 'mentions'
1338
+ ? 'DND: mentions only notify'
1339
+ : dndArg === 'on'
1340
+ ? 'DND: total silence'
1341
+ : 'DND disabled',
1342
+ );
1343
+ } else {
1344
+ const win = parseDndWindow(dndArg);
1345
+ if (!win) {
1346
+ this.#ui.addErrorMessage('Invalid format. Usage: /dnd HH:MM-HH:MM (e.g. 22:00-08:00)');
1347
+ break;
1348
+ }
1349
+ this.#dndWindow = win;
1350
+ this.#ui.setHeaderIndicator('dnd', '{yellow-fg}[🔕]{/yellow-fg}');
1351
+ this.#ui.addInfoMessage(`Quiet hours ${dndArg} — mentions only during the window`);
1352
+ }
1353
+ break;
1354
+ }
1355
+
1356
+ case '/cover': {
1357
+ const coverArg = parts[1]?.toLowerCase();
1358
+ if (coverArg === 'on' || coverArg === 'jitter') {
1359
+ this.#setCoverMode('jitter');
1360
+ this.#ui.setHeaderIndicator('cover', '{cyan-fg}[C]{/cyan-fg}');
1361
+ this.#ui.addInfoMessage(
1362
+ 'Cover traffic (jitter) enabled — encrypted decoys at random intervals',
1363
+ );
1364
+ } else if (coverArg === 'constant') {
1365
+ this.#setCoverMode('constant');
1366
+ this.#ui.setHeaderIndicator('cover', '{cyan-fg}[C=]{/cyan-fg}');
1367
+ this.#ui.addInfoMessage(
1368
+ 'Cover traffic (constant rate) enabled — uniform encrypted flow; ' +
1369
+ 'your messages go out in the next slot (up to ~3s delay)',
1370
+ );
1371
+ } else if (coverArg === 'off') {
1372
+ this.#setCoverMode('off');
1373
+ this.#ui.removeHeaderIndicator('cover');
1374
+ this.#ui.addInfoMessage('Cover traffic disabled');
1375
+ } else {
1376
+ this.#ui.addInfoMessage(
1377
+ `Cover traffic: ${this.#coverMode}. Use /cover on (jitter), /cover constant or /cover off`,
1378
+ );
1379
+ }
1380
+ break;
1381
+ }
1382
+
1383
+ case '/search': {
1384
+ if (!this.#historyStore?.isOpen) {
1385
+ this.#ui.addErrorMessage('History disabled — start the client with a passphrase');
1386
+ break;
1387
+ }
1388
+ const term = parts.slice(1).join(' ');
1389
+ if (!term) {
1390
+ this.#ui.addErrorMessage('Usage: /search <term>');
1391
+ break;
1392
+ }
1393
+ const results = this.#historyStore.search(term);
1394
+ if (results.length === 0) {
1395
+ this.#ui.addInfoMessage(`Nothing found for "${term}"`);
1396
+ break;
1397
+ }
1398
+ this.#ui.addInfoMessage(`${results.length} result(s) for "${term}":`);
1399
+ for (const e of results) {
1400
+ this.#ui.addInfoMessage(` ${this.#formatHistoryEntry(e)}`);
1401
+ }
1402
+ break;
1403
+ }
1404
+
1405
+ case '/history': {
1406
+ if (!this.#historyStore?.isOpen) {
1407
+ this.#ui.addErrorMessage('History disabled — start the client with a passphrase');
1408
+ break;
1409
+ }
1410
+ const count = parseInt(parts[1]) || 20;
1411
+ const entries = this.#historyStore.recent(count);
1412
+ if (entries.length === 0) {
1413
+ this.#ui.addInfoMessage('History empty');
1414
+ break;
1415
+ }
1416
+ this.#ui.addInfoMessage(`Last ${entries.length} message(s) from history:`);
1417
+ for (const e of entries) {
1418
+ this.#ui.addInfoMessage(` ${this.#formatHistoryEntry(e)}`);
1419
+ }
1420
+ break;
1421
+ }
1422
+
1423
+ case '/export': {
1424
+ if (!this.#historyStore?.isOpen) {
1425
+ this.#ui.addErrorMessage('History disabled — start the client with a passphrase');
1426
+ break;
1427
+ }
1428
+ if (this.#historyStore.size === 0) {
1429
+ this.#ui.addInfoMessage('History empty, nothing to export');
1430
+ break;
1431
+ }
1432
+ let target = parts.slice(1).join(' ');
1433
+ if (!target) {
1434
+ const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
1435
+ target = `exports/ciphermesh-${stamp}.txt`;
1436
+ }
1437
+ try {
1438
+ const fullPath = resolve(target);
1439
+ mkdirSync(dirname(fullPath), { recursive: true });
1440
+ const count = this.#historyStore.exportTo(fullPath);
1441
+ this.#ui.addSystemMessage(`${count} message(s) exported to ${fullPath}`);
1442
+ this.#ui.addErrorMessage('Warning: the exported file is in plain text');
1443
+ } catch (err) {
1444
+ this.#ui.addErrorMessage(`Export failed: ${err.message}`);
1445
+ }
1446
+ break;
1447
+ }
1448
+
1449
+ case '/audit': {
1450
+ const auditCount = parseInt(parts[1]) || 20;
1451
+ const events = this.#auditLog.readLast(auditCount);
1452
+ if (events.length === 0) {
1453
+ this.#ui.addInfoMessage('No audit events recorded');
1454
+ } else {
1455
+ this.#ui.addInfoMessage(`Last ${events.length} audit event(s):`);
1456
+ for (const e of events) {
1457
+ const { ts, event, ...rest } = e;
1458
+ const details = Object.keys(rest).length > 0 ? ` — ${JSON.stringify(rest)}` : '';
1459
+ this.#ui.addInfoMessage(` [${ts}] ${event}${details}`);
1460
+ }
1461
+ }
1462
+ break;
1463
+ }
1464
+
1465
+ case '/react': {
1466
+ const emojiArg = parts[1];
1467
+ if (!emojiArg) {
1468
+ this.#ui.addErrorMessage('Usage: /react <emoji> (e.g. :fire: :thumbsup: :heart:)');
1469
+ break;
1470
+ }
1471
+ if (!this.#lastReceivedMessageId) {
1472
+ this.#ui.addErrorMessage('No message to react to');
1473
+ break;
1474
+ }
1475
+ const emoji = EMOJI_MAP[emojiArg] || emojiArg;
1476
+ const reactionPayload = JSON.stringify({
1477
+ action: 'reaction',
1478
+ targetMessageId: this.#lastReceivedMessageId,
1479
+ emoji,
1480
+ sentAt: Date.now(),
1481
+ });
1482
+ this.#broadcastPayload(reactionPayload);
1483
+ this.#ui.addSystemMessage(
1484
+ `${emoji} You reacted to ${this.#lastReceivedNickname}'s message`,
1485
+ );
1486
+ break;
1487
+ }
1488
+
1489
+ case '/edit': {
1490
+ const editText = parts.slice(1).join(' ');
1491
+ if (!editText) {
1492
+ this.#ui.addErrorMessage('Usage: /edit <new text>');
1493
+ break;
1494
+ }
1495
+ if (!this.#lastSentMessageId) {
1496
+ this.#ui.addErrorMessage('No message to edit');
1497
+ break;
1498
+ }
1499
+ const editPayload = JSON.stringify({
1500
+ action: 'edit_message',
1501
+ messageId: this.#lastSentMessageId,
1502
+ newText: editText,
1503
+ sentAt: Date.now(),
1504
+ });
1505
+ this.#broadcastPayload(editPayload);
1506
+ this.#ui.addSystemMessage(`You edited: ${editText} (edited)`);
1507
+ break;
1508
+ }
1509
+
1510
+ case '/delete': {
1511
+ if (!this.#lastSentMessageId) {
1512
+ this.#ui.addErrorMessage('No message to delete');
1513
+ break;
1514
+ }
1515
+ const deletePayload = JSON.stringify({
1516
+ action: 'delete_message',
1517
+ messageId: this.#lastSentMessageId,
1518
+ sentAt: Date.now(),
1519
+ });
1520
+ this.#broadcastPayload(deletePayload);
1521
+ this.#lastSentMessageId = null;
1522
+ this.#ui.addSystemMessage('You deleted a message');
1523
+ break;
1524
+ }
1525
+
1526
+ case '/pin': {
1527
+ if (!this.#lastReceivedMessageId || !this.#lastReceivedText) {
1528
+ this.#ui.addErrorMessage('No message to pin');
1529
+ break;
1530
+ }
1531
+ const pinPayload = JSON.stringify({
1532
+ action: 'pin_message',
1533
+ messageId: this.#lastReceivedMessageId,
1534
+ nickname: this.#lastReceivedNickname,
1535
+ text: this.#lastReceivedText,
1536
+ sentAt: Date.now(),
1537
+ });
1538
+ this.#broadcastPayload(pinPayload);
1539
+ this.#pinnedMessages.push({
1540
+ messageId: this.#lastReceivedMessageId,
1541
+ nickname: this.#lastReceivedNickname,
1542
+ text: this.#lastReceivedText,
1543
+ pinnedBy: this.#nickname,
1544
+ pinnedAt: Date.now(),
1545
+ });
1546
+ this.#ui.addSystemMessage(
1547
+ `\uD83D\uDCCC You pinned: "${this.#lastReceivedText}" \u2014 ${this.#lastReceivedNickname}`,
1548
+ );
1549
+ break;
1550
+ }
1551
+
1552
+ case '/unpin': {
1553
+ if (this.#pinnedMessages.length === 0) {
1554
+ this.#ui.addErrorMessage('No pinned messages');
1555
+ break;
1556
+ }
1557
+ const removed = this.#pinnedMessages.pop();
1558
+ const unpinPayload = JSON.stringify({
1559
+ action: 'unpin_message',
1560
+ messageId: removed.messageId,
1561
+ sentAt: Date.now(),
1562
+ });
1563
+ this.#broadcastPayload(unpinPayload);
1564
+ this.#ui.addSystemMessage('You removed the pin');
1565
+ break;
1566
+ }
1567
+
1568
+ case '/pins': {
1569
+ if (this.#pinnedMessages.length === 0) {
1570
+ this.#ui.addInfoMessage('No pinned messages');
1571
+ } else {
1572
+ this.#ui.addInfoMessage('Pinned messages:');
1573
+ for (const pin of this.#pinnedMessages) {
1574
+ this.#ui.addInfoMessage(
1575
+ ` \uD83D\uDCCC "${pin.text}" \u2014 ${pin.nickname} (pinned by ${pin.pinnedBy})`,
1576
+ );
1577
+ }
1578
+ }
1579
+ break;
1580
+ }
1581
+
1582
+ case '/ephemeral': {
1583
+ const ephArg = parts[1]?.toLowerCase();
1584
+ if (!ephArg || ephArg === 'off') {
1585
+ this.#ephemeralMode = false;
1586
+ this.#ephemeralDurationMs = 0;
1587
+ this.#ui.removeHeaderIndicator('ephemeral');
1588
+ this.#ui.addInfoMessage('Ephemeral mode disabled');
1589
+ } else {
1590
+ const ms = this.#parseEphemeralTime(ephArg);
1591
+ if (!ms) {
1592
+ this.#ui.addErrorMessage('Invalid format. Use: 30s, 5m, 1h or off');
1593
+ break;
1594
+ }
1595
+ if (ms > 3_600_000) {
1596
+ this.#ui.addErrorMessage('Maximum: 1h (3600s)');
1597
+ break;
1598
+ }
1599
+ this.#ephemeralMode = true;
1600
+ this.#ephemeralDurationMs = ms;
1601
+ this.#ui.setHeaderIndicator('ephemeral', `{yellow-fg}[E ${ephArg}]{/yellow-fg}`);
1602
+ this.#ui.addInfoMessage(`Ephemeral mode enabled: ${ephArg}`);
1603
+ }
1604
+ break;
1605
+ }
1606
+
1607
+ case '/kick': {
1608
+ const kickNick = parts[1];
1609
+ if (!kickNick) {
1610
+ this.#ui.addErrorMessage('Usage: /kick <nick> [reason]');
1611
+ break;
1612
+ }
1613
+ const kickReason = parts.slice(2).join(' ');
1614
+ this.#connection.send(createKickPeer(kickNick, kickReason));
1615
+ break;
1616
+ }
1617
+
1618
+ case '/mute': {
1619
+ const muteNick = parts[1];
1620
+ if (!muteNick) {
1621
+ this.#ui.addErrorMessage('Usage: /mute <nick> [time]');
1622
+ break;
1623
+ }
1624
+ const muteTimeStr = parts[2] || '5m';
1625
+ const muteDuration = this.#parseEphemeralTime(muteTimeStr);
1626
+ if (!muteDuration) {
1627
+ this.#ui.addErrorMessage('Invalid time format. Use: 30s, 5m, 1h');
1628
+ break;
1629
+ }
1630
+ this.#connection.send(createMutePeer(muteNick, muteDuration));
1631
+ break;
1632
+ }
1633
+
1634
+ case '/ban': {
1635
+ const banNick = parts[1];
1636
+ if (!banNick) {
1637
+ this.#ui.addErrorMessage('Usage: /ban <nick> [reason]');
1638
+ break;
1639
+ }
1640
+ const banReason = parts.slice(2).join(' ');
1641
+ this.#connection.send(createBanPeer(banNick, banReason));
1642
+ break;
1643
+ }
1644
+
1645
+ case '/owner': {
1646
+ if (this.#currentRoom === 'general') {
1647
+ this.#ui.addInfoMessage('The #general room has no owner');
1648
+ } else if (this.#currentRoomOwner) {
1649
+ const isYou =
1650
+ this.#currentRoomOwner.toLowerCase() === this.#nickname.toLowerCase() ? ' (you)' : '';
1651
+ this.#ui.addInfoMessage(
1652
+ `Owner of room #${this.#currentRoom}: ${this.#currentRoomOwner}${isYou}`,
1653
+ );
1654
+ } else {
1655
+ this.#ui.addInfoMessage(`Room #${this.#currentRoom} has no owner`);
1656
+ }
1657
+ break;
1658
+ }
1659
+
1660
+ case '/plugins': {
1661
+ if (!this.#pluginManager || this.#pluginManager.pluginCount === 0) {
1662
+ this.#ui.addInfoMessage('No plugins loaded. Place .js files in ~/.ciphermesh/plugins/');
1663
+ } else {
1664
+ const names = this.#pluginManager.getPluginNames();
1665
+ this.#ui.addInfoMessage(`Plugins loaded (${names.length}): ${names.join(', ')}`);
1666
+ const cmds = this.#pluginManager.getCommandNames();
1667
+ if (cmds.length > 0) {
1668
+ this.#ui.addInfoMessage(`Commands: ${cmds.join(', ')}`);
1669
+ }
1670
+ }
1671
+ break;
1672
+ }
1673
+
1674
+ case '/accept': {
1675
+ const pending = parts[1]
1676
+ ? this.#pendingFileOffers.get(parts[1])
1677
+ : this.#pendingFileOffers.values().next().value;
1678
+ if (!pending) {
1679
+ this.#ui.addErrorMessage('No pending file offer.');
1680
+ break;
1681
+ }
1682
+ const offer = this.#fileTransfer.handleFileOffer(
1683
+ pending.from,
1684
+ pending.data,
1685
+ pending.nickname,
1686
+ );
1687
+ this.#ui.addSystemMessage(`Accepting: ${offer.message}`);
1688
+ this.#sendPayloadToPeer(
1689
+ pending.from,
1690
+ JSON.stringify({
1691
+ action: 'file_accept',
1692
+ transferId: pending.data.transferId,
1693
+ have: offer.have,
1694
+ sentAt: Date.now(),
1695
+ }),
1696
+ );
1697
+ this.#pendingFileOffers.delete(pending.data.transferId);
1698
+ break;
1699
+ }
1700
+
1701
+ case '/reject': {
1702
+ const pending = parts[1]
1703
+ ? this.#pendingFileOffers.get(parts[1])
1704
+ : this.#pendingFileOffers.values().next().value;
1705
+ if (!pending) {
1706
+ this.#ui.addErrorMessage('No pending file offer.');
1707
+ break;
1708
+ }
1709
+ this.#sendPayloadToPeer(
1710
+ pending.from,
1711
+ JSON.stringify({
1712
+ action: 'file_reject',
1713
+ transferId: pending.data.transferId,
1714
+ sentAt: Date.now(),
1715
+ }),
1716
+ );
1717
+ this.#pendingFileOffers.delete(pending.data.transferId);
1718
+ this.#ui.addSystemMessage(`Offer from ${pending.nickname} rejected.`);
1719
+ break;
1720
+ }
1721
+
1722
+ case '/img': {
1723
+ const imgPath = parts.slice(1).join(' ').trim() || this.#lastImagePath;
1724
+ if (!imgPath) {
1725
+ this.#ui.addErrorMessage('No recent image. Usage: /img [path]');
1726
+ break;
1727
+ }
1728
+ const protocol = detectImageProtocol();
1729
+ if (!protocol) {
1730
+ this.#ui.addInfoMessage(
1731
+ `Your terminal doesn't support inline images (kitty/iTerm2). File saved at: ${imgPath}`,
1732
+ );
1733
+ break;
1734
+ }
1735
+ loadImageBuffers(imgPath)
1736
+ .then((bufs) => {
1737
+ const widthCells = Math.min((process.stdout.columns || 80) - 4, 80);
1738
+ this.#ui.showRealImage(encodeInlineImage(protocol, bufs, { widthCells }));
1739
+ })
1740
+ .catch((e) => this.#ui.addErrorMessage(`Could not render: ${e.message}`));
1741
+ break;
1742
+ }
1743
+
1744
+ case '/voice': {
1745
+ const secs = Math.min(60, Math.max(1, parseInt(parts[1]) || 10));
1746
+ if (this.#peers.size === 0) {
1747
+ this.#ui.addSystemMessage('No peers online to receive the voice note');
1748
+ break;
1749
+ }
1750
+ this.#ui.addSystemMessage(`🎤 Recording voice note for ${secs}s... (speak now)`);
1751
+ recordVoiceNote(tmpdir(), secs, Date.now())
1752
+ .then((path) => {
1753
+ this.#ui.addSystemMessage('Sending voice note...');
1754
+ this.#sendFile(path);
1755
+ })
1756
+ .catch((e) => this.#ui.addErrorMessage(`Voice note: ${e.message}`));
1757
+ break;
1758
+ }
1759
+
1760
+ case '/play': {
1761
+ const audioPath = parts.slice(1).join(' ').trim() || this.#lastAudioPath;
1762
+ if (!audioPath) {
1763
+ this.#ui.addErrorMessage('No recent voice note. Usage: /play [path]');
1764
+ break;
1765
+ }
1766
+ this.#ui.addSystemMessage('🔊 Playing voice note...');
1767
+ playVoiceNote(audioPath).catch((e) => this.#ui.addErrorMessage(`Play: ${e.message}`));
1768
+ break;
1769
+ }
1770
+
1771
+ case '/theme': {
1772
+ const themeArg = parts[1]?.toLowerCase();
1773
+ if (!themeArg) {
1774
+ this.#ui.addInfoMessage(
1775
+ `Current theme: ${getThemeName()}. Available: ${themeNames().join(', ')}`,
1776
+ );
1777
+ } else if (themeNames().includes(themeArg)) {
1778
+ setTheme(themeArg);
1779
+ this.#ui.addInfoMessage(
1780
+ `Theme "${themeArg}" applied — new messages use the new nick colors`,
1781
+ );
1782
+ } else {
1783
+ this.#ui.addErrorMessage(`Unknown theme. Available: ${themeNames().join(', ')}`);
1784
+ }
1785
+ break;
1786
+ }
1787
+
1788
+ case '/backup': {
1789
+ const path = parts.slice(1).join(' ').trim() || './ciphermesh-backup.json';
1790
+ if (!this.#passphrase) {
1791
+ this.#ui.addErrorMessage(
1792
+ 'The backup is encrypted with the session passphrase — restart and set a passphrase.',
1793
+ );
1794
+ break;
1795
+ }
1796
+ try {
1797
+ const envelope = exportBackup(
1798
+ {
1799
+ identity: this.#keyManager.serialize(),
1800
+ trust: this.#trustStore.exportData(),
1801
+ },
1802
+ this.#passphrase,
1803
+ );
1804
+ writeFileSync(resolve(path), envelope, { encoding: 'utf-8', mode: 0o600 });
1805
+ this.#ui.addSystemMessage(
1806
+ `Identity + trust backup saved to ${path} (encrypted). ` +
1807
+ 'Restore it on another machine at startup.',
1808
+ );
1809
+ } catch (e) {
1810
+ this.#ui.addErrorMessage(`Failed to save backup: ${e.message}`);
1811
+ }
1812
+ break;
1813
+ }
1814
+
1815
+ case '/nick': {
1816
+ const newNick = (parts[1] || '').trim().replace(/[^a-zA-Z0-9_-]/g, '');
1817
+ if (newNick.length < 1 || newNick.length > 20) {
1818
+ this.#ui.addErrorMessage('Usage: /nick <new> (1-20 characters: a-z, 0-9, _, -)');
1819
+ break;
1820
+ }
1821
+ if (this.#sessionId) {
1822
+ this.#ui.addErrorMessage("Can't change nickname after joining — reconnect to change it.");
1823
+ break;
1824
+ }
1825
+ // Only useful before a successful JOIN (e.g. recovering from
1826
+ // "nickname taken"): the server still accepts a JOIN on this socket.
1827
+ this.#nickname = newNick;
1828
+ this.#ui.setNickname(newNick);
1829
+ this.#connection.send(createJoin(newNick, this.#keyManager.publicKeyB64));
1830
+ this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
1831
+ break;
1832
+ }
1833
+
1834
+ case '/retention': {
1835
+ if (!this.#historyStore?.isOpen) {
1836
+ this.#ui.addErrorMessage('History is not active (open the session with a passphrase).');
1837
+ break;
1838
+ }
1839
+ const ms = this.#parseRetentionTime(parts[1]?.toLowerCase());
1840
+ if (!ms) {
1841
+ this.#ui.addErrorMessage(
1842
+ 'Usage: /retention <time> (e.g. 7d, 24h, 30m) — wipes history older than that from disk',
1843
+ );
1844
+ break;
1845
+ }
1846
+ const removed = this.#historyStore.purgeOlderThan(ms);
1847
+ this.#ui.addSystemMessage(
1848
+ `Retention applied: ${removed} old message(s) removed from local history.`,
1849
+ );
1850
+ break;
1851
+ }
1852
+
1853
+ case '/panic': {
1854
+ const panicArg = parts[1]?.toLowerCase();
1855
+ if (panicArg === 'sim' || panicArg === 'yes' || panicArg === 'wipe') {
1856
+ this.#doPanic();
1857
+ } else {
1858
+ this.#ui.addErrorMessage(
1859
+ 'PANIC wipes EVERYTHING from disk (session, history, trust, keys) and exits. ' +
1860
+ 'Confirm with /panic yes',
1861
+ );
1862
+ }
1863
+ break;
1864
+ }
1865
+
1866
+ case '/quit':
1867
+ this.destroy(); // tears down the TUI, freeing the terminal for the animation
1868
+ farewellBanner().finally(() => process.exit(0));
1869
+ break;
1870
+
1871
+ default: {
1872
+ // Try plugin commands before reporting unknown
1873
+ if (this.#pluginManager) {
1874
+ const result = this.#pluginManager.handleCommand(cmd, parts.slice(1));
1875
+ if (result) {
1876
+ this.#ui.addInfoMessage(result);
1877
+ break;
1878
+ }
1879
+ }
1880
+ const suggestion = suggestCommand(cmd, COMMANDS);
1881
+ const hint = suggestion ? ` Did you mean ${suggestion}?` : ' Use /help';
1882
+ this.#ui.addErrorMessage(`Unknown command: ${cmd}.${hint}`);
1883
+ }
1884
+ }
1885
+ }
1886
+
1887
+ // ── Key rotation ─────────────────────────────────────────────
1888
+ #startKeyRotation() {
1889
+ this.#keyRotationTimer = setInterval(() => {
1890
+ this.#rotateKeys();
1891
+ }, KEY_ROTATION_INTERVAL_MS);
1892
+ }
1893
+
1894
+ #rotateKeys() {
1895
+ this.#keyManager.rotate();
1896
+
1897
+ // Announce new key to peers via encrypted channel (authenticated)
1898
+ const payload = JSON.stringify({
1899
+ action: 'key_rotation',
1900
+ newPublicKey: this.#keyManager.publicKeyB64,
1901
+ sentAt: Date.now(),
1902
+ });
1903
+ this.#broadcastPayload(payload);
1904
+
1905
+ // Update server with new public key
1906
+ this.#connection.send(createKeyUpdate(this.#keyManager.publicKeyB64));
1907
+
1908
+ this.#auditLog.log(AuditEvent.KEY_ROTATION_OWN, { fingerprint: this.#keyManager.fingerprint });
1909
+ this.#ui.addSystemMessage(`Keys rotated (new fingerprint: ${this.#keyManager.fingerprint})`);
1910
+ }
1911
+
1912
+ // ── Ephemeral helpers ────────────────────────────────────────
1913
+ #parseEphemeralTime(str) {
1914
+ const match = str.match(/^(\d+)(s|m|h)$/);
1915
+ if (!match) {
1916
+ return null;
1917
+ }
1918
+ const val = parseInt(match[1]);
1919
+ if (val <= 0) {
1920
+ return null;
1921
+ }
1922
+ const multiplier = { s: 1000, m: 60_000, h: 3_600_000 };
1923
+ return val * multiplier[match[2]];
1924
+ }
1925
+
1926
+ // Like #parseEphemeralTime but also supports days (for /retention).
1927
+ #parseRetentionTime(str) {
1928
+ if (!str) {
1929
+ return null;
1930
+ }
1931
+ const match = str.match(/^(\d+)(m|h|d)$/);
1932
+ if (!match) {
1933
+ return null;
1934
+ }
1935
+ const val = parseInt(match[1]);
1936
+ if (val <= 0) {
1937
+ return null;
1938
+ }
1939
+ const multiplier = { m: 60_000, h: 3_600_000, d: 86_400_000 };
1940
+ return val * multiplier[match[2]];
1941
+ }
1942
+
1943
+ #scheduleEphemeralRemoval(lineIndex, durationMs, nickname) {
1944
+ const timer = setTimeout(() => {
1945
+ this.#ui.burnLine(lineIndex, () => {
1946
+ this.#ui.addSystemMessage(`Ephemeral message from ${nickname} burned`);
1947
+ });
1948
+ }, durationMs);
1949
+ this.#ephemeralTimers.push(timer);
1950
+ }
1951
+
1952
+ // ── Handle server PEER_KEY_UPDATED ─────────────────────────
1953
+ #onPeerKeyUpdated(msg) {
1954
+ const peer = this.#peers.get(msg.sessionId);
1955
+ if (!peer) {
1956
+ return;
1957
+ }
1958
+
1959
+ // Server broadcast is NOT authenticated (could be MITM) — do NOT auto-update trust store
1960
+ this.#handshake.updatePeerKey(msg.sessionId, msg.publicKey);
1961
+ peer.publicKey = msg.publicKey;
1962
+ this.#ui.addSystemMessage(`${peer.nickname} updated key (via server — unauthenticated)`);
1963
+ }
1964
+
1965
+ // ── Handle ROOM_CHANGED (after /join) ──────────────────────
1966
+ #onRoomChanged(msg) {
1967
+ this.#currentRoom = msg.room;
1968
+ this.#ui.setRoom(this.#currentRoom);
1969
+ this.#currentRoomOwner = msg.roomOwner || null;
1970
+
1971
+ // Clear old peers and pins
1972
+ this.#peers.clear();
1973
+ this.#pinnedMessages = [];
1974
+
1975
+ // Populate with new room peers
1976
+ for (const peer of msg.peers) {
1977
+ this.#peers.set(peer.sessionId, {
1978
+ nickname: peer.nickname,
1979
+ publicKey: peer.publicKey,
1980
+ });
1981
+
1982
+ // Register ratchet if new peer
1983
+ if (!this.#handshake.getRatchet(peer.sessionId)) {
1984
+ this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
1985
+ }
1986
+
1987
+ this.#checkTrust(peer.nickname, peer.publicKey);
1988
+ }
1989
+
1990
+ const peerNames = [...this.#peers.values()].map((p) => p.nickname);
1991
+ this.#ui.setOnlineCount(this.#peers.size + 1);
1992
+ this.#ui.setPeerNames(peerNames);
1993
+ this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room });
1994
+ this.#ui.addSystemMessage(`You joined room #${msg.room}`);
1995
+
1996
+ if (peerNames.length > 0) {
1997
+ this.#ui.addSystemMessage(`Online: ${peerNames.join(', ')}`);
1998
+ }
1999
+
2000
+ // The new room doesn't know my presence
2001
+ if (this.#away || this.#statusText) {
2002
+ this.#broadcastPresence();
2003
+ }
2004
+ }
2005
+
2006
+ // ── Handle ROOM_LIST ───────────────────────────────────────
2007
+ #onRoomList(msg) {
2008
+ this.#ui.addInfoMessage('Available rooms:');
2009
+ for (const room of msg.rooms) {
2010
+ const current = room.name === this.#currentRoom ? ' (current)' : '';
2011
+ this.#ui.addInfoMessage(` #${room.name} — ${room.memberCount} member(s)${current}`);
2012
+ }
2013
+ }
2014
+
2015
+ // ── Handle PEER_KICKED ────────────────────────────────────
2016
+ #onPeerKicked(msg) {
2017
+ if (msg.nickname.toLowerCase() === this.#nickname.toLowerCase()) {
2018
+ const reason = msg.reason ? ` (reason: ${msg.reason})` : '';
2019
+ this.#ui.addErrorMessage(`You were kicked from the room${reason}`);
2020
+ this.#auditLog.log(AuditEvent.ADMIN_KICK, { nickname: msg.nickname, reason: msg.reason });
2021
+ } else {
2022
+ const reason = msg.reason ? ` (${msg.reason})` : '';
2023
+ this.#ui.addSystemMessage(`${msg.nickname} was kicked from the room${reason}`);
2024
+ this.#auditLog.log(AuditEvent.ADMIN_KICK, { nickname: msg.nickname, reason: msg.reason });
2025
+ }
2026
+ }
2027
+
2028
+ // ── Handle PEER_MUTED ─────────────────────────────────────
2029
+ #onPeerMuted(msg) {
2030
+ const duration = this.#formatDuration(msg.durationMs);
2031
+ if (msg.nickname.toLowerCase() === this.#nickname.toLowerCase()) {
2032
+ this.#ui.addErrorMessage(`You were muted for ${duration}`);
2033
+ this.#auditLog.log(AuditEvent.ADMIN_MUTE, {
2034
+ nickname: msg.nickname,
2035
+ durationMs: msg.durationMs,
2036
+ });
2037
+ } else {
2038
+ this.#ui.addSystemMessage(`${msg.nickname} was muted for ${duration}`);
2039
+ this.#auditLog.log(AuditEvent.ADMIN_MUTE, {
2040
+ nickname: msg.nickname,
2041
+ durationMs: msg.durationMs,
2042
+ });
2043
+ }
2044
+ }
2045
+
2046
+ // ── Presence ─────────────────────────────────────────────────
2047
+ #presencePayload() {
2048
+ return JSON.stringify({
2049
+ action: 'presence',
2050
+ away: this.#away,
2051
+ reason: this.#awayReason,
2052
+ status: this.#statusText,
2053
+ sentAt: Date.now(),
2054
+ });
2055
+ }
2056
+
2057
+ #broadcastPresence() {
2058
+ this.#broadcastPayload(this.#presencePayload());
2059
+ }
2060
+
2061
+ // ── Read receipts ────────────────────────────────────────────
2062
+ #onReadReceipt(nickname, messageId) {
2063
+ const tracked = this.#sentMessageLines.get(messageId);
2064
+ if (!tracked) {
2065
+ return;
2066
+ }
2067
+
2068
+ let readers = this.#messageReaders.get(messageId);
2069
+ if (!readers) {
2070
+ readers = new Set();
2071
+ this.#messageReaders.set(messageId, readers);
2072
+ }
2073
+ if (readers.has(nickname)) {
2074
+ return;
2075
+ }
2076
+ readers.add(nickname);
2077
+
2078
+ const marker = readers.size > 1 ? `✓✓ ${readers.size}` : '✓✓';
2079
+ this.#ui.appendBadge(tracked.lineIndex, tracked.baseLine, `{green-fg}${marker}{/green-fg}`);
2080
+ }
2081
+
2082
+ #trackSentMessage(messageId, lineIndex) {
2083
+ const baseLine = this.#ui.getLine(lineIndex);
2084
+ if (baseLine === null || baseLine === undefined) {
2085
+ return;
2086
+ }
2087
+ this.#sentMessageLines.set(messageId, { lineIndex, baseLine });
2088
+
2089
+ // Bound memory: keep only the most recent 200 tracked messages
2090
+ if (this.#sentMessageLines.size > 200) {
2091
+ const oldest = this.#sentMessageLines.keys().next().value;
2092
+ this.#sentMessageLines.delete(oldest);
2093
+ this.#messageReaders.delete(oldest);
2094
+ }
2095
+ }
2096
+
2097
+ // ── Send encrypted payload to a single peer ────────────────────
2098
+ #sendPayloadToPeer(peerId, payload) {
2099
+ const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
2100
+ if (!peerPublicKey) {
2101
+ return;
2102
+ }
2103
+
2104
+ const ratchet = this.#handshake.getRatchet(peerId);
2105
+ if (ratchet && ratchet.isInitialized) {
2106
+ try {
2107
+ const result = ratchet.encrypt(payload);
2108
+ this.#connection.send(createRatchetedMessage(this.#sessionId, peerId, result));
2109
+ return;
2110
+ } catch {
2111
+ // Fall through to static path
2112
+ }
2113
+ }
2114
+
2115
+ const nonce = this.#nonceManager.generate();
2116
+ const ciphertext = MessageCrypto.encrypt(
2117
+ payload,
2118
+ nonce,
2119
+ peerPublicKey,
2120
+ this.#handshake.secretKey,
2121
+ );
2122
+ this.#connection.send(
2123
+ createEncryptedMessage(
2124
+ this.#sessionId,
2125
+ peerId,
2126
+ ciphertext.toString('base64'),
2127
+ nonce.toString('base64'),
2128
+ ),
2129
+ );
2130
+ }
2131
+
2132
+ // ── Send encrypted command to all peers ────────────────────────
2133
+ #sendCommandToAll(action) {
2134
+ const payload = JSON.stringify({ action, sentAt: Date.now() });
2135
+ this.#broadcastPayload(payload);
2136
+ }
2137
+
2138
+ // ── Cover traffic ──────────────────────────────────────────────
2139
+ #setCoverMode(mode) {
2140
+ this.#clearCoverTimer();
2141
+ this.#flushPace(); // never strand queued real messages when leaving a mode
2142
+ this.#coverMode = mode;
2143
+ if (mode === 'jitter') {
2144
+ this.#scheduleJitterDecoy();
2145
+ } else if (mode === 'constant') {
2146
+ this.#coverTimer = setInterval(() => this.coverTick(), COVER_CONSTANT_MS);
2147
+ if (this.#coverTimer.unref) {
2148
+ this.#coverTimer.unref();
2149
+ }
2150
+ }
2151
+ }
2152
+
2153
+ #scheduleJitterDecoy() {
2154
+ const tick = () => {
2155
+ if (this.#coverMode === 'jitter') {
2156
+ this.sendCoverNow();
2157
+ this.#coverTimer = setTimeout(tick, nextCoverDelay());
2158
+ if (this.#coverTimer.unref) {
2159
+ this.#coverTimer.unref();
2160
+ }
2161
+ }
2162
+ };
2163
+ this.#coverTimer = setTimeout(tick, nextCoverDelay());
2164
+ if (this.#coverTimer.unref) {
2165
+ this.#coverTimer.unref();
2166
+ }
2167
+ }
2168
+
2169
+ #clearCoverTimer() {
2170
+ if (this.#coverTimer) {
2171
+ clearTimeout(this.#coverTimer);
2172
+ clearInterval(this.#coverTimer);
2173
+ this.#coverTimer = null;
2174
+ }
2175
+ }
2176
+
2177
+ #stopCover() {
2178
+ this.#clearCoverTimer();
2179
+ this.#flushPace();
2180
+ this.#coverMode = 'off';
2181
+ }
2182
+
2183
+ // One constant-rate slot: send a queued real message if there is one, else a
2184
+ // decoy — so the wire cadence is identical whether or not you're chatting.
2185
+ coverTick() {
2186
+ const item = this.#paceQueue.shift();
2187
+ if (item) {
2188
+ this.#broadcastPayload(item.payload, item.deniable);
2189
+ } else {
2190
+ this.sendCoverNow();
2191
+ }
2192
+ }
2193
+
2194
+ // Route an outgoing payload: paced through slots in constant mode, immediate
2195
+ // otherwise.
2196
+ #paceOrSend(payload, deniable = false) {
2197
+ if (this.#coverMode === 'constant') {
2198
+ this.#paceQueue.push({ payload, deniable });
2199
+ } else {
2200
+ this.#broadcastPayload(payload, deniable);
2201
+ }
2202
+ }
2203
+
2204
+ #flushPace() {
2205
+ while (this.#paceQueue.length > 0) {
2206
+ const { payload, deniable } = this.#paceQueue.shift();
2207
+ this.#broadcastPayload(payload, deniable);
2208
+ }
2209
+ }
2210
+
2211
+ // Sends a single decoy immediately (used by tests and by the timers).
2212
+ sendCoverNow() {
2213
+ if (this.#connection.connected && this.#peers.size > 0) {
2214
+ this.#broadcastPayload(coverPayload(Date.now()));
2215
+ }
2216
+ }
2217
+
2218
+ // ── Broadcast encrypted payload to all peers ───────────────────
2219
+ #broadcastPayload(payload, deniable = false) {
2220
+ for (const [peerId] of this.#peers) {
2221
+ const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
2222
+ if (!peerPublicKey) {
2223
+ continue;
2224
+ }
2225
+
2226
+ // Deniable path: crypto_secretbox (symmetric)
2227
+ if (deniable) {
2228
+ const nonce = this.#nonceManager.generate();
2229
+ const sharedKey = deriveSharedKey(this.#handshake.secretKey, peerPublicKey);
2230
+ const ciphertext = encryptDeniable(payload, nonce, sharedKey);
2231
+ const msg = createEncryptedMessage(
2232
+ this.#sessionId,
2233
+ peerId,
2234
+ ciphertext.toString('base64'),
2235
+ nonce.toString('base64'),
2236
+ );
2237
+ msg.payload.deniable = true;
2238
+ this.#connection.send(msg);
2239
+ continue;
2240
+ }
2241
+
2242
+ // Try ratchet path (PFS) first
2243
+ const ratchet = this.#handshake.getRatchet(peerId);
2244
+ if (ratchet && ratchet.isInitialized) {
2245
+ try {
2246
+ const result = ratchet.encrypt(payload);
2247
+ this.#connection.send(createRatchetedMessage(this.#sessionId, peerId, result));
2248
+ continue;
2249
+ } catch {
2250
+ // Ratchet failed — fall through to static path
2251
+ }
2252
+ }
2253
+
2254
+ // Static path (fallback: offline queue, initial msgs, ratchet failure)
2255
+ const nonce = this.#nonceManager.generate();
2256
+ const ciphertext = MessageCrypto.encrypt(
2257
+ payload,
2258
+ nonce,
2259
+ peerPublicKey,
2260
+ this.#handshake.secretKey,
2261
+ );
2262
+
2263
+ this.#connection.send(
2264
+ createEncryptedMessage(
2265
+ this.#sessionId,
2266
+ peerId,
2267
+ ciphertext.toString('base64'),
2268
+ nonce.toString('base64'),
2269
+ ),
2270
+ );
2271
+ }
2272
+ }
2273
+
2274
+ // ── Send file to all peers ─────────────────────────────────
2275
+ #sendFile(filePath) {
2276
+ const broadcastFn = (payloadObj) => {
2277
+ const payload = JSON.stringify({ ...payloadObj, sentAt: Date.now() });
2278
+ this.#broadcastPayload(payload);
2279
+ };
2280
+
2281
+ this.#fileTransfer.initSend(filePath, broadcastFn, {
2282
+ onProgress: (percent, text) => {
2283
+ this.#ui.updateProgress(text, percent);
2284
+ },
2285
+ onError: (text) => {
2286
+ this.#ui.finishProgress();
2287
+ this.#ui.addErrorMessage(text);
2288
+ },
2289
+ onComplete: (text) => {
2290
+ this.#ui.finishProgress();
2291
+ this.#ui.addSystemMessage(text);
2292
+ },
2293
+ });
2294
+ }
2295
+
2296
+ // ── Send encrypted message to all peers ───────────────────────
2297
+ #sendMessageToAll(text, replyTo = null) {
2298
+ if (!this.#connection.connected) {
2299
+ this.#ui.addErrorMessage('No connection to the server — message not sent');
2300
+ return;
2301
+ }
2302
+ if (this.#peers.size === 0) {
2303
+ this.#ui.addSystemMessage('No peers online to receive messages');
2304
+ return;
2305
+ }
2306
+
2307
+ text = applyShortcodes(text);
2308
+ const messageId = Math.random().toString(36).slice(2, 10);
2309
+ const msgObj = {
2310
+ text,
2311
+ sentAt: Date.now(),
2312
+ messageId,
2313
+ };
2314
+ if (replyTo) {
2315
+ msgObj.replyTo = replyTo;
2316
+ }
2317
+
2318
+ if (this.#ephemeralMode) {
2319
+ msgObj.ephemeral = this.#ephemeralDurationMs;
2320
+ }
2321
+ if (this.#deniableMode) {
2322
+ msgObj.deniable = true;
2323
+ }
2324
+
2325
+ this.#lastSentMessageId = messageId;
2326
+ this.#paceOrSend(JSON.stringify(msgObj), this.#deniableMode);
2327
+
2328
+ if (this.#historyStore?.isOpen && !this.#ephemeralMode && !this.#deniableMode) {
2329
+ this.#historyStore.append({
2330
+ room: this.#currentRoom,
2331
+ nickname: this.#nickname,
2332
+ text,
2333
+ isDM: false,
2334
+ });
2335
+ }
2336
+
2337
+ // Show own message locally
2338
+ if (replyTo) {
2339
+ this.#ui.addQuoteLine(replyTo.nickname, replyTo.excerpt, true);
2340
+ }
2341
+ const ephLabel = this.#ephemeralMode ? this.#formatDuration(this.#ephemeralDurationMs) : null;
2342
+ const { lineIndex } = this.#ui.addMessage(
2343
+ this.#nickname,
2344
+ text,
2345
+ false,
2346
+ ephLabel,
2347
+ this.#deniableMode,
2348
+ );
2349
+
2350
+ if (this.#ephemeralMode) {
2351
+ this.#scheduleEphemeralRemoval(lineIndex, this.#ephemeralDurationMs, this.#nickname);
2352
+ } else if (!this.#deniableMode) {
2353
+ this.#trackSentMessage(messageId, lineIndex);
2354
+ }
2355
+ }
2356
+
2357
+ #formatHistoryEntry(e) {
2358
+ const when = new Date(e.ts).toLocaleString('en-US', {
2359
+ day: '2-digit',
2360
+ month: '2-digit',
2361
+ hour: '2-digit',
2362
+ minute: '2-digit',
2363
+ });
2364
+ const dm = e.isDM ? ' (DM)' : '';
2365
+ return `[${when}] [#${e.room}]${dm} ${e.nickname}: ${e.text}`;
2366
+ }
2367
+
2368
+ #formatDuration(ms) {
2369
+ if (ms >= 3_600_000) {
2370
+ return `${Math.round(ms / 3_600_000)}h`;
2371
+ }
2372
+ if (ms >= 60_000) {
2373
+ return `${Math.round(ms / 60_000)}m`;
2374
+ }
2375
+ return `${Math.round(ms / 1000)}s`;
2376
+ }
2377
+
2378
+ // ── Send encrypted DM to one peer ────────────────────────────
2379
+ #sendMessageToPeer(peerId, peerNickname, text) {
2380
+ const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
2381
+ if (!peerPublicKey) {
2382
+ this.#ui.addErrorMessage(`Public key not found for ${peerNickname}`);
2383
+ return;
2384
+ }
2385
+
2386
+ text = applyShortcodes(text);
2387
+
2388
+ if (this.#historyStore?.isOpen) {
2389
+ this.#historyStore.append({
2390
+ room: this.#currentRoom,
2391
+ nickname: `${this.#nickname} → ${peerNickname}`,
2392
+ text,
2393
+ isDM: true,
2394
+ });
2395
+ }
2396
+
2397
+ const messageId = Math.random().toString(36).slice(2, 10);
2398
+ const payload = JSON.stringify({
2399
+ text,
2400
+ sentAt: Date.now(),
2401
+ messageId,
2402
+ isDM: true,
2403
+ });
2404
+
2405
+ this.#sendPayloadToPeer(peerId, payload);
2406
+ const { lineIndex } = this.#ui.addMessage(
2407
+ `${this.#nickname} \u2192 ${peerNickname}`,
2408
+ text,
2409
+ true,
2410
+ );
2411
+ this.#trackSentMessage(messageId, lineIndex);
2412
+ }
2413
+
2414
+ // ── Panic / duress wipe ──────────────────────────────────────
2415
+ #doPanic() {
2416
+ panicWipe({
2417
+ historyStore: this.#historyStore,
2418
+ trustStore: this.#trustStore,
2419
+ auditLog: this.#auditLog,
2420
+ });
2421
+ this.#passphrase = null; // never re-save state on the way out
2422
+ try {
2423
+ this.#handshake.destroy();
2424
+ } catch {
2425
+ /* best effort */
2426
+ }
2427
+ try {
2428
+ this.#keyManager.destroy();
2429
+ } catch {
2430
+ /* best effort */
2431
+ }
2432
+ this.#ui.clearChat();
2433
+ this.#ui.addSystemMessage('PANIC: session, history, trust, and keys wiped. Exiting...');
2434
+ setTimeout(() => process.exit(0), 60);
2435
+ }
2436
+
2437
+ // ── State serialization ──────────────────────────────────────
2438
+
2439
+ get passphrase() {
2440
+ return this.#passphrase;
2441
+ }
2442
+
2443
+ serializeState() {
2444
+ return {
2445
+ passphrase: this.#passphrase,
2446
+ keyManager: this.#keyManager.serialize(),
2447
+ handshake: this.#handshake.serializeState(),
2448
+ peers: Object.fromEntries(this.#peers),
2449
+ nickname: this.#nickname,
2450
+ };
2451
+ }
2452
+
2453
+ destroy() {
2454
+ if (this.#keyRotationTimer) {
2455
+ clearInterval(this.#keyRotationTimer);
2456
+ }
2457
+ this.#stopCover();
2458
+ if (this.#autoAwayTimer) {
2459
+ clearTimeout(this.#autoAwayTimer);
2460
+ }
2461
+ for (const timer of this.#peerTypingTimers.values()) {
2462
+ clearTimeout(timer);
2463
+ }
2464
+ for (const timer of this.#ephemeralTimers) {
2465
+ clearTimeout(timer);
2466
+ }
2467
+ if (this.#historyStore) {
2468
+ this.#historyStore.destroy();
2469
+ }
2470
+ this.#fileTransfer.destroy();
2471
+ this.#handshake.destroy();
2472
+ this.#keyManager.destroy();
2473
+ this.#connection.close();
2474
+ this.#ui.destroy();
2475
+ }
2476
+ }