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.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/README.pt-BR.md +253 -0
- package/bin/ciphermesh.js +34 -0
- package/docs/ARCHITECTURE.md +1188 -0
- package/docs/SETUP.md +305 -0
- package/docs/demo.svg +46 -0
- package/package.json +87 -0
- package/src/client/ChatController.js +2476 -0
- package/src/client/Connection.js +129 -0
- package/src/client/FileTransfer.js +488 -0
- package/src/client/ImagePreview.js +88 -0
- package/src/client/UI.js +1830 -0
- package/src/client/index.js +231 -0
- package/src/crypto/CertPinStore.js +79 -0
- package/src/crypto/DeniableEncrypt.js +53 -0
- package/src/crypto/DoubleRatchet.js +574 -0
- package/src/crypto/Handshake.js +219 -0
- package/src/crypto/HistoryStore.js +241 -0
- package/src/crypto/IdentityBackup.js +70 -0
- package/src/crypto/KeyManager.js +134 -0
- package/src/crypto/MessageCrypto.js +181 -0
- package/src/crypto/NonceManager.js +72 -0
- package/src/crypto/SealedSender.js +58 -0
- package/src/crypto/SenderKey.js +204 -0
- package/src/crypto/StateManager.js +138 -0
- package/src/crypto/TrustStore.js +216 -0
- package/src/p2p/Discovery.js +80 -0
- package/src/p2p/P2PChatController.js +1856 -0
- package/src/p2p/PeerConnectionManager.js +252 -0
- package/src/p2p/PeerServer.js +68 -0
- package/src/p2p/index.js +219 -0
- package/src/protocol/messages.js +138 -0
- package/src/protocol/validators.js +175 -0
- package/src/server/CertManager.js +173 -0
- package/src/server/MessageRouter.js +80 -0
- package/src/server/OfflineQueue.js +124 -0
- package/src/server/SessionManager.js +296 -0
- package/src/server/WebSocketServer.js +632 -0
- package/src/server/index.js +89 -0
- package/src/shared/AuditLog.js +91 -0
- package/src/shared/PluginManager.js +83 -0
- package/src/shared/banner.js +271 -0
- package/src/shared/commandSuggest.js +59 -0
- package/src/shared/config.js +90 -0
- package/src/shared/constants.js +126 -0
- package/src/shared/coverTraffic.js +34 -0
- package/src/shared/dnd.js +60 -0
- package/src/shared/emoji.js +17 -0
- package/src/shared/fuzzy.js +40 -0
- package/src/shared/invite.js +61 -0
- package/src/shared/keyArt.js +66 -0
- package/src/shared/logger.js +38 -0
- package/src/shared/panic.js +38 -0
- package/src/shared/prompt.js +31 -0
- package/src/shared/terminalGraphics.js +72 -0
- package/src/shared/themes.js +36 -0
- 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
|
+
}
|