ciphermesh 2.0.0 → 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 +33 -6
- package/README.pt-BR.md +33 -6
- package/bin/ciphermesh.js +5 -0
- package/docs/ARCHITECTURE.md +41 -1
- 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 +385 -9
- 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 +31 -4
- package/src/protocol/validators.js +22 -1
- package/src/server/SessionManager.js +26 -2
- package/src/server/WebSocketServer.js +138 -1
- 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 +8 -0
- package/src/shared/lastSession.js +55 -0
- package/src/shared/onboarding.js +111 -0
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
createSealedMessage,
|
|
13
13
|
createKeyUpdate,
|
|
14
14
|
createChangeRoom,
|
|
15
|
+
createRoomAuth,
|
|
15
16
|
createListRooms,
|
|
16
17
|
createKickPeer,
|
|
17
18
|
createMutePeer,
|
|
@@ -19,6 +20,14 @@ import {
|
|
|
19
20
|
ERR,
|
|
20
21
|
} from '../protocol/messages.js';
|
|
21
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';
|
|
22
31
|
import { KEY_ROTATION_INTERVAL_MS, EMOJI_MAP, COVER_CONSTANT_MS } from '../shared/constants.js';
|
|
23
32
|
import { KeyManager } from '../crypto/KeyManager.js';
|
|
24
33
|
import { Handshake } from '../crypto/Handshake.js';
|
|
@@ -43,10 +52,12 @@ import { setTheme, getThemeName, themeNames } from '../shared/themes.js';
|
|
|
43
52
|
import { panicWipe } from '../shared/panic.js';
|
|
44
53
|
import { farewellBanner } from '../shared/banner.js';
|
|
45
54
|
import { parseDndWindow, shouldNotify, nowMinutes, mentionsMe } from '../shared/dnd.js';
|
|
55
|
+
import { saveLastSession } from '../shared/lastSession.js';
|
|
46
56
|
import { COMMANDS } from './UI.js';
|
|
47
57
|
|
|
48
58
|
const TYPING_SEND_INTERVAL = 2000; // debounce: max 1 typing event per 2s
|
|
49
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)
|
|
50
61
|
|
|
51
62
|
export class ChatController {
|
|
52
63
|
#nickname;
|
|
@@ -98,6 +109,13 @@ export class ChatController {
|
|
|
98
109
|
#autoAwayMs = 0; // idle timeout in ms (0 = off)
|
|
99
110
|
#autoAwayTimer = null;
|
|
100
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
|
|
101
119
|
|
|
102
120
|
constructor(
|
|
103
121
|
nickname,
|
|
@@ -239,6 +257,16 @@ export class ChatController {
|
|
|
239
257
|
this.destroy();
|
|
240
258
|
process.exit(0);
|
|
241
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
|
+
});
|
|
242
270
|
}
|
|
243
271
|
|
|
244
272
|
// ── Auto-away (idle) ────────────────────────────────────────
|
|
@@ -250,9 +278,52 @@ export class ChatController {
|
|
|
250
278
|
this.#autoAwaySet = false;
|
|
251
279
|
this.#ui.removeHeaderIndicator('away');
|
|
252
280
|
this.#ui.addSystemMessage("You're back (auto)");
|
|
281
|
+
this.#reportAwayUnread();
|
|
253
282
|
this.#broadcastPresence();
|
|
254
283
|
}
|
|
255
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;
|
|
256
327
|
}
|
|
257
328
|
|
|
258
329
|
#armAutoAway() {
|
|
@@ -275,6 +346,8 @@ export class ChatController {
|
|
|
275
346
|
this.#away = true;
|
|
276
347
|
this.#awayReason = 'away (idle)';
|
|
277
348
|
this.#autoAwaySet = true;
|
|
349
|
+
this.#awayUnread = 0;
|
|
350
|
+
this.#awayMentions = 0;
|
|
278
351
|
this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
|
|
279
352
|
this.#ui.addSystemMessage('Auto-away: marked as away due to inactivity');
|
|
280
353
|
this.#broadcastPresence();
|
|
@@ -350,6 +423,10 @@ export class ChatController {
|
|
|
350
423
|
this.#onRoomChanged(msg);
|
|
351
424
|
break;
|
|
352
425
|
|
|
426
|
+
case MSG.ROOM_CHALLENGE:
|
|
427
|
+
this.#onRoomChallenge(msg);
|
|
428
|
+
break;
|
|
429
|
+
|
|
353
430
|
case MSG.ROOM_LIST:
|
|
354
431
|
this.#onRoomList(msg);
|
|
355
432
|
break;
|
|
@@ -367,6 +444,11 @@ export class ChatController {
|
|
|
367
444
|
this.#ui.addErrorMessage(
|
|
368
445
|
`${msg.message}. Use /nick <other> to pick a different nickname.`,
|
|
369
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})`);
|
|
370
452
|
} else {
|
|
371
453
|
this.#ui.addErrorMessage(`Error: ${msg.message} (${msg.code})`);
|
|
372
454
|
}
|
|
@@ -410,6 +492,14 @@ export class ChatController {
|
|
|
410
492
|
this.#ui.setRoom(this.#currentRoom);
|
|
411
493
|
this.#currentRoomOwner = msg.roomOwner || null;
|
|
412
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
|
+
|
|
413
503
|
// Build map of old sessionIds by nickname for ratchet migration
|
|
414
504
|
const oldSessionByNick = new Map();
|
|
415
505
|
for (const [sid, peer] of this.#peers) {
|
|
@@ -455,6 +545,17 @@ export class ChatController {
|
|
|
455
545
|
this.#connection.send(createChangeRoom(this.#inviteRoom));
|
|
456
546
|
this.#inviteRoom = null;
|
|
457
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
|
+
});
|
|
458
559
|
}
|
|
459
560
|
|
|
460
561
|
// ── New peer arrived ──────────────────────────────────────────
|
|
@@ -612,7 +713,20 @@ export class ChatController {
|
|
|
612
713
|
}
|
|
613
714
|
|
|
614
715
|
try {
|
|
615
|
-
|
|
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
|
+
}
|
|
616
730
|
|
|
617
731
|
// Cover traffic: a decoy — drop it silently (no UI, no history, no receipt).
|
|
618
732
|
if (isCover(data)) {
|
|
@@ -843,6 +957,28 @@ export class ChatController {
|
|
|
843
957
|
}
|
|
844
958
|
|
|
845
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
|
+
}
|
|
846
982
|
const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
|
|
847
983
|
const trust = trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
|
|
848
984
|
const { lineIndex } = this.#ui.addMessage(
|
|
@@ -927,11 +1063,18 @@ export class ChatController {
|
|
|
927
1063
|
this.#ui.addInfoMessage(' /users - List online users');
|
|
928
1064
|
this.#ui.addInfoMessage(' /msg <nick> <text> - Send a private message (DM)');
|
|
929
1065
|
this.#ui.addInfoMessage(' /reply <text> - Reply to the last received message');
|
|
930
|
-
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
|
+
);
|
|
931
1071
|
this.#ui.addInfoMessage(' /back - Clear the away status');
|
|
932
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');
|
|
933
1075
|
this.#ui.addInfoMessage(' /status <text|off> - Set a status (accepts :emoji:)');
|
|
934
|
-
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 🔒');
|
|
935
1078
|
this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
|
|
936
1079
|
this.#ui.addInfoMessage(' /rooms - List available rooms');
|
|
937
1080
|
this.#ui.addInfoMessage(' /room - Show the current room');
|
|
@@ -984,6 +1127,10 @@ export class ChatController {
|
|
|
984
1127
|
case '/users': {
|
|
985
1128
|
const names = [...this.#peers.values()].map((p) => {
|
|
986
1129
|
let label = p.nickname;
|
|
1130
|
+
const alias = this.#trustStore.getAlias(p.nickname);
|
|
1131
|
+
if (alias) {
|
|
1132
|
+
label += ` (${alias})`;
|
|
1133
|
+
}
|
|
987
1134
|
if (p.away) {
|
|
988
1135
|
label += ` [away${p.awayReason ? `: ${p.awayReason}` : ''}]`;
|
|
989
1136
|
}
|
|
@@ -1194,10 +1341,38 @@ export class ChatController {
|
|
|
1194
1341
|
case '/join': {
|
|
1195
1342
|
const roomName = parts[1];
|
|
1196
1343
|
if (!roomName) {
|
|
1197
|
-
this.#ui.addErrorMessage('Usage: /join <room>');
|
|
1344
|
+
this.#ui.addErrorMessage('Usage: /join <room> [password]');
|
|
1345
|
+
break;
|
|
1346
|
+
}
|
|
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));
|
|
1198
1369
|
break;
|
|
1199
1370
|
}
|
|
1200
|
-
this.#
|
|
1371
|
+
this.#prepareRoomSecrets(roomName, createPassword, (secrets) => {
|
|
1372
|
+
this.#connection.send(
|
|
1373
|
+
createChangeRoom(roomName, secrets.authPublicKey.toString('base64')),
|
|
1374
|
+
);
|
|
1375
|
+
});
|
|
1201
1376
|
break;
|
|
1202
1377
|
}
|
|
1203
1378
|
|
|
@@ -1234,7 +1409,9 @@ export class ChatController {
|
|
|
1234
1409
|
}
|
|
1235
1410
|
|
|
1236
1411
|
case '/room':
|
|
1237
|
-
this.#ui.addInfoMessage(
|
|
1412
|
+
this.#ui.addInfoMessage(
|
|
1413
|
+
`Current room: #${this.#currentRoom}${this.#roomSecrets ? ' 🔒 (private)' : ''}`,
|
|
1414
|
+
);
|
|
1238
1415
|
break;
|
|
1239
1416
|
|
|
1240
1417
|
case '/tips': {
|
|
@@ -1265,6 +1442,8 @@ export class ChatController {
|
|
|
1265
1442
|
case '/away': {
|
|
1266
1443
|
this.#away = true;
|
|
1267
1444
|
this.#autoAwaySet = false; // an explicit /away is not auto
|
|
1445
|
+
this.#awayUnread = 0;
|
|
1446
|
+
this.#awayMentions = 0;
|
|
1268
1447
|
this.#awayReason = applyShortcodes(parts.slice(1).join(' ')).slice(0, 60) || null;
|
|
1269
1448
|
this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
|
|
1270
1449
|
this.#ui.addInfoMessage(
|
|
@@ -1284,10 +1463,124 @@ export class ChatController {
|
|
|
1284
1463
|
this.#autoAwaySet = false;
|
|
1285
1464
|
this.#ui.removeHeaderIndicator('away');
|
|
1286
1465
|
this.#ui.addInfoMessage("You're back");
|
|
1466
|
+
this.#reportAwayUnread();
|
|
1287
1467
|
this.#broadcastPresence();
|
|
1288
1468
|
break;
|
|
1289
1469
|
}
|
|
1290
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
|
+
|
|
1291
1584
|
case '/autoaway': {
|
|
1292
1585
|
const aaArg = parts[1]?.toLowerCase();
|
|
1293
1586
|
if (aaArg === 'off' || aaArg === '0') {
|
|
@@ -1915,7 +2208,15 @@ export class ChatController {
|
|
|
1915
2208
|
if (this.#pluginManager) {
|
|
1916
2209
|
const result = this.#pluginManager.handleCommand(cmd, parts.slice(1));
|
|
1917
2210
|
if (result) {
|
|
1918
|
-
|
|
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
|
+
}
|
|
1919
2220
|
break;
|
|
1920
2221
|
}
|
|
1921
2222
|
}
|
|
@@ -2004,12 +2305,68 @@ export class ChatController {
|
|
|
2004
2305
|
this.#ui.addSystemMessage(`${peer.nickname} updated key (via server — unauthenticated)`);
|
|
2005
2306
|
}
|
|
2006
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
|
+
|
|
2007
2337
|
// ── Handle ROOM_CHANGED (after /join) ──────────────────────
|
|
2008
2338
|
#onRoomChanged(msg) {
|
|
2009
2339
|
this.#currentRoom = msg.room;
|
|
2010
2340
|
this.#ui.setRoom(this.#currentRoom);
|
|
2011
2341
|
this.#currentRoomOwner = msg.roomOwner || null;
|
|
2012
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
|
+
|
|
2013
2370
|
// Clear old peers and pins
|
|
2014
2371
|
this.#peers.clear();
|
|
2015
2372
|
this.#pinnedMessages = [];
|
|
@@ -2033,7 +2390,12 @@ export class ChatController {
|
|
|
2033
2390
|
this.#ui.setOnlineCount(this.#peers.size + 1);
|
|
2034
2391
|
this.#ui.setPeerNames(peerNames);
|
|
2035
2392
|
this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room });
|
|
2036
|
-
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
|
+
}
|
|
2037
2399
|
|
|
2038
2400
|
if (peerNames.length > 0) {
|
|
2039
2401
|
this.#ui.addSystemMessage(`Online: ${peerNames.join(', ')}`);
|
|
@@ -2043,6 +2405,8 @@ export class ChatController {
|
|
|
2043
2405
|
if (this.#away || this.#statusText) {
|
|
2044
2406
|
this.#broadcastPresence();
|
|
2045
2407
|
}
|
|
2408
|
+
|
|
2409
|
+
this.#saveLastSession(!!msg.private);
|
|
2046
2410
|
}
|
|
2047
2411
|
|
|
2048
2412
|
// ── Handle ROOM_LIST ───────────────────────────────────────
|
|
@@ -2050,7 +2414,8 @@ export class ChatController {
|
|
|
2050
2414
|
this.#ui.addInfoMessage('Available rooms:');
|
|
2051
2415
|
for (const room of msg.rooms) {
|
|
2052
2416
|
const current = room.name === this.#currentRoom ? ' (current)' : '';
|
|
2053
|
-
|
|
2417
|
+
const lock = room.private ? ' 🔒' : '';
|
|
2418
|
+
this.#ui.addInfoMessage(` #${room.name}${lock} — ${room.memberCount} member(s)${current}`);
|
|
2054
2419
|
}
|
|
2055
2420
|
}
|
|
2056
2421
|
|
|
@@ -2165,6 +2530,11 @@ export class ChatController {
|
|
|
2165
2530
|
return;
|
|
2166
2531
|
}
|
|
2167
2532
|
|
|
2533
|
+
// Private room: extra symmetric layer under the pairwise encryption.
|
|
2534
|
+
if (this.#roomSecrets) {
|
|
2535
|
+
payload = encryptRoomPayload(payload, this.#roomSecrets.roomKey);
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2168
2538
|
const ratchet = this.#handshake.getRatchet(peerId);
|
|
2169
2539
|
if (ratchet && ratchet.isInitialized) {
|
|
2170
2540
|
try {
|
|
@@ -2282,6 +2652,12 @@ export class ChatController {
|
|
|
2282
2652
|
|
|
2283
2653
|
// ── Broadcast encrypted payload to all peers ───────────────────
|
|
2284
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
|
+
|
|
2285
2661
|
for (const [peerId] of this.#peers) {
|
|
2286
2662
|
const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
|
|
2287
2663
|
if (!peerPublicKey) {
|