ciphermesh 1.2.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -7
- package/README.pt-BR.md +34 -7
- package/bin/ciphermesh.js +5 -0
- package/docs/ARCHITECTURE.md +58 -10
- package/docs/PLUGINS.md +69 -0
- package/examples/plugins/poll.js +28 -0
- package/examples/plugins/roll.js +24 -0
- package/package.json +2 -1
- package/src/client/ChatController.js +429 -14
- package/src/client/UI.js +128 -4
- package/src/client/index.js +50 -7
- package/src/crypto/RoomKey.js +162 -0
- package/src/crypto/TrustStore.js +47 -0
- package/src/p2p/P2PChatController.js +9 -1
- package/src/protocol/messages.js +38 -4
- package/src/protocol/validators.js +31 -25
- package/src/server/MessageRouter.js +3 -1
- package/src/server/SessionManager.js +26 -2
- package/src/server/WebSocketServer.js +143 -3
- package/src/shared/AuditLog.js +3 -0
- package/src/shared/PluginManager.js +9 -6
- package/src/shared/config.js +27 -2
- package/src/shared/constants.js +9 -1
- package/src/shared/lastSession.js +55 -0
- package/src/shared/onboarding.js +111 -0
|
@@ -9,14 +9,25 @@ import {
|
|
|
9
9
|
createJoin,
|
|
10
10
|
createEncryptedMessage,
|
|
11
11
|
createRatchetedMessage,
|
|
12
|
+
createSealedMessage,
|
|
12
13
|
createKeyUpdate,
|
|
13
14
|
createChangeRoom,
|
|
15
|
+
createRoomAuth,
|
|
14
16
|
createListRooms,
|
|
15
17
|
createKickPeer,
|
|
16
18
|
createMutePeer,
|
|
17
19
|
createBanPeer,
|
|
18
20
|
ERR,
|
|
19
21
|
} from '../protocol/messages.js';
|
|
22
|
+
import { sealEnvelope, openEnvelope } from '../crypto/SealedSender.js';
|
|
23
|
+
import {
|
|
24
|
+
deriveRoomSecrets,
|
|
25
|
+
signRoomChallenge,
|
|
26
|
+
encryptRoomPayload,
|
|
27
|
+
decryptRoomPayload,
|
|
28
|
+
isRoomWrapped,
|
|
29
|
+
freeRoomSecrets,
|
|
30
|
+
} from '../crypto/RoomKey.js';
|
|
20
31
|
import { KEY_ROTATION_INTERVAL_MS, EMOJI_MAP, COVER_CONSTANT_MS } from '../shared/constants.js';
|
|
21
32
|
import { KeyManager } from '../crypto/KeyManager.js';
|
|
22
33
|
import { Handshake } from '../crypto/Handshake.js';
|
|
@@ -41,10 +52,12 @@ import { setTheme, getThemeName, themeNames } from '../shared/themes.js';
|
|
|
41
52
|
import { panicWipe } from '../shared/panic.js';
|
|
42
53
|
import { farewellBanner } from '../shared/banner.js';
|
|
43
54
|
import { parseDndWindow, shouldNotify, nowMinutes, mentionsMe } from '../shared/dnd.js';
|
|
55
|
+
import { saveLastSession } from '../shared/lastSession.js';
|
|
44
56
|
import { COMMANDS } from './UI.js';
|
|
45
57
|
|
|
46
58
|
const TYPING_SEND_INTERVAL = 2000; // debounce: max 1 typing event per 2s
|
|
47
59
|
const TYPING_EXPIRE_TIMEOUT = 3000; // hide indicator after 3s of silence
|
|
60
|
+
const MENTIONS_MAX = 50; // session mention log cap (memory only, never persisted)
|
|
48
61
|
|
|
49
62
|
export class ChatController {
|
|
50
63
|
#nickname;
|
|
@@ -96,6 +109,13 @@ export class ChatController {
|
|
|
96
109
|
#autoAwayMs = 0; // idle timeout in ms (0 = off)
|
|
97
110
|
#autoAwayTimer = null;
|
|
98
111
|
#autoAwaySet = false; // whether the current away was set automatically
|
|
112
|
+
#mentions = []; // session mention log: { nickname, text, room, at }
|
|
113
|
+
#awayUnread = 0; // messages received while away
|
|
114
|
+
#awayMentions = 0; // …of which mentioned me
|
|
115
|
+
#autoLockMs = 0; // idle screen-lock timeout (0 = off)
|
|
116
|
+
#autoLockTimer = null;
|
|
117
|
+
#roomSecrets = null; // active private-room secrets { room, authSecretKey, roomKey, … }
|
|
118
|
+
#pendingRoomSecrets = null; // derived while joining/creating, promoted on ROOM_CHANGED
|
|
99
119
|
|
|
100
120
|
constructor(
|
|
101
121
|
nickname,
|
|
@@ -237,6 +257,16 @@ export class ChatController {
|
|
|
237
257
|
this.destroy();
|
|
238
258
|
process.exit(0);
|
|
239
259
|
});
|
|
260
|
+
|
|
261
|
+
this.#ui.on('unlocked', () => {
|
|
262
|
+
this.#auditLog.log(AuditEvent.SCREEN_UNLOCKED, {});
|
|
263
|
+
this.#ui.addSystemMessage('Screen unlocked');
|
|
264
|
+
this.#noteActive();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
this.#ui.on('lock-failed', () => {
|
|
268
|
+
this.#auditLog.log(AuditEvent.SCREEN_UNLOCK_FAILED, {});
|
|
269
|
+
});
|
|
240
270
|
}
|
|
241
271
|
|
|
242
272
|
// ── Auto-away (idle) ────────────────────────────────────────
|
|
@@ -248,9 +278,52 @@ export class ChatController {
|
|
|
248
278
|
this.#autoAwaySet = false;
|
|
249
279
|
this.#ui.removeHeaderIndicator('away');
|
|
250
280
|
this.#ui.addSystemMessage("You're back (auto)");
|
|
281
|
+
this.#reportAwayUnread();
|
|
251
282
|
this.#broadcastPresence();
|
|
252
283
|
}
|
|
253
284
|
this.#armAutoAway();
|
|
285
|
+
this.#armAutoLock();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Screen lock (privacy, not duress — that's /panic) ────────
|
|
289
|
+
#lockNow() {
|
|
290
|
+
if (!this.#passphrase) {
|
|
291
|
+
this.#ui.addErrorMessage(
|
|
292
|
+
'No session passphrase — /lock needs one (set it at startup to enable locking)',
|
|
293
|
+
);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (this.#ui.isLocked) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
this.#auditLog.log(AuditEvent.SCREEN_LOCKED, {});
|
|
300
|
+
this.#ui.showLock((attempt) => attempt === this.#passphrase);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#armAutoLock() {
|
|
304
|
+
if (this.#autoLockTimer) {
|
|
305
|
+
clearTimeout(this.#autoLockTimer);
|
|
306
|
+
this.#autoLockTimer = null;
|
|
307
|
+
}
|
|
308
|
+
if (this.#autoLockMs > 0) {
|
|
309
|
+
this.#autoLockTimer = setTimeout(() => this.#lockNow(), this.#autoLockMs);
|
|
310
|
+
if (this.#autoLockTimer.unref) {
|
|
311
|
+
this.#autoLockTimer.unref();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Summarize what arrived while away, then reset the counters.
|
|
317
|
+
#reportAwayUnread() {
|
|
318
|
+
if (this.#awayUnread > 0) {
|
|
319
|
+
const mentions =
|
|
320
|
+
this.#awayMentions > 0 ? ` — ${this.#awayMentions} mention(s), see /mentions` : '';
|
|
321
|
+
this.#ui.addSystemMessage(
|
|
322
|
+
`While you were away: ${this.#awayUnread} new message(s)${mentions}`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
this.#awayUnread = 0;
|
|
326
|
+
this.#awayMentions = 0;
|
|
254
327
|
}
|
|
255
328
|
|
|
256
329
|
#armAutoAway() {
|
|
@@ -273,6 +346,8 @@ export class ChatController {
|
|
|
273
346
|
this.#away = true;
|
|
274
347
|
this.#awayReason = 'away (idle)';
|
|
275
348
|
this.#autoAwaySet = true;
|
|
349
|
+
this.#awayUnread = 0;
|
|
350
|
+
this.#awayMentions = 0;
|
|
276
351
|
this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
|
|
277
352
|
this.#ui.addSystemMessage('Auto-away: marked as away due to inactivity');
|
|
278
353
|
this.#broadcastPresence();
|
|
@@ -348,6 +423,10 @@ export class ChatController {
|
|
|
348
423
|
this.#onRoomChanged(msg);
|
|
349
424
|
break;
|
|
350
425
|
|
|
426
|
+
case MSG.ROOM_CHALLENGE:
|
|
427
|
+
this.#onRoomChallenge(msg);
|
|
428
|
+
break;
|
|
429
|
+
|
|
351
430
|
case MSG.ROOM_LIST:
|
|
352
431
|
this.#onRoomList(msg);
|
|
353
432
|
break;
|
|
@@ -365,6 +444,11 @@ export class ChatController {
|
|
|
365
444
|
this.#ui.addErrorMessage(
|
|
366
445
|
`${msg.message}. Use /nick <other> to pick a different nickname.`,
|
|
367
446
|
);
|
|
447
|
+
} else if (msg.code === ERR.ROOM_AUTH_FAILED || msg.code === ERR.ROOM_EXISTS) {
|
|
448
|
+
// Join/create refused — drop the derived secrets for that attempt.
|
|
449
|
+
freeRoomSecrets(this.#pendingRoomSecrets);
|
|
450
|
+
this.#pendingRoomSecrets = null;
|
|
451
|
+
this.#ui.addErrorMessage(`Error: ${msg.message} (${msg.code})`);
|
|
368
452
|
} else {
|
|
369
453
|
this.#ui.addErrorMessage(`Error: ${msg.message} (${msg.code})`);
|
|
370
454
|
}
|
|
@@ -408,6 +492,14 @@ export class ChatController {
|
|
|
408
492
|
this.#ui.setRoom(this.#currentRoom);
|
|
409
493
|
this.#currentRoomOwner = msg.roomOwner || null;
|
|
410
494
|
|
|
495
|
+
// (Re)joining always lands in a public room — drop any private-room keys.
|
|
496
|
+
if (this.#roomSecrets && this.#roomSecrets.room !== this.#currentRoom) {
|
|
497
|
+
freeRoomSecrets(this.#roomSecrets);
|
|
498
|
+
this.#roomSecrets = null;
|
|
499
|
+
this.#ui.removeHeaderIndicator('private');
|
|
500
|
+
this.#ui.addInfoMessage('Reconnected outside the private room — /join it again.');
|
|
501
|
+
}
|
|
502
|
+
|
|
411
503
|
// Build map of old sessionIds by nickname for ratchet migration
|
|
412
504
|
const oldSessionByNick = new Map();
|
|
413
505
|
for (const [sid, peer] of this.#peers) {
|
|
@@ -453,6 +545,17 @@ export class ChatController {
|
|
|
453
545
|
this.#connection.send(createChangeRoom(this.#inviteRoom));
|
|
454
546
|
this.#inviteRoom = null;
|
|
455
547
|
}
|
|
548
|
+
|
|
549
|
+
this.#saveLastSession();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Remember where we are for the next launch. Privacy: in a private room only
|
|
553
|
+
// the server is written — the room name never touches disk.
|
|
554
|
+
#saveLastSession(isPrivate = false) {
|
|
555
|
+
saveLastSession({
|
|
556
|
+
server: (this.#connection.url || '').replace(/^wss?:\/\//, ''),
|
|
557
|
+
room: isPrivate || this.#roomSecrets ? undefined : this.#currentRoom,
|
|
558
|
+
});
|
|
456
559
|
}
|
|
457
560
|
|
|
458
561
|
// ── New peer arrived ──────────────────────────────────────────
|
|
@@ -507,6 +610,19 @@ export class ChatController {
|
|
|
507
610
|
|
|
508
611
|
// ── Received encrypted message ────────────────────────────────
|
|
509
612
|
#onEncryptedMessage(msg) {
|
|
613
|
+
// Sealed sender: the relay handed us only `to` + an opaque blob. Open it
|
|
614
|
+
// with our identity key to recover the real sender + payload; from here the
|
|
615
|
+
// rest of the handler is unchanged. A blob that isn't for us (or is tampered)
|
|
616
|
+
// simply fails to open and is dropped.
|
|
617
|
+
if (typeof msg.sealed === 'string') {
|
|
618
|
+
const opened = this.#openSealed(msg.sealed);
|
|
619
|
+
if (!opened || typeof opened.from !== 'string' || !opened.payload) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
// Rebind to a fresh object — never mutate the received message.
|
|
623
|
+
msg = { ...msg, from: opened.from, payload: opened.payload };
|
|
624
|
+
}
|
|
625
|
+
|
|
510
626
|
const peer = this.#peers.get(msg.from);
|
|
511
627
|
if (!peer) {
|
|
512
628
|
this.#ui.addErrorMessage('Message from unknown peer');
|
|
@@ -597,7 +713,20 @@ export class ChatController {
|
|
|
597
713
|
}
|
|
598
714
|
|
|
599
715
|
try {
|
|
600
|
-
|
|
716
|
+
let data = JSON.parse(plaintext.toString('utf-8'));
|
|
717
|
+
|
|
718
|
+
// Private-room layer: unwrap with the room key. Content we can't read
|
|
719
|
+
// (no key, or stale key after a room switch) is dropped silently.
|
|
720
|
+
if (isRoomWrapped(data)) {
|
|
721
|
+
if (!this.#roomSecrets) {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const inner = decryptRoomPayload(data, this.#roomSecrets.roomKey);
|
|
725
|
+
if (!inner) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
data = JSON.parse(inner);
|
|
729
|
+
}
|
|
601
730
|
|
|
602
731
|
// Cover traffic: a decoy — drop it silently (no UI, no history, no receipt).
|
|
603
732
|
if (isCover(data)) {
|
|
@@ -828,6 +957,28 @@ export class ChatController {
|
|
|
828
957
|
}
|
|
829
958
|
|
|
830
959
|
const mentioned = this.#mentionsMe(data.text) && !data.isDM;
|
|
960
|
+
if (mentioned) {
|
|
961
|
+
this.#mentions.push({
|
|
962
|
+
nickname: peer.nickname,
|
|
963
|
+
text: data.text,
|
|
964
|
+
room: this.#currentRoom,
|
|
965
|
+
at: Date.now(),
|
|
966
|
+
});
|
|
967
|
+
if (this.#mentions.length > MENTIONS_MAX) {
|
|
968
|
+
this.#mentions.shift();
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
// Away: count what's arriving and keep the header badge live.
|
|
972
|
+
if (this.#away) {
|
|
973
|
+
this.#awayUnread++;
|
|
974
|
+
if (mentioned) {
|
|
975
|
+
this.#awayMentions++;
|
|
976
|
+
}
|
|
977
|
+
this.#ui.setHeaderIndicator(
|
|
978
|
+
'away',
|
|
979
|
+
`{yellow-fg}[away · ${this.#awayUnread} new]{/yellow-fg}`,
|
|
980
|
+
);
|
|
981
|
+
}
|
|
831
982
|
const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
|
|
832
983
|
const trust = trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
|
|
833
984
|
const { lineIndex } = this.#ui.addMessage(
|
|
@@ -912,11 +1063,18 @@ export class ChatController {
|
|
|
912
1063
|
this.#ui.addInfoMessage(' /users - List online users');
|
|
913
1064
|
this.#ui.addInfoMessage(' /msg <nick> <text> - Send a private message (DM)');
|
|
914
1065
|
this.#ui.addInfoMessage(' /reply <text> - Reply to the last received message');
|
|
915
|
-
this.#ui.addInfoMessage(' /
|
|
1066
|
+
this.#ui.addInfoMessage(' /mentions [n] - Recent mentions of you (this session)');
|
|
1067
|
+
this.#ui.addInfoMessage(' /contacts [add|remove|all] - Contact book (aliases for peers)');
|
|
1068
|
+
this.#ui.addInfoMessage(
|
|
1069
|
+
' /away [reason] - Mark yourself as away (unreads are counted)',
|
|
1070
|
+
);
|
|
916
1071
|
this.#ui.addInfoMessage(' /back - Clear the away status');
|
|
917
1072
|
this.#ui.addInfoMessage(' /autoaway <min|off> - Auto-away on inactivity');
|
|
1073
|
+
this.#ui.addInfoMessage(' /lock - Lock the screen (session passphrase)');
|
|
1074
|
+
this.#ui.addInfoMessage(' /autolock <min|off> - Auto-lock on inactivity');
|
|
918
1075
|
this.#ui.addInfoMessage(' /status <text|off> - Set a status (accepts :emoji:)');
|
|
919
|
-
this.#ui.addInfoMessage(' /join <room>
|
|
1076
|
+
this.#ui.addInfoMessage(' /join <room> [pass] - Join a room (password if private)');
|
|
1077
|
+
this.#ui.addInfoMessage(' /create <room> <pass> - Create a private room 🔒');
|
|
920
1078
|
this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
|
|
921
1079
|
this.#ui.addInfoMessage(' /rooms - List available rooms');
|
|
922
1080
|
this.#ui.addInfoMessage(' /room - Show the current room');
|
|
@@ -969,6 +1127,10 @@ export class ChatController {
|
|
|
969
1127
|
case '/users': {
|
|
970
1128
|
const names = [...this.#peers.values()].map((p) => {
|
|
971
1129
|
let label = p.nickname;
|
|
1130
|
+
const alias = this.#trustStore.getAlias(p.nickname);
|
|
1131
|
+
if (alias) {
|
|
1132
|
+
label += ` (${alias})`;
|
|
1133
|
+
}
|
|
972
1134
|
if (p.away) {
|
|
973
1135
|
label += ` [away${p.awayReason ? `: ${p.awayReason}` : ''}]`;
|
|
974
1136
|
}
|
|
@@ -1179,10 +1341,38 @@ export class ChatController {
|
|
|
1179
1341
|
case '/join': {
|
|
1180
1342
|
const roomName = parts[1];
|
|
1181
1343
|
if (!roomName) {
|
|
1182
|
-
this.#ui.addErrorMessage('Usage: /join <room>');
|
|
1344
|
+
this.#ui.addErrorMessage('Usage: /join <room> [password]');
|
|
1183
1345
|
break;
|
|
1184
1346
|
}
|
|
1185
|
-
|
|
1347
|
+
const joinPassword = parts.slice(2).join(' ');
|
|
1348
|
+
if (joinPassword) {
|
|
1349
|
+
// Derive now so we can answer the server's challenge immediately.
|
|
1350
|
+
this.#prepareRoomSecrets(roomName, joinPassword, () => {
|
|
1351
|
+
this.#connection.send(createChangeRoom(roomName));
|
|
1352
|
+
});
|
|
1353
|
+
} else {
|
|
1354
|
+
this.#connection.send(createChangeRoom(roomName));
|
|
1355
|
+
}
|
|
1356
|
+
break;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
case '/create': {
|
|
1360
|
+
const roomName = parts[1];
|
|
1361
|
+
if (!roomName) {
|
|
1362
|
+
this.#ui.addErrorMessage('Usage: /create <room> <password>');
|
|
1363
|
+
break;
|
|
1364
|
+
}
|
|
1365
|
+
const createPassword = parts.slice(2).join(' ');
|
|
1366
|
+
if (!createPassword) {
|
|
1367
|
+
// No password — same as joining/creating a public room.
|
|
1368
|
+
this.#connection.send(createChangeRoom(roomName));
|
|
1369
|
+
break;
|
|
1370
|
+
}
|
|
1371
|
+
this.#prepareRoomSecrets(roomName, createPassword, (secrets) => {
|
|
1372
|
+
this.#connection.send(
|
|
1373
|
+
createChangeRoom(roomName, secrets.authPublicKey.toString('base64')),
|
|
1374
|
+
);
|
|
1375
|
+
});
|
|
1186
1376
|
break;
|
|
1187
1377
|
}
|
|
1188
1378
|
|
|
@@ -1219,7 +1409,9 @@ export class ChatController {
|
|
|
1219
1409
|
}
|
|
1220
1410
|
|
|
1221
1411
|
case '/room':
|
|
1222
|
-
this.#ui.addInfoMessage(
|
|
1412
|
+
this.#ui.addInfoMessage(
|
|
1413
|
+
`Current room: #${this.#currentRoom}${this.#roomSecrets ? ' 🔒 (private)' : ''}`,
|
|
1414
|
+
);
|
|
1223
1415
|
break;
|
|
1224
1416
|
|
|
1225
1417
|
case '/tips': {
|
|
@@ -1250,6 +1442,8 @@ export class ChatController {
|
|
|
1250
1442
|
case '/away': {
|
|
1251
1443
|
this.#away = true;
|
|
1252
1444
|
this.#autoAwaySet = false; // an explicit /away is not auto
|
|
1445
|
+
this.#awayUnread = 0;
|
|
1446
|
+
this.#awayMentions = 0;
|
|
1253
1447
|
this.#awayReason = applyShortcodes(parts.slice(1).join(' ')).slice(0, 60) || null;
|
|
1254
1448
|
this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
|
|
1255
1449
|
this.#ui.addInfoMessage(
|
|
@@ -1269,10 +1463,124 @@ export class ChatController {
|
|
|
1269
1463
|
this.#autoAwaySet = false;
|
|
1270
1464
|
this.#ui.removeHeaderIndicator('away');
|
|
1271
1465
|
this.#ui.addInfoMessage("You're back");
|
|
1466
|
+
this.#reportAwayUnread();
|
|
1272
1467
|
this.#broadcastPresence();
|
|
1273
1468
|
break;
|
|
1274
1469
|
}
|
|
1275
1470
|
|
|
1471
|
+
case '/contacts': {
|
|
1472
|
+
const sub = (parts[1] || 'list').toLowerCase();
|
|
1473
|
+
|
|
1474
|
+
if (sub === 'add') {
|
|
1475
|
+
const nick = parts[2];
|
|
1476
|
+
const alias = parts.slice(3).join(' ').trim();
|
|
1477
|
+
if (!nick || !alias) {
|
|
1478
|
+
this.#ui.addErrorMessage('Usage: /contacts add <nick> <alias>');
|
|
1479
|
+
break;
|
|
1480
|
+
}
|
|
1481
|
+
if (this.#trustStore.setAlias(nick, alias)) {
|
|
1482
|
+
this.#ui.addInfoMessage(`Contact saved: ${nick} → "${alias.slice(0, 30)}"`);
|
|
1483
|
+
} else {
|
|
1484
|
+
this.#ui.addErrorMessage(
|
|
1485
|
+
`"${nick}" was never seen on this identity — no trust record to alias`,
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
break;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
if (sub === 'remove') {
|
|
1492
|
+
const nick = parts[2];
|
|
1493
|
+
if (!nick) {
|
|
1494
|
+
this.#ui.addErrorMessage('Usage: /contacts remove <nick>');
|
|
1495
|
+
break;
|
|
1496
|
+
}
|
|
1497
|
+
if (this.#trustStore.clearAlias(nick)) {
|
|
1498
|
+
this.#ui.addInfoMessage(`Alias removed from ${nick}`);
|
|
1499
|
+
} else {
|
|
1500
|
+
this.#ui.addErrorMessage(`${nick} has no alias`);
|
|
1501
|
+
}
|
|
1502
|
+
break;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
if (sub !== 'list' && sub !== 'all') {
|
|
1506
|
+
this.#ui.addErrorMessage('Usage: /contacts [add <nick> <alias> | remove <nick> | all]');
|
|
1507
|
+
break;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
const contacts = this.#trustStore.listContacts(sub === 'all');
|
|
1511
|
+
if (contacts.length === 0) {
|
|
1512
|
+
this.#ui.addInfoMessage(
|
|
1513
|
+
sub === 'all'
|
|
1514
|
+
? 'No peers known yet.'
|
|
1515
|
+
: 'No contacts yet. Use /contacts add <nick> <alias>',
|
|
1516
|
+
);
|
|
1517
|
+
break;
|
|
1518
|
+
}
|
|
1519
|
+
this.#ui.addInfoMessage(sub === 'all' ? 'Known peers:' : 'Contacts:');
|
|
1520
|
+
for (const c of contacts) {
|
|
1521
|
+
const badge = c.verified ? ' ✓' : '';
|
|
1522
|
+
const alias = c.alias ? ` (${c.alias})` : '';
|
|
1523
|
+
const seen = c.lastSeen
|
|
1524
|
+
? ` — last seen ${new Date(c.lastSeen).toLocaleString('en-US', {
|
|
1525
|
+
day: '2-digit',
|
|
1526
|
+
month: '2-digit',
|
|
1527
|
+
hour: '2-digit',
|
|
1528
|
+
minute: '2-digit',
|
|
1529
|
+
})}`
|
|
1530
|
+
: '';
|
|
1531
|
+
this.#ui.addInfoMessage(` ${c.nickname}${alias}${badge}${seen}`);
|
|
1532
|
+
}
|
|
1533
|
+
break;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
case '/mentions': {
|
|
1537
|
+
if (this.#mentions.length === 0) {
|
|
1538
|
+
this.#ui.addInfoMessage('No mentions in this session yet.');
|
|
1539
|
+
break;
|
|
1540
|
+
}
|
|
1541
|
+
const count = Math.min(parseInt(parts[1], 10) || 10, this.#mentions.length);
|
|
1542
|
+
this.#ui.addInfoMessage(`Last ${count} mention(s) of you:`);
|
|
1543
|
+
for (const m of this.#mentions.slice(-count)) {
|
|
1544
|
+
const when = new Date(m.at).toLocaleString('en-US', {
|
|
1545
|
+
hour: '2-digit',
|
|
1546
|
+
minute: '2-digit',
|
|
1547
|
+
});
|
|
1548
|
+
this.#ui.addInfoMessage(` [${when}] [#${m.room}] ${m.nickname}: ${m.text.slice(0, 80)}`);
|
|
1549
|
+
}
|
|
1550
|
+
break;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
case '/lock':
|
|
1554
|
+
this.#lockNow();
|
|
1555
|
+
break;
|
|
1556
|
+
|
|
1557
|
+
case '/autolock': {
|
|
1558
|
+
const alArg = parts[1]?.toLowerCase();
|
|
1559
|
+
if (alArg === 'off' || alArg === '0') {
|
|
1560
|
+
this.#autoLockMs = 0;
|
|
1561
|
+
this.#armAutoLock();
|
|
1562
|
+
this.#ui.addInfoMessage('Auto-lock disabled');
|
|
1563
|
+
break;
|
|
1564
|
+
}
|
|
1565
|
+
const alMin = parseInt(alArg, 10);
|
|
1566
|
+
if (!Number.isInteger(alMin) || alMin < 1 || alMin > 240) {
|
|
1567
|
+
this.#ui.addInfoMessage(
|
|
1568
|
+
`Auto-lock: ${this.#autoLockMs ? `${this.#autoLockMs / 60000}min` : 'off'}. Usage: /autolock <minutes|off>`,
|
|
1569
|
+
);
|
|
1570
|
+
break;
|
|
1571
|
+
}
|
|
1572
|
+
if (!this.#passphrase) {
|
|
1573
|
+
this.#ui.addErrorMessage(
|
|
1574
|
+
'No session passphrase — auto-lock needs one (set it at startup)',
|
|
1575
|
+
);
|
|
1576
|
+
break;
|
|
1577
|
+
}
|
|
1578
|
+
this.#autoLockMs = alMin * 60_000;
|
|
1579
|
+
this.#armAutoLock();
|
|
1580
|
+
this.#ui.addInfoMessage(`Auto-lock after ${alMin}min of inactivity`);
|
|
1581
|
+
break;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1276
1584
|
case '/autoaway': {
|
|
1277
1585
|
const aaArg = parts[1]?.toLowerCase();
|
|
1278
1586
|
if (aaArg === 'off' || aaArg === '0') {
|
|
@@ -1900,7 +2208,15 @@ export class ChatController {
|
|
|
1900
2208
|
if (this.#pluginManager) {
|
|
1901
2209
|
const result = this.#pluginManager.handleCommand(cmd, parts.slice(1));
|
|
1902
2210
|
if (result) {
|
|
1903
|
-
|
|
2211
|
+
// Plugin API: `{ send }` goes to the room as a normal E2EE
|
|
2212
|
+
// message; `{ info }` or a plain string stays local.
|
|
2213
|
+
if (typeof result === 'object' && typeof result.send === 'string' && result.send) {
|
|
2214
|
+
this.#sendMessageToAll(result.send);
|
|
2215
|
+
} else if (typeof result === 'object' && typeof result.info === 'string') {
|
|
2216
|
+
this.#ui.addInfoMessage(result.info);
|
|
2217
|
+
} else if (typeof result === 'string') {
|
|
2218
|
+
this.#ui.addInfoMessage(result);
|
|
2219
|
+
}
|
|
1904
2220
|
break;
|
|
1905
2221
|
}
|
|
1906
2222
|
}
|
|
@@ -1989,12 +2305,68 @@ export class ChatController {
|
|
|
1989
2305
|
this.#ui.addSystemMessage(`${peer.nickname} updated key (via server — unauthenticated)`);
|
|
1990
2306
|
}
|
|
1991
2307
|
|
|
2308
|
+
// ── Private rooms: derive secrets off the input handler ─────
|
|
2309
|
+
// Argon2id (MODERATE) blocks for ~1s — let the UI paint the notice first.
|
|
2310
|
+
#prepareRoomSecrets(roomName, password, onReady) {
|
|
2311
|
+
const room = roomName.toLowerCase();
|
|
2312
|
+
this.#ui.addInfoMessage('Deriving room key (Argon2id)…');
|
|
2313
|
+
setImmediate(() => {
|
|
2314
|
+
freeRoomSecrets(this.#pendingRoomSecrets);
|
|
2315
|
+
const secrets = deriveRoomSecrets(room, password);
|
|
2316
|
+
this.#pendingRoomSecrets = { room, ...secrets };
|
|
2317
|
+
onReady(secrets);
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
// ── Handle ROOM_CHALLENGE (target room is private) ──────────
|
|
2322
|
+
#onRoomChallenge(msg) {
|
|
2323
|
+
const pending = this.#pendingRoomSecrets;
|
|
2324
|
+
if (!pending || pending.room !== msg.room) {
|
|
2325
|
+
this.#ui.addErrorMessage(`Room #${msg.room} is private. Usage: /join ${msg.room} <password>`);
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
const signature = signRoomChallenge(
|
|
2329
|
+
pending.authSecretKey,
|
|
2330
|
+
msg.room,
|
|
2331
|
+
msg.nonce,
|
|
2332
|
+
this.#sessionId,
|
|
2333
|
+
);
|
|
2334
|
+
this.#connection.send(createRoomAuth(msg.room, msg.nonce, signature.toString('base64')));
|
|
2335
|
+
}
|
|
2336
|
+
|
|
1992
2337
|
// ── Handle ROOM_CHANGED (after /join) ──────────────────────
|
|
1993
2338
|
#onRoomChanged(msg) {
|
|
1994
2339
|
this.#currentRoom = msg.room;
|
|
1995
2340
|
this.#ui.setRoom(this.#currentRoom);
|
|
1996
2341
|
this.#currentRoomOwner = msg.roomOwner || null;
|
|
1997
2342
|
|
|
2343
|
+
// Rotate private-room secrets: drop the old room's, promote the pending
|
|
2344
|
+
// ones when the server confirms the new room is private.
|
|
2345
|
+
if (this.#roomSecrets) {
|
|
2346
|
+
freeRoomSecrets(this.#roomSecrets);
|
|
2347
|
+
this.#roomSecrets = null;
|
|
2348
|
+
}
|
|
2349
|
+
if (this.#pendingRoomSecrets?.room === msg.room) {
|
|
2350
|
+
if (msg.private) {
|
|
2351
|
+
this.#roomSecrets = this.#pendingRoomSecrets;
|
|
2352
|
+
} else {
|
|
2353
|
+
// Old server without private-room support silently made it public.
|
|
2354
|
+
freeRoomSecrets(this.#pendingRoomSecrets);
|
|
2355
|
+
this.#ui.addErrorMessage(
|
|
2356
|
+
'WARNING: this server does not support private rooms — the room is PUBLIC and anyone can join.',
|
|
2357
|
+
);
|
|
2358
|
+
}
|
|
2359
|
+
this.#pendingRoomSecrets = null;
|
|
2360
|
+
} else if (this.#pendingRoomSecrets) {
|
|
2361
|
+
freeRoomSecrets(this.#pendingRoomSecrets);
|
|
2362
|
+
this.#pendingRoomSecrets = null;
|
|
2363
|
+
}
|
|
2364
|
+
if (this.#roomSecrets) {
|
|
2365
|
+
this.#ui.setHeaderIndicator('private', '{green-fg}[🔒]{/green-fg}');
|
|
2366
|
+
} else {
|
|
2367
|
+
this.#ui.removeHeaderIndicator('private');
|
|
2368
|
+
}
|
|
2369
|
+
|
|
1998
2370
|
// Clear old peers and pins
|
|
1999
2371
|
this.#peers.clear();
|
|
2000
2372
|
this.#pinnedMessages = [];
|
|
@@ -2018,7 +2390,12 @@ export class ChatController {
|
|
|
2018
2390
|
this.#ui.setOnlineCount(this.#peers.size + 1);
|
|
2019
2391
|
this.#ui.setPeerNames(peerNames);
|
|
2020
2392
|
this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room });
|
|
2021
|
-
this.#
|
|
2393
|
+
if (this.#roomSecrets) {
|
|
2394
|
+
this.#ui.addSystemMessage(`You joined private room #${msg.room} 🔒`);
|
|
2395
|
+
this.#ui.addInfoMessage('Messages here get an extra layer encrypted with the room key.');
|
|
2396
|
+
} else {
|
|
2397
|
+
this.#ui.addSystemMessage(`You joined room #${msg.room}`);
|
|
2398
|
+
}
|
|
2022
2399
|
|
|
2023
2400
|
if (peerNames.length > 0) {
|
|
2024
2401
|
this.#ui.addSystemMessage(`Online: ${peerNames.join(', ')}`);
|
|
@@ -2028,6 +2405,8 @@ export class ChatController {
|
|
|
2028
2405
|
if (this.#away || this.#statusText) {
|
|
2029
2406
|
this.#broadcastPresence();
|
|
2030
2407
|
}
|
|
2408
|
+
|
|
2409
|
+
this.#saveLastSession(!!msg.private);
|
|
2031
2410
|
}
|
|
2032
2411
|
|
|
2033
2412
|
// ── Handle ROOM_LIST ───────────────────────────────────────
|
|
@@ -2035,7 +2414,8 @@ export class ChatController {
|
|
|
2035
2414
|
this.#ui.addInfoMessage('Available rooms:');
|
|
2036
2415
|
for (const room of msg.rooms) {
|
|
2037
2416
|
const current = room.name === this.#currentRoom ? ' (current)' : '';
|
|
2038
|
-
|
|
2417
|
+
const lock = room.private ? ' 🔒' : '';
|
|
2418
|
+
this.#ui.addInfoMessage(` #${room.name}${lock} — ${room.memberCount} member(s)${current}`);
|
|
2039
2419
|
}
|
|
2040
2420
|
}
|
|
2041
2421
|
|
|
@@ -2121,6 +2501,28 @@ export class ChatController {
|
|
|
2121
2501
|
}
|
|
2122
2502
|
}
|
|
2123
2503
|
|
|
2504
|
+
// Sealed sender: wrap an outgoing wire message so the relay sees only `to` and
|
|
2505
|
+
// an opaque blob. The sender identity (`from`) + the payload are sealed to the
|
|
2506
|
+
// recipient's key — only they can open it.
|
|
2507
|
+
#sealAndSend(recipientPublicKey, wireMsg) {
|
|
2508
|
+
const sealed = sealEnvelope(wireMsg.from, wireMsg.payload, recipientPublicKey);
|
|
2509
|
+
this.#connection.send(createSealedMessage(wireMsg.to, sealed));
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
// Open a sealed envelope with our identity key, falling back to the previous
|
|
2513
|
+
// key during the post-rotation grace window. Returns { from, payload } or null.
|
|
2514
|
+
#openSealed(sealedB64) {
|
|
2515
|
+
let opened = openEnvelope(sealedB64, this.#keyManager.publicKey, this.#keyManager.secretKey);
|
|
2516
|
+
if (!opened && this.#keyManager.previousPublicKey) {
|
|
2517
|
+
opened = openEnvelope(
|
|
2518
|
+
sealedB64,
|
|
2519
|
+
this.#keyManager.previousPublicKey,
|
|
2520
|
+
this.#keyManager.previousSecretKey,
|
|
2521
|
+
);
|
|
2522
|
+
}
|
|
2523
|
+
return opened;
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2124
2526
|
// ── Send encrypted payload to a single peer ────────────────────
|
|
2125
2527
|
#sendPayloadToPeer(peerId, payload) {
|
|
2126
2528
|
const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
|
|
@@ -2128,11 +2530,16 @@ export class ChatController {
|
|
|
2128
2530
|
return;
|
|
2129
2531
|
}
|
|
2130
2532
|
|
|
2533
|
+
// Private room: extra symmetric layer under the pairwise encryption.
|
|
2534
|
+
if (this.#roomSecrets) {
|
|
2535
|
+
payload = encryptRoomPayload(payload, this.#roomSecrets.roomKey);
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2131
2538
|
const ratchet = this.#handshake.getRatchet(peerId);
|
|
2132
2539
|
if (ratchet && ratchet.isInitialized) {
|
|
2133
2540
|
try {
|
|
2134
2541
|
const result = ratchet.encrypt(payload);
|
|
2135
|
-
this.#
|
|
2542
|
+
this.#sealAndSend(peerPublicKey, createRatchetedMessage(this.#sessionId, peerId, result));
|
|
2136
2543
|
return;
|
|
2137
2544
|
} catch {
|
|
2138
2545
|
// Fall through to static path
|
|
@@ -2146,7 +2553,8 @@ export class ChatController {
|
|
|
2146
2553
|
peerPublicKey,
|
|
2147
2554
|
this.#handshake.secretKey,
|
|
2148
2555
|
);
|
|
2149
|
-
this.#
|
|
2556
|
+
this.#sealAndSend(
|
|
2557
|
+
peerPublicKey,
|
|
2150
2558
|
createEncryptedMessage(
|
|
2151
2559
|
this.#sessionId,
|
|
2152
2560
|
peerId,
|
|
@@ -2244,6 +2652,12 @@ export class ChatController {
|
|
|
2244
2652
|
|
|
2245
2653
|
// ── Broadcast encrypted payload to all peers ───────────────────
|
|
2246
2654
|
#broadcastPayload(payload, deniable = false) {
|
|
2655
|
+
// Private room: extra symmetric layer under the pairwise encryption, so
|
|
2656
|
+
// even a relay-injected member can't read the room without the password.
|
|
2657
|
+
if (this.#roomSecrets) {
|
|
2658
|
+
payload = encryptRoomPayload(payload, this.#roomSecrets.roomKey);
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2247
2661
|
for (const [peerId] of this.#peers) {
|
|
2248
2662
|
const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
|
|
2249
2663
|
if (!peerPublicKey) {
|
|
@@ -2262,7 +2676,7 @@ export class ChatController {
|
|
|
2262
2676
|
nonce.toString('base64'),
|
|
2263
2677
|
);
|
|
2264
2678
|
msg.payload.deniable = true;
|
|
2265
|
-
this.#
|
|
2679
|
+
this.#sealAndSend(peerPublicKey, msg);
|
|
2266
2680
|
continue;
|
|
2267
2681
|
}
|
|
2268
2682
|
|
|
@@ -2271,7 +2685,7 @@ export class ChatController {
|
|
|
2271
2685
|
if (ratchet && ratchet.isInitialized) {
|
|
2272
2686
|
try {
|
|
2273
2687
|
const result = ratchet.encrypt(payload);
|
|
2274
|
-
this.#
|
|
2688
|
+
this.#sealAndSend(peerPublicKey, createRatchetedMessage(this.#sessionId, peerId, result));
|
|
2275
2689
|
continue;
|
|
2276
2690
|
} catch {
|
|
2277
2691
|
// Ratchet failed — fall through to static path
|
|
@@ -2287,7 +2701,8 @@ export class ChatController {
|
|
|
2287
2701
|
this.#handshake.secretKey,
|
|
2288
2702
|
);
|
|
2289
2703
|
|
|
2290
|
-
this.#
|
|
2704
|
+
this.#sealAndSend(
|
|
2705
|
+
peerPublicKey,
|
|
2291
2706
|
createEncryptedMessage(
|
|
2292
2707
|
this.#sessionId,
|
|
2293
2708
|
peerId,
|