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,1856 @@
|
|
|
1
|
+
import sodium from 'sodium-native';
|
|
2
|
+
import notifier from 'node-notifier';
|
|
3
|
+
import qrcode from 'qrcode-terminal';
|
|
4
|
+
import { writeFileSync } from 'node:fs';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { exportBackup } from '../crypto/IdentityBackup.js';
|
|
8
|
+
import { keyArt } from '../shared/keyArt.js';
|
|
9
|
+
import {
|
|
10
|
+
KEY_ROTATION_INTERVAL_MS,
|
|
11
|
+
EMOJI_MAP,
|
|
12
|
+
OFFLINE_QUEUE_MAX_PER_PEER,
|
|
13
|
+
OFFLINE_QUEUE_MAX_AGE_MS,
|
|
14
|
+
COVER_CONSTANT_MS,
|
|
15
|
+
} from '../shared/constants.js';
|
|
16
|
+
import { KeyManager } from '../crypto/KeyManager.js';
|
|
17
|
+
import { Handshake } from '../crypto/Handshake.js';
|
|
18
|
+
import { NonceManager } from '../crypto/NonceManager.js';
|
|
19
|
+
import * as MessageCrypto from '../crypto/MessageCrypto.js';
|
|
20
|
+
import { TrustStore, TrustResult } from '../crypto/TrustStore.js';
|
|
21
|
+
import { FileTransfer } from '../client/FileTransfer.js';
|
|
22
|
+
import { isImageFile, renderImagePreview, loadImageBuffers } from '../client/ImagePreview.js';
|
|
23
|
+
import { detectImageProtocol, encodeInlineImage } from '../shared/terminalGraphics.js';
|
|
24
|
+
import { AuditLog, AuditEvent } from '../shared/AuditLog.js';
|
|
25
|
+
import { deriveSharedKey, encryptDeniable, decryptDeniable } from '../crypto/DeniableEncrypt.js';
|
|
26
|
+
import { GroupSession } from '../crypto/SenderKey.js';
|
|
27
|
+
import { suggestCommand } from '../shared/commandSuggest.js';
|
|
28
|
+
import { nextCoverDelay, coverPayload, isCover } from '../shared/coverTraffic.js';
|
|
29
|
+
import { recordVoiceNote, playVoiceNote, isAudioFile } from '../shared/voiceNote.js';
|
|
30
|
+
import { setTheme, getThemeName, themeNames } from '../shared/themes.js';
|
|
31
|
+
import { panicWipe } from '../shared/panic.js';
|
|
32
|
+
import { farewellBanner } from '../shared/banner.js';
|
|
33
|
+
import { parseDndWindow, shouldNotify, nowMinutes, mentionsMe } from '../shared/dnd.js';
|
|
34
|
+
import { COMMANDS } from '../client/UI.js';
|
|
35
|
+
|
|
36
|
+
const TYPING_SEND_INTERVAL = 2000;
|
|
37
|
+
const TYPING_EXPIRE_TIMEOUT = 3000;
|
|
38
|
+
|
|
39
|
+
export class P2PChatController {
|
|
40
|
+
#nickname;
|
|
41
|
+
#connManager;
|
|
42
|
+
#discovery;
|
|
43
|
+
#peerServer;
|
|
44
|
+
#ui;
|
|
45
|
+
#keyManager;
|
|
46
|
+
#handshake;
|
|
47
|
+
#nonceManager;
|
|
48
|
+
#peers; // Map<nickname, { publicKey }>
|
|
49
|
+
#lastTypingSent;
|
|
50
|
+
#peerTypingTimers;
|
|
51
|
+
#fileTransfer;
|
|
52
|
+
#pendingFileOffers = new Map(); // transferId -> { data, nickname }
|
|
53
|
+
#knownPeers = new Set(); // nicknames seen this session (for store-and-forward)
|
|
54
|
+
#sfQueue = new Map(); // nickname -> [{ payload, queuedAt }] for offline peers
|
|
55
|
+
#currentRoom = 'general';
|
|
56
|
+
#peerRooms = new Map(); // nickname -> room (last announced)
|
|
57
|
+
#groups = new Map(); // room -> GroupSession (sender keys)
|
|
58
|
+
#groupBuffer = new Map(); // sender -> group msgs awaiting the sender's key
|
|
59
|
+
#dndMode = 'off'; // 'off' | 'mentions' | 'on'
|
|
60
|
+
#dndWindow = null; // quiet-hours { start, end } in minutes, or null
|
|
61
|
+
#lastImagePath = null; // last received image (for /img full-res render)
|
|
62
|
+
#lastAudioPath = null; // last received voice note (for /play)
|
|
63
|
+
#keyRotationTimer;
|
|
64
|
+
#trustStore;
|
|
65
|
+
#passphrase;
|
|
66
|
+
#auditLog;
|
|
67
|
+
#ephemeralMode;
|
|
68
|
+
#ephemeralDurationMs;
|
|
69
|
+
#ephemeralTimers;
|
|
70
|
+
#lastReceivedMessageId;
|
|
71
|
+
#lastReceivedNickname;
|
|
72
|
+
#lastSentMessageId;
|
|
73
|
+
#messageAuthors;
|
|
74
|
+
#pinnedMessages;
|
|
75
|
+
#lastReceivedText;
|
|
76
|
+
#deniableMode;
|
|
77
|
+
#coverMode; // 'off' | 'jitter' | 'constant'
|
|
78
|
+
#coverTimer;
|
|
79
|
+
#paceQueue;
|
|
80
|
+
#pluginManager;
|
|
81
|
+
|
|
82
|
+
constructor(
|
|
83
|
+
nickname,
|
|
84
|
+
peerServer,
|
|
85
|
+
connManager,
|
|
86
|
+
discovery,
|
|
87
|
+
ui,
|
|
88
|
+
keyManager,
|
|
89
|
+
restoredState = null,
|
|
90
|
+
pluginManager = null,
|
|
91
|
+
) {
|
|
92
|
+
this.#nickname = nickname;
|
|
93
|
+
this.#connManager = connManager;
|
|
94
|
+
this.#discovery = discovery;
|
|
95
|
+
this.#peerServer = peerServer;
|
|
96
|
+
this.#ui = ui;
|
|
97
|
+
this.#passphrase = restoredState?.passphrase || null;
|
|
98
|
+
this.#keyManager = keyManager;
|
|
99
|
+
|
|
100
|
+
this.#handshake = new Handshake(this.#keyManager);
|
|
101
|
+
this.#handshake.setMySessionId(nickname); // Use nickname as session ID in P2P
|
|
102
|
+
|
|
103
|
+
if (restoredState?.handshake) {
|
|
104
|
+
this.#handshake.restoreState(restoredState.handshake);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.#nonceManager = new NonceManager();
|
|
108
|
+
this.#peers = new Map();
|
|
109
|
+
this.#lastTypingSent = 0;
|
|
110
|
+
this.#peerTypingTimers = new Map();
|
|
111
|
+
this.#fileTransfer = new FileTransfer();
|
|
112
|
+
this.#keyRotationTimer = null;
|
|
113
|
+
this.#trustStore = new TrustStore();
|
|
114
|
+
if (restoredState?.trust) {
|
|
115
|
+
this.#trustStore.importData(restoredState.trust);
|
|
116
|
+
}
|
|
117
|
+
this.#auditLog = new AuditLog();
|
|
118
|
+
this.#ephemeralMode = false;
|
|
119
|
+
this.#ephemeralDurationMs = 0;
|
|
120
|
+
this.#ephemeralTimers = [];
|
|
121
|
+
this.#lastReceivedMessageId = null;
|
|
122
|
+
this.#lastReceivedNickname = null;
|
|
123
|
+
this.#lastSentMessageId = null;
|
|
124
|
+
this.#messageAuthors = new Map();
|
|
125
|
+
this.#pinnedMessages = [];
|
|
126
|
+
this.#lastReceivedText = null;
|
|
127
|
+
this.#deniableMode = false;
|
|
128
|
+
this.#coverMode = 'off';
|
|
129
|
+
this.#coverTimer = null;
|
|
130
|
+
this.#paceQueue = [];
|
|
131
|
+
this.#pluginManager = pluginManager;
|
|
132
|
+
|
|
133
|
+
this.#setupHandlers();
|
|
134
|
+
this.#startKeyRotation();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get fingerprint() {
|
|
138
|
+
return this.#keyManager.fingerprint;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
get passphrase() {
|
|
142
|
+
return this.#passphrase;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Event handlers ─────────────────────────────────────────────
|
|
146
|
+
#setupHandlers() {
|
|
147
|
+
// PeerConnectionManager events
|
|
148
|
+
this.#connManager.on('peer-connected', ({ nickname, publicKey }) => {
|
|
149
|
+
this.#onPeerConnected(nickname, publicKey);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
this.#connManager.on('peer-disconnected', (nickname) => {
|
|
153
|
+
this.#onPeerDisconnected(nickname);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
this.#connManager.on('message', (nickname, msg) => {
|
|
157
|
+
this.#onPeerMessage(nickname, msg);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// PeerServer — delegate inbound connections to connection manager
|
|
161
|
+
this.#peerServer.on('connection', (ws) => {
|
|
162
|
+
this.#connManager.acceptConnection(ws);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Discovery events
|
|
166
|
+
this.#discovery.on('peer-discovered', (peer) => {
|
|
167
|
+
this.#connManager.connectTo(peer.nickname, peer.host, peer.port);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// UI events
|
|
171
|
+
this.#ui.on('input', (text) => {
|
|
172
|
+
this.#handleUserInput(text);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
this.#ui.on('activity', () => {
|
|
176
|
+
this.#handleTypingActivity();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
this.#ui.on('quit', () => {
|
|
180
|
+
this.destroy();
|
|
181
|
+
process.exit(0);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Peer connected (handshake complete) ────────────────────────
|
|
186
|
+
#onPeerConnected(nickname, publicKey) {
|
|
187
|
+
this.#peers.set(nickname, { publicKey });
|
|
188
|
+
this.#handshake.registerPeer(nickname, publicKey);
|
|
189
|
+
this.#checkTrust(nickname, publicKey);
|
|
190
|
+
|
|
191
|
+
this.#ui.setOnlineCount(this.#peers.size + 1);
|
|
192
|
+
this.#ui.setPeerNames([...this.#peers.keys()]);
|
|
193
|
+
this.#ui.handshakeConnect(nickname);
|
|
194
|
+
this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname });
|
|
195
|
+
|
|
196
|
+
this.#knownPeers.add(nickname);
|
|
197
|
+
// Tell the new peer which room we're in (and default them to general).
|
|
198
|
+
this.#peerRooms.set(nickname, this.#peerRooms.get(nickname) || 'general');
|
|
199
|
+
this.#broadcastPayload(
|
|
200
|
+
JSON.stringify({ action: 'room_announce', room: this.#currentRoom, sentAt: Date.now() }),
|
|
201
|
+
false,
|
|
202
|
+
nickname,
|
|
203
|
+
);
|
|
204
|
+
this.#flushSFQueue(nickname);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Store-and-forward (P2P) ──────────────────────────────────
|
|
208
|
+
#enqueueSF(nickname, payload) {
|
|
209
|
+
let queue = this.#sfQueue.get(nickname);
|
|
210
|
+
if (!queue) {
|
|
211
|
+
queue = [];
|
|
212
|
+
this.#sfQueue.set(nickname, queue);
|
|
213
|
+
}
|
|
214
|
+
queue.push({ payload, queuedAt: Date.now() });
|
|
215
|
+
if (queue.length > OFFLINE_QUEUE_MAX_PER_PEER) {
|
|
216
|
+
queue.shift(); // drop the oldest
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#flushSFQueue(nickname) {
|
|
221
|
+
const queue = this.#sfQueue.get(nickname);
|
|
222
|
+
if (!queue || queue.length === 0) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.#sfQueue.delete(nickname);
|
|
226
|
+
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
let delivered = 0;
|
|
229
|
+
for (const item of queue) {
|
|
230
|
+
if (now - item.queuedAt > OFFLINE_QUEUE_MAX_AGE_MS) {
|
|
231
|
+
continue; // expired
|
|
232
|
+
}
|
|
233
|
+
// Re-encrypt with the peer's current ratchet and send only to them.
|
|
234
|
+
this.#broadcastPayload(item.payload, false, nickname);
|
|
235
|
+
delivered++;
|
|
236
|
+
}
|
|
237
|
+
if (delivered > 0) {
|
|
238
|
+
this.#ui.addSystemMessage(`${delivered} pending message(s) delivered to ${nickname}.`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Peer disconnected ──────────────────────────────────────────
|
|
243
|
+
#onPeerDisconnected(nickname) {
|
|
244
|
+
this.#hidePeerTyping(nickname);
|
|
245
|
+
this.#nonceManager.removePeer(nickname);
|
|
246
|
+
const wasInMyRoom = (this.#peerRooms.get(nickname) || 'general') === this.#currentRoom;
|
|
247
|
+
this.#peers.delete(nickname);
|
|
248
|
+
this.#groupBuffer.delete(nickname);
|
|
249
|
+
for (const group of this.#groups.values()) {
|
|
250
|
+
group.removeMember(nickname);
|
|
251
|
+
}
|
|
252
|
+
// Forward secrecy: someone left my room → rotate my sender key so they
|
|
253
|
+
// can't read my future messages, and redistribute to who's left.
|
|
254
|
+
if (wasInMyRoom && this.#groups.has(this.#currentRoom)) {
|
|
255
|
+
this.#getGroup(this.#currentRoom).rotate();
|
|
256
|
+
this.#distributeSenderKey(this.#currentRoom);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this.#ui.setOnlineCount(this.#peers.size + 1);
|
|
260
|
+
this.#ui.setPeerNames([...this.#peers.keys()]);
|
|
261
|
+
this.#ui.handshakeDisconnect(nickname);
|
|
262
|
+
this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Handle message from peer ───────────────────────────────────
|
|
266
|
+
#onPeerMessage(fromNickname, msg) {
|
|
267
|
+
if (msg.type === 'p2p_group') {
|
|
268
|
+
this.#onGroupMessage(fromNickname, msg);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (msg.type !== 'p2p_message') {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const peer = this.#peers.get(fromNickname);
|
|
276
|
+
if (!peer) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const senderPublicKey = this.#handshake.getPeerPublicKey(fromNickname);
|
|
281
|
+
if (!senderPublicKey) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const ciphertext = Buffer.from(msg.payload.ciphertext, 'base64');
|
|
286
|
+
const nonce = Buffer.from(msg.payload.nonce, 'base64');
|
|
287
|
+
|
|
288
|
+
let plaintext = null;
|
|
289
|
+
const isDeniable = !!msg.payload.deniable;
|
|
290
|
+
|
|
291
|
+
// Deniable message path (symmetric crypto_secretbox)
|
|
292
|
+
if (isDeniable) {
|
|
293
|
+
// Anti-replay: deniable sends already use a structured NonceManager nonce.
|
|
294
|
+
if (!this.#nonceManager.validate(fromNickname, nonce)) {
|
|
295
|
+
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: fromNickname, deniable: true });
|
|
296
|
+
this.#ui.addErrorMessage(`Invalid nonce from ${fromNickname} (possible replay)`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const sharedKey = deriveSharedKey(this.#handshake.secretKey, senderPublicKey);
|
|
300
|
+
plaintext = decryptDeniable(ciphertext, nonce, sharedKey);
|
|
301
|
+
if (!plaintext) {
|
|
302
|
+
this.#auditLog.log(AuditEvent.DECRYPT_FAILURE, { nickname: fromNickname, deniable: true });
|
|
303
|
+
this.#ui.addErrorMessage(`Failed to decrypt deniable message from ${fromNickname}`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Ratcheted path (has ephemeralPublicKey)
|
|
309
|
+
if (!isDeniable && msg.payload.ephemeralPublicKey) {
|
|
310
|
+
const ratchet = this.#handshake.getRatchet(fromNickname);
|
|
311
|
+
if (ratchet) {
|
|
312
|
+
const ephPub = Buffer.from(msg.payload.ephemeralPublicKey, 'base64');
|
|
313
|
+
plaintext = ratchet.decrypt(
|
|
314
|
+
ciphertext,
|
|
315
|
+
nonce,
|
|
316
|
+
ephPub,
|
|
317
|
+
msg.payload.counter,
|
|
318
|
+
msg.payload.previousCounter,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Fallback to static decrypt if ratchet failed
|
|
323
|
+
if (!plaintext) {
|
|
324
|
+
if (!this.#nonceManager.validate(fromNickname, nonce)) {
|
|
325
|
+
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: fromNickname });
|
|
326
|
+
this.#ui.addErrorMessage(`Failed to decrypt message from ${fromNickname}`);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
plaintext = MessageCrypto.decryptWithFallback(
|
|
330
|
+
ciphertext,
|
|
331
|
+
nonce,
|
|
332
|
+
senderPublicKey,
|
|
333
|
+
this.#handshake.secretKey,
|
|
334
|
+
this.#handshake.getPreviousPeerPublicKey(fromNickname),
|
|
335
|
+
this.#handshake.previousSecretKey,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
} else if (!isDeniable) {
|
|
339
|
+
// Static path (no ephemeralPublicKey)
|
|
340
|
+
if (!this.#nonceManager.validate(fromNickname, nonce)) {
|
|
341
|
+
this.#auditLog.log(AuditEvent.NONCE_REPLAY, { nickname: fromNickname });
|
|
342
|
+
this.#ui.addErrorMessage(`Invalid nonce from ${fromNickname}`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
plaintext = MessageCrypto.decryptWithFallback(
|
|
346
|
+
ciphertext,
|
|
347
|
+
nonce,
|
|
348
|
+
senderPublicKey,
|
|
349
|
+
this.#handshake.secretKey,
|
|
350
|
+
this.#handshake.getPreviousPeerPublicKey(fromNickname),
|
|
351
|
+
this.#handshake.previousSecretKey,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!plaintext) {
|
|
356
|
+
this.#auditLog.log(AuditEvent.DECRYPT_FAILURE, { nickname: fromNickname });
|
|
357
|
+
this.#ui.addErrorMessage(`Failed to decrypt message from ${fromNickname} (invalid MAC)`);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
const data = JSON.parse(plaintext.toString('utf-8'));
|
|
363
|
+
this.#handleDecryptedAction(fromNickname, data, isDeniable);
|
|
364
|
+
} catch {
|
|
365
|
+
this.#ui.addErrorMessage(`Invalid payload from ${fromNickname}`);
|
|
366
|
+
} finally {
|
|
367
|
+
if (plaintext && Buffer.isBuffer(plaintext)) {
|
|
368
|
+
sodium.sodium_memzero(plaintext);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
#handleDecryptedAction(fromNickname, data, isDeniable = false) {
|
|
374
|
+
const peer = this.#peers.get(fromNickname);
|
|
375
|
+
|
|
376
|
+
// Cover traffic: a decoy — drop it silently (no UI, no history, no receipt).
|
|
377
|
+
if (isCover(data)) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (data.action === 'clear') {
|
|
382
|
+
this.#ui.clearChat();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (data.action === 'typing') {
|
|
387
|
+
this.#showPeerTyping(fromNickname);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (data.action === 'sk_dist') {
|
|
392
|
+
this.#getGroup(data.room).addMember(fromNickname, data.dist);
|
|
393
|
+
this.#flushGroupBuffer(fromNickname, data.room);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (data.action === 'room_announce') {
|
|
398
|
+
const prev = this.#peerRooms.get(fromNickname);
|
|
399
|
+
const room = data.room || 'general';
|
|
400
|
+
this.#peerRooms.set(fromNickname, room);
|
|
401
|
+
// They're now in my room — give them my sender key (before any group msg).
|
|
402
|
+
if (room === this.#currentRoom) {
|
|
403
|
+
this.#distributeSenderKey(this.#currentRoom, fromNickname);
|
|
404
|
+
}
|
|
405
|
+
if (prev !== undefined && prev !== room) {
|
|
406
|
+
if (room === this.#currentRoom) {
|
|
407
|
+
this.#ui.addSystemMessage(`${fromNickname} joined room #${room}`);
|
|
408
|
+
} else if (prev === this.#currentRoom) {
|
|
409
|
+
// They left my room — rotate so they can't read my future messages.
|
|
410
|
+
this.#ui.addSystemMessage(`${fromNickname} left for room #${room}`);
|
|
411
|
+
this.#getGroup(this.#currentRoom).rotate();
|
|
412
|
+
this.#distributeSenderKey(this.#currentRoom);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (data.action === 'key_rotation') {
|
|
419
|
+
this.#handshake.updatePeerKey(fromNickname, data.newPublicKey);
|
|
420
|
+
if (peer) {
|
|
421
|
+
peer.publicKey = data.newPublicKey;
|
|
422
|
+
}
|
|
423
|
+
this.#trustStore.autoUpdatePeer(fromNickname, data.newPublicKey);
|
|
424
|
+
this.#auditLog.log(AuditEvent.KEY_ROTATION_PEER, { nickname: fromNickname });
|
|
425
|
+
this.#ui.addSystemMessage(`${fromNickname} rotated keys`);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (data.action === 'file_offer') {
|
|
430
|
+
// Require explicit consent — do NOT start receiving automatically.
|
|
431
|
+
this.#pendingFileOffers.set(data.transferId, { data, nickname: fromNickname });
|
|
432
|
+
const kb = (data.fileSize / 1024).toFixed(0);
|
|
433
|
+
this.#ui.addSystemMessage(
|
|
434
|
+
`${fromNickname} wants to send "${data.fileName}" (${kb}KB). ` +
|
|
435
|
+
`Use /accept ${data.transferId} or /reject ${data.transferId}.`,
|
|
436
|
+
);
|
|
437
|
+
this.#ui.playNotification();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (data.action === 'file_accept') {
|
|
442
|
+
this.#fileTransfer.handleFileAccept(fromNickname, data);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (data.action === 'file_reject') {
|
|
447
|
+
this.#fileTransfer.handleFileReject(fromNickname, data);
|
|
448
|
+
this.#ui.finishProgress();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (data.action === 'file_chunk') {
|
|
453
|
+
const progress = this.#fileTransfer.handleFileChunk(fromNickname, data);
|
|
454
|
+
if (progress && progress.percent % 10 === 0) {
|
|
455
|
+
this.#ui.updateProgress(progress.text, progress.percent);
|
|
456
|
+
}
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (data.action === 'file_complete') {
|
|
461
|
+
this.#fileTransfer.handleFileComplete(fromNickname, data).then(async (result) => {
|
|
462
|
+
this.#ui.finishProgress();
|
|
463
|
+
if (!result.success) {
|
|
464
|
+
this.#ui.addErrorMessage(result.message);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
this.#ui.addSystemMessage(result.message);
|
|
468
|
+
if (result.savePath && isImageFile(result.savePath)) {
|
|
469
|
+
this.#lastImagePath = result.savePath;
|
|
470
|
+
try {
|
|
471
|
+
this.#ui.addImagePreview(await renderImagePreview(result.savePath));
|
|
472
|
+
} catch {
|
|
473
|
+
// preview is best-effort
|
|
474
|
+
}
|
|
475
|
+
if (detectImageProtocol()) {
|
|
476
|
+
this.#ui.addInfoMessage('Tip: /img to view this image in full resolution');
|
|
477
|
+
}
|
|
478
|
+
} else if (result.savePath && isAudioFile(result.savePath)) {
|
|
479
|
+
this.#lastAudioPath = result.savePath;
|
|
480
|
+
this.#ui.addInfoMessage('🔊 Voice note received — /play to listen');
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (data.action === 'reaction') {
|
|
487
|
+
this.#ui.addSystemMessage(`${data.emoji} ${fromNickname} reacted to a message`);
|
|
488
|
+
this.#ui.playNotification();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (data.action === 'edit_message') {
|
|
493
|
+
const author = this.#messageAuthors.get(data.messageId);
|
|
494
|
+
if (author && author === fromNickname) {
|
|
495
|
+
this.#ui.addSystemMessage(`${fromNickname} edited: ${data.newText} (edited)`);
|
|
496
|
+
}
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (data.action === 'delete_message') {
|
|
501
|
+
const author = this.#messageAuthors.get(data.messageId);
|
|
502
|
+
if (author && author === fromNickname) {
|
|
503
|
+
this.#ui.addSystemMessage(`${fromNickname} deleted a message`);
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (data.action === 'pin_message') {
|
|
509
|
+
this.#pinnedMessages.push({
|
|
510
|
+
messageId: data.messageId,
|
|
511
|
+
nickname: data.nickname,
|
|
512
|
+
text: data.text,
|
|
513
|
+
pinnedBy: fromNickname,
|
|
514
|
+
pinnedAt: Date.now(),
|
|
515
|
+
});
|
|
516
|
+
this.#ui.addSystemMessage(
|
|
517
|
+
`\uD83D\uDCCC ${fromNickname} pinned: "${data.text}" \u2014 ${data.nickname}`,
|
|
518
|
+
);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (data.action === 'unpin_message') {
|
|
523
|
+
this.#pinnedMessages = this.#pinnedMessages.filter((p) => p.messageId !== data.messageId);
|
|
524
|
+
this.#ui.addSystemMessage(`${fromNickname} removed a pin`);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Text message — ignore if it belongs to a different room (defense in depth;
|
|
529
|
+
// room-scoped sends already avoid delivering it, but a room change could race).
|
|
530
|
+
if (data.room && data.room !== this.#currentRoom && !data.isDM) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
this.#hidePeerTyping(fromNickname);
|
|
534
|
+
if (data.messageId) {
|
|
535
|
+
this.#lastReceivedMessageId = data.messageId;
|
|
536
|
+
this.#lastReceivedNickname = fromNickname;
|
|
537
|
+
this.#lastReceivedText = data.text;
|
|
538
|
+
this.#messageAuthors.set(data.messageId, fromNickname);
|
|
539
|
+
}
|
|
540
|
+
const mentioned = mentionsMe(data.text, this.#nickname) && !data.isDM;
|
|
541
|
+
const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
|
|
542
|
+
const { lineIndex } = this.#ui.addMessage(
|
|
543
|
+
fromNickname,
|
|
544
|
+
data.text,
|
|
545
|
+
!!data.isDM,
|
|
546
|
+
ephLabel,
|
|
547
|
+
isDeniable || !!data.deniable,
|
|
548
|
+
mentioned,
|
|
549
|
+
);
|
|
550
|
+
const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
|
|
551
|
+
if (notify) {
|
|
552
|
+
this.#ui.playNotification();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (data.ephemeral && data.ephemeral > 0) {
|
|
556
|
+
this.#scheduleEphemeralRemoval(lineIndex, data.ephemeral, fromNickname);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (notify && (this.#ui.notifyEnabled || mentioned)) {
|
|
560
|
+
notifier.notify({
|
|
561
|
+
title: mentioned
|
|
562
|
+
? `🔔 ${fromNickname} mentioned you`
|
|
563
|
+
: data.isDM
|
|
564
|
+
? `DM from ${fromNickname}`
|
|
565
|
+
: `${fromNickname} — CipherMesh`,
|
|
566
|
+
message: data.text.slice(0, 100),
|
|
567
|
+
sound: mentioned,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ── TOFU: Trust On First Use ───────────────────────────────────
|
|
573
|
+
#checkTrust(nickname, publicKey) {
|
|
574
|
+
const result = this.#trustStore.checkPeer(nickname, publicKey);
|
|
575
|
+
|
|
576
|
+
switch (result) {
|
|
577
|
+
case TrustResult.NEW_PEER:
|
|
578
|
+
this.#trustStore.recordPeer(nickname, publicKey);
|
|
579
|
+
this.#auditLog.log(AuditEvent.TRUST_NEW_PEER, { nickname });
|
|
580
|
+
break;
|
|
581
|
+
|
|
582
|
+
case TrustResult.TRUSTED:
|
|
583
|
+
break;
|
|
584
|
+
|
|
585
|
+
case TrustResult.MISMATCH:
|
|
586
|
+
this.#auditLog.log(AuditEvent.TRUST_MISMATCH, { nickname });
|
|
587
|
+
this.#ui.addErrorMessage(
|
|
588
|
+
`WARNING: ${nickname}'s key changed! Use /trust ${nickname} to accept or /verify ${nickname} to verify.`,
|
|
589
|
+
);
|
|
590
|
+
break;
|
|
591
|
+
|
|
592
|
+
case TrustResult.VERIFIED_MISMATCH:
|
|
593
|
+
this.#auditLog.log(AuditEvent.TRUST_VERIFIED_MISMATCH, { nickname });
|
|
594
|
+
this.#ui.addErrorMessage(
|
|
595
|
+
`ALERT: ${nickname}'s VERIFIED key changed! Use /verify ${nickname} to re-verify.`,
|
|
596
|
+
);
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ── Typing indicator ──────────────────────────────────────────
|
|
602
|
+
#handleTypingActivity() {
|
|
603
|
+
const now = Date.now();
|
|
604
|
+
if (now - this.#lastTypingSent < TYPING_SEND_INTERVAL) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (this.#peers.size === 0) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
this.#lastTypingSent = now;
|
|
612
|
+
this.#sendCommandToAll('typing');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
#showPeerTyping(nickname) {
|
|
616
|
+
const existing = this.#peerTypingTimers.get(nickname);
|
|
617
|
+
if (existing) {
|
|
618
|
+
clearTimeout(existing);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
this.#ui.showTyping(nickname);
|
|
622
|
+
|
|
623
|
+
const timer = setTimeout(() => {
|
|
624
|
+
this.#ui.hideTyping(nickname);
|
|
625
|
+
this.#peerTypingTimers.delete(nickname);
|
|
626
|
+
}, TYPING_EXPIRE_TIMEOUT);
|
|
627
|
+
|
|
628
|
+
this.#peerTypingTimers.set(nickname, timer);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
#hidePeerTyping(nickname) {
|
|
632
|
+
const timer = this.#peerTypingTimers.get(nickname);
|
|
633
|
+
if (timer) {
|
|
634
|
+
clearTimeout(timer);
|
|
635
|
+
this.#peerTypingTimers.delete(nickname);
|
|
636
|
+
}
|
|
637
|
+
this.#ui.hideTyping(nickname);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ── User input ─────────────────────────────────────────────────
|
|
641
|
+
#handleUserInput(text) {
|
|
642
|
+
if (text.startsWith('/')) {
|
|
643
|
+
this.#handleCommand(text);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
this.#sendMessageToAll(text);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
#handleCommand(text) {
|
|
651
|
+
const parts = text.split(/\s+/);
|
|
652
|
+
const cmd = parts[0].toLowerCase();
|
|
653
|
+
|
|
654
|
+
switch (cmd) {
|
|
655
|
+
case '/help':
|
|
656
|
+
this.#ui.addInfoMessage('Available commands (P2P mode):');
|
|
657
|
+
this.#ui.addInfoMessage(' /help - Show this help');
|
|
658
|
+
this.#ui.addInfoMessage(' /users - List connected peers');
|
|
659
|
+
this.#ui.addInfoMessage(' /msg <nick> <text> - Send a private message (DM)');
|
|
660
|
+
this.#ui.addInfoMessage(' /fingerprint - Show your fingerprint');
|
|
661
|
+
this.#ui.addInfoMessage(" /fingerprint <nick> - Another peer's fingerprint");
|
|
662
|
+
this.#ui.addInfoMessage(' /verify <nick> - SAS code for verification');
|
|
663
|
+
this.#ui.addInfoMessage(' /verify-confirm <nick> - Confirm verification');
|
|
664
|
+
this.#ui.addInfoMessage(' /trust <nick> - Accept a new key');
|
|
665
|
+
this.#ui.addInfoMessage(' /trustlist - Trust status');
|
|
666
|
+
this.#ui.addInfoMessage(' /clear - Clear the chat');
|
|
667
|
+
this.#ui.addInfoMessage(' /file <path> - Send a file (max 50MB)');
|
|
668
|
+
this.#ui.addInfoMessage(
|
|
669
|
+
' /voice [sec] - Record and send a voice note (default 10s)',
|
|
670
|
+
);
|
|
671
|
+
this.#ui.addInfoMessage(' /play [path] - Play the last received voice note');
|
|
672
|
+
this.#ui.addInfoMessage(' /sound [on|off] - Sound notifications');
|
|
673
|
+
this.#ui.addInfoMessage(' /notify [on|off] - Desktop notifications');
|
|
674
|
+
this.#ui.addInfoMessage(
|
|
675
|
+
' /dnd [on|off|mentions|HH:MM-HH:MM] - Do not disturb / mentions only',
|
|
676
|
+
);
|
|
677
|
+
this.#ui.addInfoMessage(' /audit [N] - Show the last N audit events');
|
|
678
|
+
this.#ui.addInfoMessage(' /ephemeral <time|off> - Ephemeral messages (e.g. 30s, 5m, 1h)');
|
|
679
|
+
this.#ui.addInfoMessage(' /react <emoji> - React to the last received message');
|
|
680
|
+
this.#ui.addInfoMessage(' /edit <new text> - Edit your last sent message');
|
|
681
|
+
this.#ui.addInfoMessage(' /delete - Delete your last sent message');
|
|
682
|
+
this.#ui.addInfoMessage(' /pin - Pin the last received message');
|
|
683
|
+
this.#ui.addInfoMessage(' /unpin - Remove the last pin');
|
|
684
|
+
this.#ui.addInfoMessage(' /pins - List pinned messages');
|
|
685
|
+
this.#ui.addInfoMessage(' /deniable [on|off] - Deniable mode (symmetric crypto)');
|
|
686
|
+
this.#ui.addInfoMessage(' /cover [on|constant|off] - Cover traffic (masks timing/volume)');
|
|
687
|
+
this.#ui.addInfoMessage(' /kick, /mute, /ban - (server mode only)');
|
|
688
|
+
this.#ui.addInfoMessage(' /theme [name] - Nick color theme');
|
|
689
|
+
this.#ui.addInfoMessage(
|
|
690
|
+
' /panic [yes] - Wipe EVERYTHING from disk and exit (duress)',
|
|
691
|
+
);
|
|
692
|
+
this.#ui.addInfoMessage(' /plugins - List loaded plugins');
|
|
693
|
+
this.#ui.addInfoMessage(' /quit - Exit the chat');
|
|
694
|
+
break;
|
|
695
|
+
|
|
696
|
+
case '/users': {
|
|
697
|
+
const names = [...this.#peers.keys()];
|
|
698
|
+
this.#ui.addInfoMessage(
|
|
699
|
+
`Online (${names.length + 1}): ${this.#nickname} (you), ${names.join(', ') || 'nobody else'}`,
|
|
700
|
+
);
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
case '/fingerprint': {
|
|
705
|
+
const targetNick = parts[1];
|
|
706
|
+
if (!targetNick) {
|
|
707
|
+
this.#ui.addInfoMessage(`Your fingerprint: ${this.#keyManager.fingerprint}`);
|
|
708
|
+
this.#ui.addPlainLines(
|
|
709
|
+
keyArt(Buffer.from(this.#keyManager.publicKeyB64, 'base64'), this.#nickname).split(
|
|
710
|
+
'\n',
|
|
711
|
+
),
|
|
712
|
+
);
|
|
713
|
+
} else {
|
|
714
|
+
const found = this.#findPeer(targetNick);
|
|
715
|
+
if (found) {
|
|
716
|
+
const fp = KeyManager.computeFingerprint(Buffer.from(found.publicKey, 'base64'));
|
|
717
|
+
this.#ui.addInfoMessage(`${found.nickname}'s fingerprint: ${fp}`);
|
|
718
|
+
this.#ui.addPlainLines(
|
|
719
|
+
keyArt(Buffer.from(found.publicKey, 'base64'), found.nickname).split('\n'),
|
|
720
|
+
);
|
|
721
|
+
} else {
|
|
722
|
+
this.#ui.addErrorMessage(`Peer "${targetNick}" not found`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
case '/clear':
|
|
729
|
+
this.#sendCommandToAll('clear');
|
|
730
|
+
this.#ui.clearChat();
|
|
731
|
+
break;
|
|
732
|
+
|
|
733
|
+
case '/sound': {
|
|
734
|
+
const arg = parts[1]?.toLowerCase();
|
|
735
|
+
if (arg === 'off') {
|
|
736
|
+
this.#ui.setSoundEnabled(false);
|
|
737
|
+
this.#ui.addInfoMessage('Sound notifications disabled');
|
|
738
|
+
} else if (arg === 'on') {
|
|
739
|
+
this.#ui.setSoundEnabled(true);
|
|
740
|
+
this.#ui.addInfoMessage('Sound notifications enabled');
|
|
741
|
+
} else {
|
|
742
|
+
const status = this.#ui.soundEnabled ? 'enabled' : 'disabled';
|
|
743
|
+
this.#ui.addInfoMessage(`Sound: ${status}. Use /sound on or /sound off`);
|
|
744
|
+
}
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
case '/verify': {
|
|
749
|
+
const verifyNick = parts[1];
|
|
750
|
+
if (!verifyNick) {
|
|
751
|
+
this.#ui.addErrorMessage('Usage: /verify <nickname>');
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
const verifyPeer = this.#findPeer(verifyNick);
|
|
755
|
+
if (!verifyPeer) {
|
|
756
|
+
this.#ui.addErrorMessage(`Peer "${verifyNick}" not found`);
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
const sas = TrustStore.computeSAS(this.#keyManager.publicKeyB64, verifyPeer.publicKey);
|
|
760
|
+
this.#auditLog.log(AuditEvent.SAS_VERIFY, { nickname: verifyPeer.nickname });
|
|
761
|
+
this.#ui.addInfoMessage(`SAS code for ${verifyPeer.nickname}: ${sas}`);
|
|
762
|
+
this.#ui.addPlainLines(
|
|
763
|
+
keyArt(Buffer.from(verifyPeer.publicKey, 'base64'), verifyPeer.nickname).split('\n'),
|
|
764
|
+
);
|
|
765
|
+
this.#ui.addInfoMessage(
|
|
766
|
+
'Compare the code (or the art) with the peer by voice or another channel. If it matches, use /verify-confirm ' +
|
|
767
|
+
verifyPeer.nickname,
|
|
768
|
+
);
|
|
769
|
+
qrcode.generate(sas, { small: true }, (qr) => {
|
|
770
|
+
this.#ui.addPlainLines(qr.split('\n'));
|
|
771
|
+
});
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
case '/verify-confirm': {
|
|
776
|
+
const confirmNick = parts[1];
|
|
777
|
+
if (!confirmNick) {
|
|
778
|
+
this.#ui.addErrorMessage('Usage: /verify-confirm <nickname>');
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
const confirmed = this.#trustStore.markVerified(confirmNick);
|
|
782
|
+
if (confirmed) {
|
|
783
|
+
this.#auditLog.log(AuditEvent.SAS_CONFIRM, { nickname: confirmNick });
|
|
784
|
+
this.#ui.addSystemMessage(`${confirmNick} marked as verified`);
|
|
785
|
+
} else {
|
|
786
|
+
this.#ui.addErrorMessage(`Peer "${confirmNick}" not found in trust store.`);
|
|
787
|
+
}
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
case '/trust': {
|
|
792
|
+
const trustNick = parts[1];
|
|
793
|
+
if (!trustNick) {
|
|
794
|
+
this.#ui.addErrorMessage('Usage: /trust <nickname>');
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
797
|
+
const trustPeer = this.#findPeer(trustNick);
|
|
798
|
+
if (!trustPeer) {
|
|
799
|
+
this.#ui.addErrorMessage(`Peer "${trustNick}" is not online`);
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
this.#trustStore.updatePeer(trustPeer.nickname, trustPeer.publicKey);
|
|
803
|
+
this.#ui.addSystemMessage(`${trustPeer.nickname}'s key accepted (verification reset)`);
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
case '/trustlist': {
|
|
808
|
+
const peerNames = [...this.#peers.keys()];
|
|
809
|
+
if (peerNames.length === 0) {
|
|
810
|
+
this.#ui.addInfoMessage('No peers online');
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
this.#ui.addInfoMessage('Trust status:');
|
|
814
|
+
for (const name of peerNames) {
|
|
815
|
+
const record = this.#trustStore.getPeerRecord(name);
|
|
816
|
+
let status;
|
|
817
|
+
if (!record) {
|
|
818
|
+
status = 'unknown';
|
|
819
|
+
} else if (record.verified) {
|
|
820
|
+
status = 'verified';
|
|
821
|
+
} else {
|
|
822
|
+
status = 'trusted (TOFU)';
|
|
823
|
+
}
|
|
824
|
+
this.#ui.addInfoMessage(` ${name}: ${status}`);
|
|
825
|
+
}
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
case '/notify': {
|
|
830
|
+
const notifyArg = parts[1]?.toLowerCase();
|
|
831
|
+
if (notifyArg === 'off') {
|
|
832
|
+
this.#ui.setNotifyEnabled(false);
|
|
833
|
+
this.#ui.addInfoMessage('Desktop notifications disabled');
|
|
834
|
+
} else if (notifyArg === 'on') {
|
|
835
|
+
this.#ui.setNotifyEnabled(true);
|
|
836
|
+
this.#ui.addInfoMessage('Desktop notifications enabled');
|
|
837
|
+
} else {
|
|
838
|
+
const status = this.#ui.notifyEnabled ? 'enabled' : 'disabled';
|
|
839
|
+
this.#ui.addInfoMessage(
|
|
840
|
+
`Desktop notifications: ${status}. Use /notify on or /notify off`,
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
case '/msg': {
|
|
847
|
+
const msgNick = parts[1];
|
|
848
|
+
if (!msgNick) {
|
|
849
|
+
this.#ui.addErrorMessage('Usage: /msg <nick> <text>');
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
const msgText = parts.slice(2).join(' ');
|
|
853
|
+
if (!msgText) {
|
|
854
|
+
this.#ui.addErrorMessage('Usage: /msg <nick> <text>');
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
const msgPeer = this.#findPeer(msgNick);
|
|
858
|
+
if (!msgPeer) {
|
|
859
|
+
this.#ui.addErrorMessage(`Peer "${msgNick}" not found`);
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
this.#sendMessageToPeer(msgPeer.nickname, msgText);
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
case '/file': {
|
|
867
|
+
const filePath = parts.slice(1).join(' ');
|
|
868
|
+
if (!filePath) {
|
|
869
|
+
this.#ui.addErrorMessage('Usage: /file <path>');
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
872
|
+
if (this.#peers.size === 0) {
|
|
873
|
+
this.#ui.addSystemMessage('No peers online to receive files');
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
this.#sendFile(filePath);
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
case '/voice': {
|
|
881
|
+
const secs = Math.min(60, Math.max(1, parseInt(parts[1]) || 10));
|
|
882
|
+
if (this.#peers.size === 0) {
|
|
883
|
+
this.#ui.addSystemMessage('No peers online to receive the voice note');
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
this.#ui.addSystemMessage(`🎤 Recording voice note for ${secs}s... (speak now)`);
|
|
887
|
+
recordVoiceNote(tmpdir(), secs, Date.now())
|
|
888
|
+
.then((path) => {
|
|
889
|
+
this.#ui.addSystemMessage('Sending voice note...');
|
|
890
|
+
this.#sendFile(path);
|
|
891
|
+
})
|
|
892
|
+
.catch((e) => this.#ui.addErrorMessage(`Voice note: ${e.message}`));
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
case '/play': {
|
|
897
|
+
const audioPath = parts.slice(1).join(' ').trim() || this.#lastAudioPath;
|
|
898
|
+
if (!audioPath) {
|
|
899
|
+
this.#ui.addErrorMessage('No recent voice note. Usage: /play [path]');
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
this.#ui.addSystemMessage('🔊 Playing voice note...');
|
|
903
|
+
playVoiceNote(audioPath).catch((e) => this.#ui.addErrorMessage(`Play: ${e.message}`));
|
|
904
|
+
break;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
case '/theme': {
|
|
908
|
+
const themeArg = parts[1]?.toLowerCase();
|
|
909
|
+
if (!themeArg) {
|
|
910
|
+
this.#ui.addInfoMessage(
|
|
911
|
+
`Current theme: ${getThemeName()}. Available: ${themeNames().join(', ')}`,
|
|
912
|
+
);
|
|
913
|
+
} else if (themeNames().includes(themeArg)) {
|
|
914
|
+
setTheme(themeArg);
|
|
915
|
+
this.#ui.addInfoMessage(
|
|
916
|
+
`Theme "${themeArg}" applied — new messages use the new nick colors`,
|
|
917
|
+
);
|
|
918
|
+
} else {
|
|
919
|
+
this.#ui.addErrorMessage(`Unknown theme. Available: ${themeNames().join(', ')}`);
|
|
920
|
+
}
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
case '/img': {
|
|
925
|
+
const imgPath = parts.slice(1).join(' ').trim() || this.#lastImagePath;
|
|
926
|
+
if (!imgPath) {
|
|
927
|
+
this.#ui.addErrorMessage('No recent image. Usage: /img [path]');
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
const protocol = detectImageProtocol();
|
|
931
|
+
if (!protocol) {
|
|
932
|
+
this.#ui.addInfoMessage(
|
|
933
|
+
`Your terminal does not support inline images (kitty/iTerm2). File saved to: ${imgPath}`,
|
|
934
|
+
);
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
loadImageBuffers(imgPath)
|
|
938
|
+
.then((bufs) => {
|
|
939
|
+
const widthCells = Math.min((process.stdout.columns || 80) - 4, 80);
|
|
940
|
+
this.#ui.showRealImage(encodeInlineImage(protocol, bufs, { widthCells }));
|
|
941
|
+
})
|
|
942
|
+
.catch((e) => this.#ui.addErrorMessage(`Could not render: ${e.message}`));
|
|
943
|
+
break;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
case '/backup': {
|
|
947
|
+
const path = parts.slice(1).join(' ').trim() || './ciphermesh-backup.json';
|
|
948
|
+
if (!this.#passphrase) {
|
|
949
|
+
this.#ui.addErrorMessage(
|
|
950
|
+
'The backup is encrypted with the session passphrase — restart and set a passphrase.',
|
|
951
|
+
);
|
|
952
|
+
break;
|
|
953
|
+
}
|
|
954
|
+
try {
|
|
955
|
+
const envelope = exportBackup(
|
|
956
|
+
{
|
|
957
|
+
identity: this.#keyManager.serialize(),
|
|
958
|
+
trust: this.#trustStore.exportData(),
|
|
959
|
+
},
|
|
960
|
+
this.#passphrase,
|
|
961
|
+
);
|
|
962
|
+
writeFileSync(resolve(path), envelope, { encoding: 'utf-8', mode: 0o600 });
|
|
963
|
+
this.#ui.addSystemMessage(`Identity + trust backup saved to ${path} (encrypted).`);
|
|
964
|
+
} catch (e) {
|
|
965
|
+
this.#ui.addErrorMessage(`Failed to save backup: ${e.message}`);
|
|
966
|
+
}
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
case '/accept': {
|
|
971
|
+
const pending = parts[1]
|
|
972
|
+
? this.#pendingFileOffers.get(parts[1])
|
|
973
|
+
: this.#pendingFileOffers.values().next().value;
|
|
974
|
+
if (!pending) {
|
|
975
|
+
this.#ui.addErrorMessage('No pending file offer.');
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
const offer = this.#fileTransfer.handleFileOffer(
|
|
979
|
+
pending.nickname,
|
|
980
|
+
pending.data,
|
|
981
|
+
pending.nickname,
|
|
982
|
+
);
|
|
983
|
+
this.#ui.addSystemMessage(`Accepting: ${offer.message}`);
|
|
984
|
+
this.#broadcastPayload(
|
|
985
|
+
JSON.stringify({
|
|
986
|
+
action: 'file_accept',
|
|
987
|
+
transferId: pending.data.transferId,
|
|
988
|
+
have: offer.have,
|
|
989
|
+
sentAt: Date.now(),
|
|
990
|
+
}),
|
|
991
|
+
);
|
|
992
|
+
this.#pendingFileOffers.delete(pending.data.transferId);
|
|
993
|
+
break;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
case '/reject': {
|
|
997
|
+
const pending = parts[1]
|
|
998
|
+
? this.#pendingFileOffers.get(parts[1])
|
|
999
|
+
: this.#pendingFileOffers.values().next().value;
|
|
1000
|
+
if (!pending) {
|
|
1001
|
+
this.#ui.addErrorMessage('No pending file offer.');
|
|
1002
|
+
break;
|
|
1003
|
+
}
|
|
1004
|
+
this.#broadcastPayload(
|
|
1005
|
+
JSON.stringify({
|
|
1006
|
+
action: 'file_reject',
|
|
1007
|
+
transferId: pending.data.transferId,
|
|
1008
|
+
sentAt: Date.now(),
|
|
1009
|
+
}),
|
|
1010
|
+
);
|
|
1011
|
+
this.#pendingFileOffers.delete(pending.data.transferId);
|
|
1012
|
+
this.#ui.addSystemMessage(`Offer from ${pending.nickname} rejected.`);
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
case '/deniable': {
|
|
1017
|
+
const denArg = parts[1]?.toLowerCase();
|
|
1018
|
+
if (denArg === 'off') {
|
|
1019
|
+
this.#deniableMode = false;
|
|
1020
|
+
this.#ui.removeHeaderIndicator('deniable');
|
|
1021
|
+
this.#ui.addInfoMessage('Deniable mode disabled');
|
|
1022
|
+
} else if (denArg === 'on') {
|
|
1023
|
+
this.#deniableMode = true;
|
|
1024
|
+
this.#ui.setHeaderIndicator('deniable', '{magenta-fg}[D]{/magenta-fg}');
|
|
1025
|
+
this.#ui.addInfoMessage(
|
|
1026
|
+
'Deniable mode enabled (symmetric crypto — plausible deniability)',
|
|
1027
|
+
);
|
|
1028
|
+
} else {
|
|
1029
|
+
const status = this.#deniableMode ? 'enabled' : 'disabled';
|
|
1030
|
+
this.#ui.addInfoMessage(`Deniable mode: ${status}. Use /deniable on or /deniable off`);
|
|
1031
|
+
}
|
|
1032
|
+
break;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
case '/dnd': {
|
|
1036
|
+
const dndArg = parts[1]?.toLowerCase();
|
|
1037
|
+
if (!dndArg) {
|
|
1038
|
+
const win = this.#dndWindow ? ' + quiet window' : '';
|
|
1039
|
+
this.#ui.addInfoMessage(
|
|
1040
|
+
`DND: ${this.#dndMode}${win}. Usage: /dnd on | off | mentions | HH:MM-HH:MM`,
|
|
1041
|
+
);
|
|
1042
|
+
} else if (dndArg === 'on' || dndArg === 'off' || dndArg === 'mentions') {
|
|
1043
|
+
this.#dndMode = dndArg;
|
|
1044
|
+
if (dndArg === 'off' && !this.#dndWindow) {
|
|
1045
|
+
this.#ui.removeHeaderIndicator('dnd');
|
|
1046
|
+
} else {
|
|
1047
|
+
this.#ui.setHeaderIndicator('dnd', '{yellow-fg}[🔕]{/yellow-fg}');
|
|
1048
|
+
}
|
|
1049
|
+
this.#ui.addInfoMessage(
|
|
1050
|
+
dndArg === 'mentions'
|
|
1051
|
+
? 'DND: mentions only notify'
|
|
1052
|
+
: dndArg === 'on'
|
|
1053
|
+
? 'DND: total silence'
|
|
1054
|
+
: 'DND disabled',
|
|
1055
|
+
);
|
|
1056
|
+
} else {
|
|
1057
|
+
const win = parseDndWindow(dndArg);
|
|
1058
|
+
if (!win) {
|
|
1059
|
+
this.#ui.addErrorMessage('Invalid format. Usage: /dnd HH:MM-HH:MM (e.g. 22:00-08:00)');
|
|
1060
|
+
break;
|
|
1061
|
+
}
|
|
1062
|
+
this.#dndWindow = win;
|
|
1063
|
+
this.#ui.setHeaderIndicator('dnd', '{yellow-fg}[🔕]{/yellow-fg}');
|
|
1064
|
+
this.#ui.addInfoMessage(`Quiet hours ${dndArg} — mentions only during the window`);
|
|
1065
|
+
}
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
case '/cover': {
|
|
1070
|
+
const coverArg = parts[1]?.toLowerCase();
|
|
1071
|
+
if (coverArg === 'on' || coverArg === 'jitter') {
|
|
1072
|
+
this.#setCoverMode('jitter');
|
|
1073
|
+
this.#ui.setHeaderIndicator('cover', '{cyan-fg}[C]{/cyan-fg}');
|
|
1074
|
+
this.#ui.addInfoMessage(
|
|
1075
|
+
'Cover traffic (jitter) enabled — encrypted decoys at random intervals',
|
|
1076
|
+
);
|
|
1077
|
+
} else if (coverArg === 'constant') {
|
|
1078
|
+
this.#setCoverMode('constant');
|
|
1079
|
+
this.#ui.setHeaderIndicator('cover', '{cyan-fg}[C=]{/cyan-fg}');
|
|
1080
|
+
this.#ui.addInfoMessage(
|
|
1081
|
+
'Cover traffic (constant rate) enabled — uniform encrypted stream; ' +
|
|
1082
|
+
'your messages go out in the next slot (up to ~3s delay)',
|
|
1083
|
+
);
|
|
1084
|
+
} else if (coverArg === 'off') {
|
|
1085
|
+
this.#setCoverMode('off');
|
|
1086
|
+
this.#ui.removeHeaderIndicator('cover');
|
|
1087
|
+
this.#ui.addInfoMessage('Cover traffic disabled');
|
|
1088
|
+
} else {
|
|
1089
|
+
this.#ui.addInfoMessage(
|
|
1090
|
+
`Cover traffic: ${this.#coverMode}. Use /cover on (jitter), /cover constant or /cover off`,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
case '/audit': {
|
|
1097
|
+
const auditCount = parseInt(parts[1]) || 20;
|
|
1098
|
+
const events = this.#auditLog.readLast(auditCount);
|
|
1099
|
+
if (events.length === 0) {
|
|
1100
|
+
this.#ui.addInfoMessage('No audit events recorded');
|
|
1101
|
+
} else {
|
|
1102
|
+
this.#ui.addInfoMessage(`Last ${events.length} audit event(s):`);
|
|
1103
|
+
for (const e of events) {
|
|
1104
|
+
const { ts, event, ...rest } = e;
|
|
1105
|
+
const details = Object.keys(rest).length > 0 ? ` — ${JSON.stringify(rest)}` : '';
|
|
1106
|
+
this.#ui.addInfoMessage(` [${ts}] ${event}${details}`);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
case '/react': {
|
|
1113
|
+
const emojiArg = parts[1];
|
|
1114
|
+
if (!emojiArg) {
|
|
1115
|
+
this.#ui.addErrorMessage('Usage: /react <emoji> (e.g. :fire: :thumbsup: :heart:)');
|
|
1116
|
+
break;
|
|
1117
|
+
}
|
|
1118
|
+
if (!this.#lastReceivedMessageId) {
|
|
1119
|
+
this.#ui.addErrorMessage('No message to react to');
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
const emoji = EMOJI_MAP[emojiArg] || emojiArg;
|
|
1123
|
+
const reactionPayload = JSON.stringify({
|
|
1124
|
+
action: 'reaction',
|
|
1125
|
+
targetMessageId: this.#lastReceivedMessageId,
|
|
1126
|
+
emoji,
|
|
1127
|
+
sentAt: Date.now(),
|
|
1128
|
+
});
|
|
1129
|
+
this.#broadcastPayload(reactionPayload);
|
|
1130
|
+
this.#ui.addSystemMessage(
|
|
1131
|
+
`${emoji} You reacted to ${this.#lastReceivedNickname}'s message`,
|
|
1132
|
+
);
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
case '/edit': {
|
|
1137
|
+
const editText = parts.slice(1).join(' ');
|
|
1138
|
+
if (!editText) {
|
|
1139
|
+
this.#ui.addErrorMessage('Usage: /edit <new text>');
|
|
1140
|
+
break;
|
|
1141
|
+
}
|
|
1142
|
+
if (!this.#lastSentMessageId) {
|
|
1143
|
+
this.#ui.addErrorMessage('No message to edit');
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
const editPayload = JSON.stringify({
|
|
1147
|
+
action: 'edit_message',
|
|
1148
|
+
messageId: this.#lastSentMessageId,
|
|
1149
|
+
newText: editText,
|
|
1150
|
+
sentAt: Date.now(),
|
|
1151
|
+
});
|
|
1152
|
+
this.#broadcastPayload(editPayload);
|
|
1153
|
+
this.#ui.addSystemMessage(`You edited: ${editText} (edited)`);
|
|
1154
|
+
break;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
case '/delete': {
|
|
1158
|
+
if (!this.#lastSentMessageId) {
|
|
1159
|
+
this.#ui.addErrorMessage('No message to delete');
|
|
1160
|
+
break;
|
|
1161
|
+
}
|
|
1162
|
+
const deletePayload = JSON.stringify({
|
|
1163
|
+
action: 'delete_message',
|
|
1164
|
+
messageId: this.#lastSentMessageId,
|
|
1165
|
+
sentAt: Date.now(),
|
|
1166
|
+
});
|
|
1167
|
+
this.#broadcastPayload(deletePayload);
|
|
1168
|
+
this.#lastSentMessageId = null;
|
|
1169
|
+
this.#ui.addSystemMessage('You deleted a message');
|
|
1170
|
+
break;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
case '/pin': {
|
|
1174
|
+
if (!this.#lastReceivedMessageId || !this.#lastReceivedText) {
|
|
1175
|
+
this.#ui.addErrorMessage('No message to pin');
|
|
1176
|
+
break;
|
|
1177
|
+
}
|
|
1178
|
+
const pinPayload = JSON.stringify({
|
|
1179
|
+
action: 'pin_message',
|
|
1180
|
+
messageId: this.#lastReceivedMessageId,
|
|
1181
|
+
nickname: this.#lastReceivedNickname,
|
|
1182
|
+
text: this.#lastReceivedText,
|
|
1183
|
+
sentAt: Date.now(),
|
|
1184
|
+
});
|
|
1185
|
+
this.#broadcastPayload(pinPayload);
|
|
1186
|
+
this.#pinnedMessages.push({
|
|
1187
|
+
messageId: this.#lastReceivedMessageId,
|
|
1188
|
+
nickname: this.#lastReceivedNickname,
|
|
1189
|
+
text: this.#lastReceivedText,
|
|
1190
|
+
pinnedBy: this.#nickname,
|
|
1191
|
+
pinnedAt: Date.now(),
|
|
1192
|
+
});
|
|
1193
|
+
this.#ui.addSystemMessage(
|
|
1194
|
+
`\uD83D\uDCCC You pinned: "${this.#lastReceivedText}" \u2014 ${this.#lastReceivedNickname}`,
|
|
1195
|
+
);
|
|
1196
|
+
break;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
case '/unpin': {
|
|
1200
|
+
if (this.#pinnedMessages.length === 0) {
|
|
1201
|
+
this.#ui.addErrorMessage('No pinned messages');
|
|
1202
|
+
break;
|
|
1203
|
+
}
|
|
1204
|
+
const removed = this.#pinnedMessages.pop();
|
|
1205
|
+
const unpinPayload = JSON.stringify({
|
|
1206
|
+
action: 'unpin_message',
|
|
1207
|
+
messageId: removed.messageId,
|
|
1208
|
+
sentAt: Date.now(),
|
|
1209
|
+
});
|
|
1210
|
+
this.#broadcastPayload(unpinPayload);
|
|
1211
|
+
this.#ui.addSystemMessage('You unpinned a message');
|
|
1212
|
+
break;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
case '/pins': {
|
|
1216
|
+
if (this.#pinnedMessages.length === 0) {
|
|
1217
|
+
this.#ui.addInfoMessage('No pinned messages');
|
|
1218
|
+
} else {
|
|
1219
|
+
this.#ui.addInfoMessage('Pinned messages:');
|
|
1220
|
+
for (const pin of this.#pinnedMessages) {
|
|
1221
|
+
this.#ui.addInfoMessage(
|
|
1222
|
+
` \uD83D\uDCCC "${pin.text}" \u2014 ${pin.nickname} (pinned by ${pin.pinnedBy})`,
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
case '/ephemeral': {
|
|
1230
|
+
const ephArg = parts[1]?.toLowerCase();
|
|
1231
|
+
if (!ephArg || ephArg === 'off') {
|
|
1232
|
+
this.#ephemeralMode = false;
|
|
1233
|
+
this.#ephemeralDurationMs = 0;
|
|
1234
|
+
this.#ui.removeHeaderIndicator('ephemeral');
|
|
1235
|
+
this.#ui.addInfoMessage('Ephemeral mode disabled');
|
|
1236
|
+
} else {
|
|
1237
|
+
const ms = this.#parseEphemeralTime(ephArg);
|
|
1238
|
+
if (!ms) {
|
|
1239
|
+
this.#ui.addErrorMessage('Invalid format. Use: 30s, 5m, 1h or off');
|
|
1240
|
+
break;
|
|
1241
|
+
}
|
|
1242
|
+
if (ms > 3_600_000) {
|
|
1243
|
+
this.#ui.addErrorMessage('Maximum: 1h (3600s)');
|
|
1244
|
+
break;
|
|
1245
|
+
}
|
|
1246
|
+
this.#ephemeralMode = true;
|
|
1247
|
+
this.#ephemeralDurationMs = ms;
|
|
1248
|
+
this.#ui.setHeaderIndicator('ephemeral', `{yellow-fg}[E ${ephArg}]{/yellow-fg}`);
|
|
1249
|
+
this.#ui.addInfoMessage(`Ephemeral mode enabled: ${ephArg}`);
|
|
1250
|
+
}
|
|
1251
|
+
break;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
case '/join': {
|
|
1255
|
+
const room = (parts[1] || '')
|
|
1256
|
+
.trim()
|
|
1257
|
+
.toLowerCase()
|
|
1258
|
+
.replace(/[^a-z0-9_-]/g, '');
|
|
1259
|
+
if (room.length < 1 || room.length > 30) {
|
|
1260
|
+
this.#ui.addErrorMessage('Usage: /join <room> (1-30 characters: a-z, 0-9, _, -)');
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
if (room === this.#currentRoom) {
|
|
1264
|
+
this.#ui.addInfoMessage(`You are already in room #${room}`);
|
|
1265
|
+
break;
|
|
1266
|
+
}
|
|
1267
|
+
this.#currentRoom = room;
|
|
1268
|
+
this.#ui.setRoom(room);
|
|
1269
|
+
this.#ui.setHeaderIndicator('room', `{cyan-fg}#${room}{/cyan-fg}`);
|
|
1270
|
+
this.#broadcastPayload(
|
|
1271
|
+
JSON.stringify({ action: 'room_announce', room, sentAt: Date.now() }),
|
|
1272
|
+
);
|
|
1273
|
+
// Give my sender key to peers already in this room.
|
|
1274
|
+
this.#distributeSenderKey(room);
|
|
1275
|
+
this.#ui.addSystemMessage(`You joined room #${room}`);
|
|
1276
|
+
break;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
case '/rooms': {
|
|
1280
|
+
const rooms = new Set([this.#currentRoom]);
|
|
1281
|
+
for (const r of this.#peerRooms.values()) {
|
|
1282
|
+
rooms.add(r);
|
|
1283
|
+
}
|
|
1284
|
+
const list = [...rooms].map((r) => {
|
|
1285
|
+
const peers = [...this.#peers.keys()].filter(
|
|
1286
|
+
(n) => (this.#peerRooms.get(n) || 'general') === r,
|
|
1287
|
+
).length;
|
|
1288
|
+
const count = peers + (r === this.#currentRoom ? 1 : 0);
|
|
1289
|
+
return `#${r} (${count})`;
|
|
1290
|
+
});
|
|
1291
|
+
this.#ui.addInfoMessage(`Known rooms: ${list.join(', ')}`);
|
|
1292
|
+
break;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
case '/room':
|
|
1296
|
+
this.#ui.addInfoMessage(`Current room: #${this.#currentRoom}`);
|
|
1297
|
+
break;
|
|
1298
|
+
|
|
1299
|
+
case '/kick':
|
|
1300
|
+
case '/mute':
|
|
1301
|
+
case '/ban':
|
|
1302
|
+
case '/owner':
|
|
1303
|
+
this.#ui.addErrorMessage('Moderation not available in P2P mode');
|
|
1304
|
+
break;
|
|
1305
|
+
|
|
1306
|
+
case '/plugins': {
|
|
1307
|
+
if (!this.#pluginManager || this.#pluginManager.pluginCount === 0) {
|
|
1308
|
+
this.#ui.addInfoMessage('No plugins loaded. Put .js files in ~/.ciphermesh/plugins/');
|
|
1309
|
+
} else {
|
|
1310
|
+
const names = this.#pluginManager.getPluginNames();
|
|
1311
|
+
this.#ui.addInfoMessage(`Plugins loaded (${names.length}): ${names.join(', ')}`);
|
|
1312
|
+
const cmds = this.#pluginManager.getCommandNames();
|
|
1313
|
+
if (cmds.length > 0) {
|
|
1314
|
+
this.#ui.addInfoMessage(`Commands: ${cmds.join(', ')}`);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
break;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
case '/panic': {
|
|
1321
|
+
const panicArg = parts[1]?.toLowerCase();
|
|
1322
|
+
if (panicArg === 'sim' || panicArg === 'yes' || panicArg === 'wipe') {
|
|
1323
|
+
this.#doPanic();
|
|
1324
|
+
} else {
|
|
1325
|
+
this.#ui.addErrorMessage(
|
|
1326
|
+
'PANIC wipes EVERYTHING from disk (session, trust, keys) and exits. Confirm with /panic yes',
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
break;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
case '/quit':
|
|
1333
|
+
this.destroy(); // tears down the TUI, freeing the terminal for the animation
|
|
1334
|
+
farewellBanner().finally(() => process.exit(0));
|
|
1335
|
+
break;
|
|
1336
|
+
|
|
1337
|
+
default: {
|
|
1338
|
+
if (this.#pluginManager) {
|
|
1339
|
+
const result = this.#pluginManager.handleCommand(cmd, parts.slice(1));
|
|
1340
|
+
if (result) {
|
|
1341
|
+
this.#ui.addInfoMessage(result);
|
|
1342
|
+
break;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
const suggestion = suggestCommand(cmd, COMMANDS);
|
|
1346
|
+
const hint = suggestion ? ` Did you mean ${suggestion}?` : ' Use /help';
|
|
1347
|
+
this.#ui.addErrorMessage(`Unknown command: ${cmd}.${hint}`);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
#findPeer(nickname) {
|
|
1353
|
+
const direct = this.#peers.get(nickname);
|
|
1354
|
+
if (direct) {
|
|
1355
|
+
return { ...direct, nickname };
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
for (const [name, p] of this.#peers) {
|
|
1359
|
+
if (name.toLowerCase() === nickname.toLowerCase()) {
|
|
1360
|
+
return { ...p, nickname: name };
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return null;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// ── Ephemeral helpers ────────────────────────────────────────
|
|
1367
|
+
#parseEphemeralTime(str) {
|
|
1368
|
+
const match = str.match(/^(\d+)(s|m|h)$/);
|
|
1369
|
+
if (!match) {
|
|
1370
|
+
return null;
|
|
1371
|
+
}
|
|
1372
|
+
const val = parseInt(match[1]);
|
|
1373
|
+
if (val <= 0) {
|
|
1374
|
+
return null;
|
|
1375
|
+
}
|
|
1376
|
+
const multiplier = { s: 1000, m: 60_000, h: 3_600_000 };
|
|
1377
|
+
return val * multiplier[match[2]];
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
#formatDuration(ms) {
|
|
1381
|
+
if (ms >= 3_600_000) {
|
|
1382
|
+
return `${Math.round(ms / 3_600_000)}h`;
|
|
1383
|
+
}
|
|
1384
|
+
if (ms >= 60_000) {
|
|
1385
|
+
return `${Math.round(ms / 60_000)}m`;
|
|
1386
|
+
}
|
|
1387
|
+
return `${Math.round(ms / 1000)}s`;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
#scheduleEphemeralRemoval(lineIndex, durationMs, nickname) {
|
|
1391
|
+
const timer = setTimeout(() => {
|
|
1392
|
+
this.#ui.burnLine(lineIndex, () => {
|
|
1393
|
+
this.#ui.addSystemMessage(`Ephemeral message from ${nickname} burned`);
|
|
1394
|
+
});
|
|
1395
|
+
}, durationMs);
|
|
1396
|
+
this.#ephemeralTimers.push(timer);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// ── Key rotation ──────────────────────────────────────────────
|
|
1400
|
+
#startKeyRotation() {
|
|
1401
|
+
this.#keyRotationTimer = setInterval(() => {
|
|
1402
|
+
this.#rotateKeys();
|
|
1403
|
+
}, KEY_ROTATION_INTERVAL_MS);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
#rotateKeys() {
|
|
1407
|
+
this.#keyManager.rotate();
|
|
1408
|
+
|
|
1409
|
+
const payload = JSON.stringify({
|
|
1410
|
+
action: 'key_rotation',
|
|
1411
|
+
newPublicKey: this.#keyManager.publicKeyB64,
|
|
1412
|
+
sentAt: Date.now(),
|
|
1413
|
+
});
|
|
1414
|
+
this.#broadcastPayload(payload);
|
|
1415
|
+
|
|
1416
|
+
this.#auditLog.log(AuditEvent.KEY_ROTATION_OWN, { fingerprint: this.#keyManager.fingerprint });
|
|
1417
|
+
this.#ui.addSystemMessage(`Keys rotated (new fingerprint: ${this.#keyManager.fingerprint})`);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// ── Send encrypted payload ─────────────────────────────────────
|
|
1421
|
+
#sendCommandToAll(action) {
|
|
1422
|
+
const payload = JSON.stringify({ action, sentAt: Date.now() });
|
|
1423
|
+
this.#broadcastPayload(payload);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// ── Cover traffic ──────────────────────────────────────────────
|
|
1427
|
+
#setCoverMode(mode) {
|
|
1428
|
+
this.#clearCoverTimer();
|
|
1429
|
+
this.#flushPace(); // never strand queued real messages when leaving a mode
|
|
1430
|
+
this.#coverMode = mode;
|
|
1431
|
+
if (mode === 'jitter') {
|
|
1432
|
+
this.#scheduleJitterDecoy();
|
|
1433
|
+
} else if (mode === 'constant') {
|
|
1434
|
+
this.#coverTimer = setInterval(() => this.coverTick(), COVER_CONSTANT_MS);
|
|
1435
|
+
if (this.#coverTimer.unref) {
|
|
1436
|
+
this.#coverTimer.unref();
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
#scheduleJitterDecoy() {
|
|
1442
|
+
const tick = () => {
|
|
1443
|
+
if (this.#coverMode === 'jitter') {
|
|
1444
|
+
this.sendCoverNow();
|
|
1445
|
+
this.#coverTimer = setTimeout(tick, nextCoverDelay());
|
|
1446
|
+
if (this.#coverTimer.unref) {
|
|
1447
|
+
this.#coverTimer.unref();
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
this.#coverTimer = setTimeout(tick, nextCoverDelay());
|
|
1452
|
+
if (this.#coverTimer.unref) {
|
|
1453
|
+
this.#coverTimer.unref();
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
#clearCoverTimer() {
|
|
1458
|
+
if (this.#coverTimer) {
|
|
1459
|
+
clearTimeout(this.#coverTimer);
|
|
1460
|
+
clearInterval(this.#coverTimer);
|
|
1461
|
+
this.#coverTimer = null;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
#stopCover() {
|
|
1466
|
+
this.#clearCoverTimer();
|
|
1467
|
+
this.#flushPace();
|
|
1468
|
+
this.#coverMode = 'off';
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// One constant-rate slot: a queued real message if there is one, else a decoy.
|
|
1472
|
+
coverTick() {
|
|
1473
|
+
const item = this.#paceQueue.shift();
|
|
1474
|
+
if (item) {
|
|
1475
|
+
this.#broadcastPayload(item.payload, item.deniable, null, item.room);
|
|
1476
|
+
} else {
|
|
1477
|
+
this.sendCoverNow();
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Route an outgoing room payload: paced in constant mode, immediate otherwise.
|
|
1482
|
+
#paceOrSend(payload, deniable, room) {
|
|
1483
|
+
if (this.#coverMode === 'constant') {
|
|
1484
|
+
this.#paceQueue.push({ payload, deniable, room });
|
|
1485
|
+
} else {
|
|
1486
|
+
this.#broadcastPayload(payload, deniable, null, room);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
#flushPace() {
|
|
1491
|
+
while (this.#paceQueue.length > 0) {
|
|
1492
|
+
const { payload, deniable, room } = this.#paceQueue.shift();
|
|
1493
|
+
this.#broadcastPayload(payload, deniable, null, room);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Sends a single decoy immediately (used by tests and by the timers).
|
|
1498
|
+
sendCoverNow() {
|
|
1499
|
+
if (this.#peers.size > 0) {
|
|
1500
|
+
this.#broadcastPayload(coverPayload(Date.now()));
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
#broadcastPayload(payload, deniable = false, only = null, room = null) {
|
|
1505
|
+
for (const [peerNickname] of this.#peers) {
|
|
1506
|
+
if (only && peerNickname !== only) {
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
// Room-scoped send: only deliver to peers known to be in `room`.
|
|
1510
|
+
if (room !== null && (this.#peerRooms.get(peerNickname) || 'general') !== room) {
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
const peerPublicKey = this.#handshake.getPeerPublicKey(peerNickname);
|
|
1514
|
+
if (!peerPublicKey) {
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Deniable path: crypto_secretbox (symmetric)
|
|
1519
|
+
if (deniable) {
|
|
1520
|
+
const nonce = this.#nonceManager.generate();
|
|
1521
|
+
const sharedKey = deriveSharedKey(this.#handshake.secretKey, peerPublicKey);
|
|
1522
|
+
const ciphertext = encryptDeniable(payload, nonce, sharedKey);
|
|
1523
|
+
this.#connManager.send(peerNickname, {
|
|
1524
|
+
type: 'p2p_message',
|
|
1525
|
+
payload: {
|
|
1526
|
+
ciphertext: ciphertext.toString('base64'),
|
|
1527
|
+
nonce: nonce.toString('base64'),
|
|
1528
|
+
deniable: true,
|
|
1529
|
+
},
|
|
1530
|
+
});
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// Try ratchet path (PFS) first
|
|
1535
|
+
const ratchet = this.#handshake.getRatchet(peerNickname);
|
|
1536
|
+
if (ratchet && ratchet.isInitialized) {
|
|
1537
|
+
try {
|
|
1538
|
+
const result = ratchet.encrypt(payload);
|
|
1539
|
+
this.#connManager.send(peerNickname, {
|
|
1540
|
+
type: 'p2p_message',
|
|
1541
|
+
payload: {
|
|
1542
|
+
ephemeralPublicKey: result.ephemeralPublicKey.toString('base64'),
|
|
1543
|
+
counter: result.counter,
|
|
1544
|
+
previousCounter: result.previousCounter,
|
|
1545
|
+
ciphertext: result.ciphertext.toString('base64'),
|
|
1546
|
+
nonce: result.nonce.toString('base64'),
|
|
1547
|
+
},
|
|
1548
|
+
});
|
|
1549
|
+
continue;
|
|
1550
|
+
} catch {
|
|
1551
|
+
// Fall through to static path
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// Static path fallback
|
|
1556
|
+
const nonce = this.#nonceManager.generate();
|
|
1557
|
+
const ciphertext = MessageCrypto.encrypt(
|
|
1558
|
+
payload,
|
|
1559
|
+
nonce,
|
|
1560
|
+
peerPublicKey,
|
|
1561
|
+
this.#handshake.secretKey,
|
|
1562
|
+
);
|
|
1563
|
+
|
|
1564
|
+
this.#connManager.send(peerNickname, {
|
|
1565
|
+
type: 'p2p_message',
|
|
1566
|
+
payload: {
|
|
1567
|
+
ciphertext: ciphertext.toString('base64'),
|
|
1568
|
+
nonce: nonce.toString('base64'),
|
|
1569
|
+
},
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// ── Group crypto (sender keys) ─────────────────────────────────
|
|
1575
|
+
#getGroup(room) {
|
|
1576
|
+
let group = this.#groups.get(room);
|
|
1577
|
+
if (!group) {
|
|
1578
|
+
group = new GroupSession();
|
|
1579
|
+
this.#groups.set(room, group);
|
|
1580
|
+
}
|
|
1581
|
+
return group;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Hand my sender key for `room` to a peer (or all room peers) over the
|
|
1585
|
+
// pairwise channel — confidential, and ordered before any group message on
|
|
1586
|
+
// the same connection.
|
|
1587
|
+
#distributeSenderKey(room, toPeer = null) {
|
|
1588
|
+
const payload = JSON.stringify({
|
|
1589
|
+
action: 'sk_dist',
|
|
1590
|
+
room,
|
|
1591
|
+
dist: this.#getGroup(room).distribution(),
|
|
1592
|
+
sentAt: Date.now(),
|
|
1593
|
+
});
|
|
1594
|
+
this.#broadcastPayload(payload, false, toPeer, room);
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// Encrypt a room message ONCE and send the same ciphertext to every online
|
|
1598
|
+
// room peer — real group cryptography (O(1) encryption instead of O(N)).
|
|
1599
|
+
#sendRoomGroup(room, payload) {
|
|
1600
|
+
const packet = this.#getGroup(room).encrypt(payload);
|
|
1601
|
+
for (const [peerNickname] of this.#peers) {
|
|
1602
|
+
if ((this.#peerRooms.get(peerNickname) || 'general') !== room) {
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
this.#connManager.send(peerNickname, { type: 'p2p_group', room, ...packet });
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
#onGroupMessage(fromNickname, msg) {
|
|
1610
|
+
if (!this.#peers.has(fromNickname)) {
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
const plaintext = this.#getGroup(msg.room).decrypt(fromNickname, {
|
|
1614
|
+
counter: msg.counter,
|
|
1615
|
+
ciphertext: msg.ciphertext,
|
|
1616
|
+
nonce: msg.nonce,
|
|
1617
|
+
});
|
|
1618
|
+
if (!plaintext) {
|
|
1619
|
+
// No sender key yet (rare race) — buffer until sk_dist arrives.
|
|
1620
|
+
this.#bufferGroupMessage(fromNickname, msg);
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
try {
|
|
1624
|
+
this.#handleDecryptedAction(fromNickname, JSON.parse(plaintext.toString('utf-8')));
|
|
1625
|
+
} catch {
|
|
1626
|
+
// ignore malformed
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
#bufferGroupMessage(fromNickname, msg) {
|
|
1631
|
+
let buf = this.#groupBuffer.get(fromNickname);
|
|
1632
|
+
if (!buf) {
|
|
1633
|
+
buf = [];
|
|
1634
|
+
this.#groupBuffer.set(fromNickname, buf);
|
|
1635
|
+
}
|
|
1636
|
+
if (buf.length < 20) {
|
|
1637
|
+
buf.push(msg);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
#flushGroupBuffer(fromNickname, room) {
|
|
1642
|
+
const buf = this.#groupBuffer.get(fromNickname);
|
|
1643
|
+
if (!buf) {
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
this.#groupBuffer.delete(fromNickname);
|
|
1647
|
+
for (const msg of buf) {
|
|
1648
|
+
if (msg.room === room) {
|
|
1649
|
+
this.#onGroupMessage(fromNickname, msg);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
#sendMessageToAll(text) {
|
|
1655
|
+
const inMyRoom = (n) => (this.#peerRooms.get(n) || 'general') === this.#currentRoom;
|
|
1656
|
+
const onlineInRoom = [...this.#peers.keys()].filter(inMyRoom);
|
|
1657
|
+
const offlineKnownInRoom = [...this.#knownPeers].filter(
|
|
1658
|
+
(n) => !this.#peers.has(n) && inMyRoom(n),
|
|
1659
|
+
);
|
|
1660
|
+
|
|
1661
|
+
if (onlineInRoom.length === 0 && offlineKnownInRoom.length === 0) {
|
|
1662
|
+
this.#ui.addSystemMessage(`Nobody in room #${this.#currentRoom} to receive messages`);
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const messageId = Math.random().toString(36).slice(2, 10);
|
|
1667
|
+
const msgObj = {
|
|
1668
|
+
text,
|
|
1669
|
+
sentAt: Date.now(),
|
|
1670
|
+
messageId,
|
|
1671
|
+
room: this.#currentRoom,
|
|
1672
|
+
};
|
|
1673
|
+
|
|
1674
|
+
this.#lastSentMessageId = messageId;
|
|
1675
|
+
|
|
1676
|
+
if (this.#ephemeralMode) {
|
|
1677
|
+
msgObj.ephemeral = this.#ephemeralDurationMs;
|
|
1678
|
+
}
|
|
1679
|
+
if (this.#deniableMode) {
|
|
1680
|
+
msgObj.deniable = true;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const payload = JSON.stringify(msgObj);
|
|
1684
|
+
// Online delivery: real group crypto (one encryption) for normal messages.
|
|
1685
|
+
// Deniable stays pairwise (plausible deniability); cover-constant keeps the
|
|
1686
|
+
// paced pairwise path so the timing guarantee holds.
|
|
1687
|
+
if (this.#deniableMode || this.#coverMode === 'constant') {
|
|
1688
|
+
this.#paceOrSend(payload, this.#deniableMode, this.#currentRoom);
|
|
1689
|
+
} else {
|
|
1690
|
+
this.#sendRoomGroup(this.#currentRoom, payload);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// Store-and-forward: queue for same-room known peers that are offline (not
|
|
1694
|
+
// for deniable/ephemeral messages, which are not meant to be persisted).
|
|
1695
|
+
if (!this.#deniableMode && !this.#ephemeralMode && offlineKnownInRoom.length > 0) {
|
|
1696
|
+
for (const nick of offlineKnownInRoom) {
|
|
1697
|
+
this.#enqueueSF(nick, payload);
|
|
1698
|
+
}
|
|
1699
|
+
if (onlineInRoom.length === 0) {
|
|
1700
|
+
this.#ui.addSystemMessage(
|
|
1701
|
+
`Queued for ${offlineKnownInRoom.length} offline peer(s) (delivery on reconnect).`,
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
const ephLabel = this.#ephemeralMode ? this.#formatDuration(this.#ephemeralDurationMs) : null;
|
|
1707
|
+
const { lineIndex } = this.#ui.addMessage(
|
|
1708
|
+
this.#nickname,
|
|
1709
|
+
text,
|
|
1710
|
+
false,
|
|
1711
|
+
ephLabel,
|
|
1712
|
+
this.#deniableMode,
|
|
1713
|
+
);
|
|
1714
|
+
|
|
1715
|
+
if (this.#ephemeralMode) {
|
|
1716
|
+
this.#scheduleEphemeralRemoval(lineIndex, this.#ephemeralDurationMs, this.#nickname);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// ── Send encrypted DM to one peer ────────────────────────────
|
|
1721
|
+
#sendMessageToPeer(peerNickname, text) {
|
|
1722
|
+
const peerPublicKey = this.#handshake.getPeerPublicKey(peerNickname);
|
|
1723
|
+
if (!peerPublicKey) {
|
|
1724
|
+
this.#ui.addErrorMessage(`Public key not found for ${peerNickname}`);
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
const payload = JSON.stringify({
|
|
1729
|
+
text,
|
|
1730
|
+
sentAt: Date.now(),
|
|
1731
|
+
messageId: Math.random().toString(36).slice(2, 10),
|
|
1732
|
+
isDM: true,
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
// Try ratchet path (PFS) first
|
|
1736
|
+
const ratchet = this.#handshake.getRatchet(peerNickname);
|
|
1737
|
+
if (ratchet && ratchet.isInitialized) {
|
|
1738
|
+
try {
|
|
1739
|
+
const result = ratchet.encrypt(payload);
|
|
1740
|
+
this.#connManager.send(peerNickname, {
|
|
1741
|
+
type: 'p2p_message',
|
|
1742
|
+
payload: {
|
|
1743
|
+
ephemeralPublicKey: result.ephemeralPublicKey.toString('base64'),
|
|
1744
|
+
counter: result.counter,
|
|
1745
|
+
previousCounter: result.previousCounter,
|
|
1746
|
+
ciphertext: result.ciphertext.toString('base64'),
|
|
1747
|
+
nonce: result.nonce.toString('base64'),
|
|
1748
|
+
},
|
|
1749
|
+
});
|
|
1750
|
+
this.#ui.addMessage(`${this.#nickname} \u2192 ${peerNickname}`, text, true);
|
|
1751
|
+
return;
|
|
1752
|
+
} catch {
|
|
1753
|
+
// Fall through to static path
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// Static path fallback
|
|
1758
|
+
const nonce = this.#nonceManager.generate();
|
|
1759
|
+
const ciphertext = MessageCrypto.encrypt(
|
|
1760
|
+
payload,
|
|
1761
|
+
nonce,
|
|
1762
|
+
peerPublicKey,
|
|
1763
|
+
this.#handshake.secretKey,
|
|
1764
|
+
);
|
|
1765
|
+
this.#connManager.send(peerNickname, {
|
|
1766
|
+
type: 'p2p_message',
|
|
1767
|
+
payload: {
|
|
1768
|
+
ciphertext: ciphertext.toString('base64'),
|
|
1769
|
+
nonce: nonce.toString('base64'),
|
|
1770
|
+
},
|
|
1771
|
+
});
|
|
1772
|
+
this.#ui.addMessage(`${this.#nickname} \u2192 ${peerNickname}`, text, true);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
#sendFile(filePath) {
|
|
1776
|
+
const broadcastFn = (payloadObj) => {
|
|
1777
|
+
const payload = JSON.stringify({ ...payloadObj, sentAt: Date.now() });
|
|
1778
|
+
this.#broadcastPayload(payload);
|
|
1779
|
+
};
|
|
1780
|
+
|
|
1781
|
+
this.#fileTransfer.initSend(filePath, broadcastFn, {
|
|
1782
|
+
onProgress: (percent, text) => this.#ui.updateProgress(text, percent),
|
|
1783
|
+
onError: (text) => {
|
|
1784
|
+
this.#ui.finishProgress();
|
|
1785
|
+
this.#ui.addErrorMessage(text);
|
|
1786
|
+
},
|
|
1787
|
+
onComplete: (text) => {
|
|
1788
|
+
this.#ui.finishProgress();
|
|
1789
|
+
this.#ui.addSystemMessage(text);
|
|
1790
|
+
},
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// ── State serialization ──────────────────────────────────────
|
|
1795
|
+
serializeState() {
|
|
1796
|
+
return {
|
|
1797
|
+
passphrase: this.#passphrase,
|
|
1798
|
+
keyManager: this.#keyManager.serialize(),
|
|
1799
|
+
handshake: this.#handshake.serializeState(),
|
|
1800
|
+
peers: Object.fromEntries(this.#peers),
|
|
1801
|
+
nickname: this.#nickname,
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// ── Panic / duress wipe ──────────────────────────────────────
|
|
1806
|
+
#doPanic() {
|
|
1807
|
+
panicWipe({ trustStore: this.#trustStore, auditLog: this.#auditLog });
|
|
1808
|
+
this.#passphrase = null; // never re-save state on the way out
|
|
1809
|
+
for (const group of this.#groups.values()) {
|
|
1810
|
+
try {
|
|
1811
|
+
group.destroy();
|
|
1812
|
+
} catch {
|
|
1813
|
+
/* best effort */
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
this.#groups.clear();
|
|
1817
|
+
try {
|
|
1818
|
+
this.#handshake.destroy();
|
|
1819
|
+
} catch {
|
|
1820
|
+
/* best effort */
|
|
1821
|
+
}
|
|
1822
|
+
try {
|
|
1823
|
+
this.#keyManager.destroy();
|
|
1824
|
+
} catch {
|
|
1825
|
+
/* best effort */
|
|
1826
|
+
}
|
|
1827
|
+
this.#ui.clearChat();
|
|
1828
|
+
this.#ui.addSystemMessage('PANIC: session, trust, and keys wiped. Exiting...');
|
|
1829
|
+
setTimeout(() => process.exit(0), 60);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// ── Destroy ─────────────────────────────────────────────────
|
|
1833
|
+
destroy() {
|
|
1834
|
+
if (this.#keyRotationTimer) {
|
|
1835
|
+
clearInterval(this.#keyRotationTimer);
|
|
1836
|
+
}
|
|
1837
|
+
this.#stopCover();
|
|
1838
|
+
for (const group of this.#groups.values()) {
|
|
1839
|
+
group.destroy();
|
|
1840
|
+
}
|
|
1841
|
+
this.#groups.clear();
|
|
1842
|
+
for (const timer of this.#peerTypingTimers.values()) {
|
|
1843
|
+
clearTimeout(timer);
|
|
1844
|
+
}
|
|
1845
|
+
for (const timer of this.#ephemeralTimers) {
|
|
1846
|
+
clearTimeout(timer);
|
|
1847
|
+
}
|
|
1848
|
+
this.#fileTransfer.destroy();
|
|
1849
|
+
this.#handshake.destroy();
|
|
1850
|
+
this.#keyManager.destroy();
|
|
1851
|
+
this.#connManager.destroy();
|
|
1852
|
+
this.#peerServer.stop();
|
|
1853
|
+
this.#discovery.stop();
|
|
1854
|
+
this.#ui.destroy();
|
|
1855
|
+
}
|
|
1856
|
+
}
|