ciphermesh 2.0.0 → 2.2.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 +40 -7
- package/README.pt-BR.md +40 -7
- package/bin/ciphermesh.js +5 -0
- package/docs/ARCHITECTURE.md +44 -1
- package/docs/PLUGINS.md +69 -0
- package/examples/plugins/poll.js +28 -0
- package/examples/plugins/roll.js +24 -0
- package/package.json +4 -3
- package/src/client/ChatController.js +814 -92
- package/src/client/UI.js +262 -6
- 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 +76 -8
- package/src/protocol/validators.js +61 -1
- package/src/server/SessionManager.js +124 -16
- package/src/server/WebSocketServer.js +339 -41
- 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
package/src/client/UI.js
CHANGED
|
@@ -19,10 +19,14 @@ const COMMAND_INFO = [
|
|
|
19
19
|
['/users', 'List online users'],
|
|
20
20
|
['/msg', 'Private message (DM)'],
|
|
21
21
|
['/reply', 'Reply to the last message'],
|
|
22
|
+
['/mentions', 'Recent mentions of you'],
|
|
23
|
+
['/contacts', 'Contact book — aliases for peers'],
|
|
22
24
|
['/away', 'Mark yourself as away'],
|
|
23
25
|
['/back', 'Clear away status'],
|
|
24
26
|
['/status', 'Set a status'],
|
|
25
|
-
['/join', 'Join a room'],
|
|
27
|
+
['/join', 'Join a room as a new buffer (Alt+1..9 switches)'],
|
|
28
|
+
['/leave', 'Leave a room — its buffer closes'],
|
|
29
|
+
['/create', 'Create a private room with a password'],
|
|
26
30
|
['/rooms', 'List rooms'],
|
|
27
31
|
['/room', 'Show the current room'],
|
|
28
32
|
['/invite', 'Generate an invite with QR'],
|
|
@@ -54,6 +58,8 @@ const COMMAND_INFO = [
|
|
|
54
58
|
['/receipts', 'Read receipts'],
|
|
55
59
|
['/cover', 'Cover traffic (anti-metadata)'],
|
|
56
60
|
['/theme', 'Nick color theme'],
|
|
61
|
+
['/lock', 'Lock the screen (session passphrase to unlock)'],
|
|
62
|
+
['/autolock', 'Auto-lock on inactivity'],
|
|
57
63
|
['/panic', 'Wipe everything from disk and exit (duress)'],
|
|
58
64
|
['/kick', 'Kick a user (owner)'],
|
|
59
65
|
['/mute', 'Mute a user (owner)'],
|
|
@@ -83,6 +89,8 @@ export const COMMANDS = [
|
|
|
83
89
|
'/sound',
|
|
84
90
|
'/msg',
|
|
85
91
|
'/reply',
|
|
92
|
+
'/mentions',
|
|
93
|
+
'/contacts',
|
|
86
94
|
'/away',
|
|
87
95
|
'/back',
|
|
88
96
|
'/autoaway',
|
|
@@ -90,6 +98,8 @@ export const COMMANDS = [
|
|
|
90
98
|
'/notify',
|
|
91
99
|
'/dnd',
|
|
92
100
|
'/join',
|
|
101
|
+
'/leave',
|
|
102
|
+
'/create',
|
|
93
103
|
'/rooms',
|
|
94
104
|
'/room',
|
|
95
105
|
'/invite',
|
|
@@ -110,6 +120,8 @@ export const COMMANDS = [
|
|
|
110
120
|
'/receipts',
|
|
111
121
|
'/cover',
|
|
112
122
|
'/theme',
|
|
123
|
+
'/lock',
|
|
124
|
+
'/autolock',
|
|
113
125
|
'/panic',
|
|
114
126
|
'/kick',
|
|
115
127
|
'/mute',
|
|
@@ -302,6 +314,23 @@ export function renderMarkdown(text) {
|
|
|
302
314
|
return out.join('\n');
|
|
303
315
|
}
|
|
304
316
|
|
|
317
|
+
// Sanitizes pasted text while PRESERVING its line structure — the input box is
|
|
318
|
+
// multi-line and fenced code blocks render in markdown, so pasted code must
|
|
319
|
+
// keep its newlines. Normalizes CRLF/CR, turns tabs into spaces and strips the
|
|
320
|
+
// remaining control chars (incl. stray paste markers). Pure and exported for
|
|
321
|
+
// testing.
|
|
322
|
+
export function cleanPaste(raw) {
|
|
323
|
+
return (
|
|
324
|
+
raw
|
|
325
|
+
// eslint-disable-next-line no-control-regex
|
|
326
|
+
.replace(/\x1b\[20[01]~/g, '')
|
|
327
|
+
.replace(/\r\n?/g, '\n')
|
|
328
|
+
.replace(/\t/g, ' ')
|
|
329
|
+
// eslint-disable-next-line no-control-regex
|
|
330
|
+
.replace(/[\x00-\x09\x0b-\x1f\x7f]/g, '')
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
305
334
|
// Builds the rendered content of the (possibly multi-line) input box with an
|
|
306
335
|
// inverse cursor cell, windowed so the cursor line is always visible. Pure and
|
|
307
336
|
// exported for testing. Returns { content, height } (height includes borders).
|
|
@@ -466,6 +495,15 @@ export class UI extends EventEmitter {
|
|
|
466
495
|
#emojiPicker;
|
|
467
496
|
#emojiOpen;
|
|
468
497
|
#emojiQuery;
|
|
498
|
+
#locked;
|
|
499
|
+
#lockBox;
|
|
500
|
+
#lockInput;
|
|
501
|
+
#lockError;
|
|
502
|
+
#lockVerify;
|
|
503
|
+
#bufferLines; // Map<room, lines[]> — stored content of INACTIVE buffers
|
|
504
|
+
#activeBuffer; // name of the buffer currently on screen
|
|
505
|
+
#redirecting; // true while add* calls are being written to an inactive buffer
|
|
506
|
+
#bufferBar; // [{ room, active, unread, private }] for the status bar
|
|
469
507
|
|
|
470
508
|
constructor(nickname) {
|
|
471
509
|
super();
|
|
@@ -511,6 +549,14 @@ export class UI extends EventEmitter {
|
|
|
511
549
|
this.#paletteQuery = '';
|
|
512
550
|
this.#emojiOpen = false;
|
|
513
551
|
this.#emojiQuery = '';
|
|
552
|
+
this.#locked = false;
|
|
553
|
+
this.#lockInput = '';
|
|
554
|
+
this.#lockError = false;
|
|
555
|
+
this.#lockVerify = null;
|
|
556
|
+
this.#bufferLines = new Map();
|
|
557
|
+
this.#activeBuffer = 'general';
|
|
558
|
+
this.#redirecting = false;
|
|
559
|
+
this.#bufferBar = [];
|
|
514
560
|
|
|
515
561
|
// blessed's terminfo parser can't compile the modern Setulc (underline
|
|
516
562
|
// colour) capability that terminals like ghostty ship, so it dumps a
|
|
@@ -670,6 +716,12 @@ export class UI extends EventEmitter {
|
|
|
670
716
|
}
|
|
671
717
|
this.#lastKeyEvent = { seq, time: now };
|
|
672
718
|
|
|
719
|
+
// Locked screen swallows everything except the passphrase entry.
|
|
720
|
+
if (this.#locked) {
|
|
721
|
+
this.#handleLockKey(ch, key);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
673
725
|
if (this.#paletteOpen) {
|
|
674
726
|
this.#handlePaletteKey(ch, key);
|
|
675
727
|
return;
|
|
@@ -730,6 +782,12 @@ export class UI extends EventEmitter {
|
|
|
730
782
|
// Any non-tab key resets tab cycling
|
|
731
783
|
this.#tabState = { suggestions: [], index: -1, original: '' };
|
|
732
784
|
|
|
785
|
+
// Alt+1..9 — switch chat buffer (multi-room)
|
|
786
|
+
if (key.meta && /^[1-9]$/.test(name)) {
|
|
787
|
+
this.emit('buffer-switch', Number(name) - 1);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
|
|
733
791
|
// Alt+Enter / Ctrl+J — insert a newline (reliable across terminals, unlike
|
|
734
792
|
// bare Shift+Enter which most terminals don't distinguish from Enter).
|
|
735
793
|
if (((name === 'return' || name === 'enter') && key.meta) || (key.ctrl && name === 'j')) {
|
|
@@ -833,6 +891,87 @@ export class UI extends EventEmitter {
|
|
|
833
891
|
}
|
|
834
892
|
}
|
|
835
893
|
|
|
894
|
+
// ── Screen lock ──────────────────────────────────────────────
|
|
895
|
+
// Fullscreen overlay that hides the chat and swallows all input until the
|
|
896
|
+
// session passphrase is re-entered. `verify` is a callback so the passphrase
|
|
897
|
+
// itself never lives in the UI layer.
|
|
898
|
+
showLock(verify) {
|
|
899
|
+
if (this.#locked || typeof verify !== 'function') {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
this.#locked = true;
|
|
903
|
+
this.#lockVerify = verify;
|
|
904
|
+
this.#lockInput = '';
|
|
905
|
+
this.#lockError = false;
|
|
906
|
+
this.#lockBox = blessed.box({
|
|
907
|
+
parent: this.#screen,
|
|
908
|
+
top: 0,
|
|
909
|
+
left: 0,
|
|
910
|
+
width: '100%',
|
|
911
|
+
height: '100%',
|
|
912
|
+
tags: true,
|
|
913
|
+
style: { bg: 'black', fg: 'white' },
|
|
914
|
+
});
|
|
915
|
+
this.#lockBox.setFront();
|
|
916
|
+
this.#renderLock();
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
get isLocked() {
|
|
920
|
+
return this.#locked;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
#renderLock() {
|
|
924
|
+
if (!this.#lockBox) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const dots = '●'.repeat(Math.min(this.#lockInput.length, 40));
|
|
928
|
+
const error = this.#lockError ? '{red-fg}Wrong passphrase — try again{/red-fg}' : '';
|
|
929
|
+
const pad = '\n'.repeat(Math.max(1, Math.floor((this.#screen.height - 8) / 2)));
|
|
930
|
+
this.#lockBox.setContent(
|
|
931
|
+
`${pad}{center}{bold}🔒 Session locked{/bold}{/center}\n` +
|
|
932
|
+
`{center}{#8888aa-fg}Messages keep arriving encrypted underneath{/#8888aa-fg}{/center}\n\n` +
|
|
933
|
+
`{center}Passphrase: ${dots}{inverse} {/inverse}{/center}\n` +
|
|
934
|
+
`{center}${error}{/center}`,
|
|
935
|
+
);
|
|
936
|
+
this.#screen.render();
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
#handleLockKey(ch, key) {
|
|
940
|
+
const name = key.name || '';
|
|
941
|
+
if (key.ctrl && name === 'c') {
|
|
942
|
+
this.emit('quit'); // locking is privacy, not a prison
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (name === 'return' || name === 'enter') {
|
|
946
|
+
if (this.#lockVerify(this.#lockInput)) {
|
|
947
|
+
this.#lockBox.destroy();
|
|
948
|
+
this.#lockBox = null;
|
|
949
|
+
this.#locked = false;
|
|
950
|
+
this.#lockVerify = null;
|
|
951
|
+
this.#lockInput = '';
|
|
952
|
+
this.#lockError = false;
|
|
953
|
+
this.#screen.render();
|
|
954
|
+
this.emit('unlocked');
|
|
955
|
+
} else {
|
|
956
|
+
this.#lockError = true;
|
|
957
|
+
this.#lockInput = '';
|
|
958
|
+
this.emit('lock-failed');
|
|
959
|
+
this.#renderLock();
|
|
960
|
+
}
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (name === 'backspace') {
|
|
964
|
+
this.#lockInput = this.#lockInput.slice(0, -1);
|
|
965
|
+
this.#renderLock();
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const code = ch ? ch.charCodeAt(0) : 0;
|
|
969
|
+
if (ch && ch.length <= 2 && !key.ctrl && !key.meta && code > 0x1f && code !== 0x7f) {
|
|
970
|
+
this.#lockInput += ch;
|
|
971
|
+
this.#renderLock();
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
836
975
|
// Bracketed-paste state machine. Returns true when the sequence is part of a
|
|
837
976
|
// paste (start / content / end) and must not be treated as normal keypresses.
|
|
838
977
|
#handlePaste(seq) {
|
|
@@ -870,9 +1009,10 @@ export class UI extends EventEmitter {
|
|
|
870
1009
|
}
|
|
871
1010
|
|
|
872
1011
|
#insertPaste(raw) {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1012
|
+
if (this.#locked) {
|
|
1013
|
+
return; // no pasting into a locked screen
|
|
1014
|
+
}
|
|
1015
|
+
const clean = cleanPaste(raw);
|
|
876
1016
|
if (!clean) {
|
|
877
1017
|
return;
|
|
878
1018
|
}
|
|
@@ -1260,12 +1400,29 @@ export class UI extends EventEmitter {
|
|
|
1260
1400
|
}
|
|
1261
1401
|
|
|
1262
1402
|
#statusContent() {
|
|
1263
|
-
|
|
1403
|
+
// Several buffers → an IRC-style bar with unread badges; one → plain room.
|
|
1404
|
+
let room;
|
|
1405
|
+
if (this.#bufferBar.length > 1) {
|
|
1406
|
+
room = this.#bufferBar
|
|
1407
|
+
.map((b, i) => {
|
|
1408
|
+
const lock = b.private ? '🔒' : '';
|
|
1409
|
+
const unread = b.unread > 0 ? `{yellow-fg}•${b.unread}{/yellow-fg}` : '';
|
|
1410
|
+
const label = `${i + 1}:${lock}${b.room}${unread}`;
|
|
1411
|
+
return b.active
|
|
1412
|
+
? `{inverse}{cyan-fg}[${label}]{/cyan-fg}{/inverse}`
|
|
1413
|
+
: `{cyan-fg}[${label}]{/cyan-fg}`;
|
|
1414
|
+
})
|
|
1415
|
+
.join(' ');
|
|
1416
|
+
} else {
|
|
1417
|
+
room = `{cyan-fg}#${this.#statusRoom}{/cyan-fg}`;
|
|
1418
|
+
}
|
|
1264
1419
|
const fp = this.#statusFingerprint
|
|
1265
1420
|
? ` {#8888aa-fg}🔑 ${this.#statusFingerprint}{/#8888aa-fg}`
|
|
1266
1421
|
: '';
|
|
1267
1422
|
const hint =
|
|
1268
|
-
|
|
1423
|
+
this.#bufferBar.length > 1
|
|
1424
|
+
? '{#7777aa-fg}Alt+1..9 buffers · Ctrl+K commands · /help{/#7777aa-fg}'
|
|
1425
|
+
: '{#7777aa-fg}Tab · Ctrl+K commands · Ctrl+E emoji · PgUp/PgDn scroll · /help · Ctrl+C quit{/#7777aa-fg}';
|
|
1269
1426
|
return ` ${room}${fp} {|} ${hint} `;
|
|
1270
1427
|
}
|
|
1271
1428
|
|
|
@@ -1287,6 +1444,102 @@ export class UI extends EventEmitter {
|
|
|
1287
1444
|
this.#updateStatusBar();
|
|
1288
1445
|
}
|
|
1289
1446
|
|
|
1447
|
+
// ── Buffers (multi-room, IRC style) ──────────────────────────
|
|
1448
|
+
// The active buffer lives in #lines + the chat log; inactive ones are plain
|
|
1449
|
+
// line arrays in #bufferLines. All add*() methods target the active buffer —
|
|
1450
|
+
// toBuffer() retargets them for one call without touching the screen.
|
|
1451
|
+
|
|
1452
|
+
get activeBuffer() {
|
|
1453
|
+
return this.#activeBuffer;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* Run `fn` with every add*() call landing in `room`'s stored buffer instead
|
|
1458
|
+
* of the screen. Uses proxies so the formatting pipeline (widths, colors,
|
|
1459
|
+
* grouping) is exactly the one the live log uses.
|
|
1460
|
+
*/
|
|
1461
|
+
toBuffer(room, fn) {
|
|
1462
|
+
if (!room || room === this.#activeBuffer) {
|
|
1463
|
+
return fn();
|
|
1464
|
+
}
|
|
1465
|
+
if (!this.#bufferLines.has(room)) {
|
|
1466
|
+
this.#bufferLines.set(room, []);
|
|
1467
|
+
}
|
|
1468
|
+
const liveLines = this.#lines;
|
|
1469
|
+
const liveSender = this.#lastSender;
|
|
1470
|
+
const liveLog = this.#chatLog;
|
|
1471
|
+
const liveScreen = this.#screen;
|
|
1472
|
+
this.#lines = this.#bufferLines.get(room);
|
|
1473
|
+
this.#lastSender = null;
|
|
1474
|
+
this.#redirecting = true;
|
|
1475
|
+
this.#chatLog = new Proxy(liveLog, {
|
|
1476
|
+
get: (t, p) => (p === 'log' ? () => {} : t[p]),
|
|
1477
|
+
});
|
|
1478
|
+
this.#screen = new Proxy(liveScreen, {
|
|
1479
|
+
get: (t, p) => {
|
|
1480
|
+
if (p === 'render') {
|
|
1481
|
+
return () => {};
|
|
1482
|
+
}
|
|
1483
|
+
const v = t[p];
|
|
1484
|
+
return typeof v === 'function' ? v.bind(t) : v;
|
|
1485
|
+
},
|
|
1486
|
+
});
|
|
1487
|
+
try {
|
|
1488
|
+
return fn();
|
|
1489
|
+
} finally {
|
|
1490
|
+
this.#lines = liveLines;
|
|
1491
|
+
this.#lastSender = liveSender;
|
|
1492
|
+
this.#chatLog = liveLog;
|
|
1493
|
+
this.#screen = liveScreen;
|
|
1494
|
+
this.#redirecting = false;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/** Bring `room`'s buffer on screen, storing the current one. */
|
|
1499
|
+
switchBuffer(room) {
|
|
1500
|
+
if (room === this.#activeBuffer) {
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
this.#bufferLines.set(this.#activeBuffer, this.#lines);
|
|
1504
|
+
this.#lines = this.#bufferLines.get(room) || [];
|
|
1505
|
+
this.#bufferLines.delete(room);
|
|
1506
|
+
this.#activeBuffer = room;
|
|
1507
|
+
this.#lastSender = null;
|
|
1508
|
+
this.#chatLog.setContent(this.#lines.join('\n'));
|
|
1509
|
+
this.#chatLog.setScrollPerc(100);
|
|
1510
|
+
this.setRoom(room);
|
|
1511
|
+
this.#screen.render();
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/** Forget every buffer and start fresh in `room` (reconnect / legacy switch). */
|
|
1515
|
+
resetBuffers(room) {
|
|
1516
|
+
this.#bufferLines.clear();
|
|
1517
|
+
this.#activeBuffer = room;
|
|
1518
|
+
this.#lines = [];
|
|
1519
|
+
this.#lastSender = null;
|
|
1520
|
+
this.#chatLog.setContent('');
|
|
1521
|
+
this.setRoom(room);
|
|
1522
|
+
this.#screen.render();
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
dropBuffer(room) {
|
|
1526
|
+
this.#bufferLines.delete(room);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
clearBuffer(room) {
|
|
1530
|
+
if (room === this.#activeBuffer) {
|
|
1531
|
+
this.clearChat();
|
|
1532
|
+
} else if (this.#bufferLines.has(room)) {
|
|
1533
|
+
this.#bufferLines.get(room).length = 0;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/** Status-bar buffer list: [{ room, active, unread, private }]. */
|
|
1538
|
+
setBufferBar(items) {
|
|
1539
|
+
this.#bufferBar = Array.isArray(items) ? items : [];
|
|
1540
|
+
this.#updateStatusBar();
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1290
1543
|
// Render a real image inline by briefly leaving the TUI (kitty/iTerm2). Safe
|
|
1291
1544
|
// best-effort: any keypress or a 30s timeout returns to the chat.
|
|
1292
1545
|
showRealImage(escapeSeq) {
|
|
@@ -1523,6 +1776,9 @@ export class UI extends EventEmitter {
|
|
|
1523
1776
|
// Count a fresh arrival while the user is reading history, and pulse the
|
|
1524
1777
|
// "new messages" pill so they know to page down.
|
|
1525
1778
|
#noteIncoming(important = false) {
|
|
1779
|
+
if (this.#redirecting) {
|
|
1780
|
+
return; // inactive buffer — its unread badge lives in the buffer bar
|
|
1781
|
+
}
|
|
1526
1782
|
if (this.#scrolledUp) {
|
|
1527
1783
|
this.#unseenCount++;
|
|
1528
1784
|
if (important) {
|
package/src/client/index.js
CHANGED
|
@@ -17,7 +17,9 @@ import { HistoryStore } from '../crypto/HistoryStore.js';
|
|
|
17
17
|
import { parseInvite } from '../shared/invite.js';
|
|
18
18
|
import { importBackup } from '../crypto/IdentityBackup.js';
|
|
19
19
|
import { questionHidden } from '../shared/prompt.js';
|
|
20
|
-
import { loadConfig, startupCommands } from '../shared/config.js';
|
|
20
|
+
import { loadConfig, hasConfigFile, startupCommands } from '../shared/config.js';
|
|
21
|
+
import { runOnboarding } from '../shared/onboarding.js';
|
|
22
|
+
import { loadLastSession, clearLastSession } from '../shared/lastSession.js';
|
|
21
23
|
import { randomTip } from '../shared/tips.js';
|
|
22
24
|
import { setTheme } from '../shared/themes.js';
|
|
23
25
|
import { Connection } from './Connection.js';
|
|
@@ -37,7 +39,31 @@ if (config.theme) {
|
|
|
37
39
|
// ── Prompt setup ────────────────────────────────────────────────
|
|
38
40
|
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
// ── First-run onboarding ────────────────────────────────────────
|
|
43
|
+
// Runs once (no config file yet) or on demand with --setup; --no-onboard
|
|
44
|
+
// skips it (scripts/CI). The wizard's answers double as this session's
|
|
45
|
+
// nickname/server, so nothing is asked twice.
|
|
46
|
+
const argvFlags = process.argv.slice(2);
|
|
47
|
+
const forceSetup = argvFlags.includes('--setup');
|
|
48
|
+
const skipOnboard = argvFlags.includes('--no-onboard') || !stdin.isTTY;
|
|
49
|
+
const freshStart = argvFlags.includes('--fresh');
|
|
50
|
+
|
|
51
|
+
// Last session (server + room) — restored unless --fresh wipes it.
|
|
52
|
+
let lastSession = null;
|
|
53
|
+
if (freshStart) {
|
|
54
|
+
clearLastSession();
|
|
55
|
+
} else {
|
|
56
|
+
lastSession = loadLastSession();
|
|
57
|
+
}
|
|
58
|
+
let onboarded = null;
|
|
59
|
+
if (forceSetup || (!hasConfigFile() && !skipOnboard)) {
|
|
60
|
+
onboarded = await runOnboarding(rl);
|
|
61
|
+
config.nickname = onboarded.nickname;
|
|
62
|
+
config.theme = onboarded.theme;
|
|
63
|
+
config.server = onboarded.server;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let nickname = onboarded?.nickname || '';
|
|
41
67
|
while (!nickname) {
|
|
42
68
|
const hint = config.nickname ? `(${config.nickname})` : '(a-z, 0-9, _, -)';
|
|
43
69
|
const raw = await rl.question(promptLabel(`Nickname ${promptDim(hint)}: `));
|
|
@@ -112,11 +138,19 @@ if (!restoredState?.keyManager) {
|
|
|
112
138
|
}
|
|
113
139
|
}
|
|
114
140
|
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
)
|
|
119
|
-
|
|
141
|
+
// Most recent wins: last real session > configured default > localhost.
|
|
142
|
+
const defaultServer = lastSession?.server || config.server || `localhost:${SERVER_PORT}`;
|
|
143
|
+
let serverAddr;
|
|
144
|
+
if (onboarded) {
|
|
145
|
+
// The wizard just asked — don't ask again this session.
|
|
146
|
+
serverAddr = onboarded.server;
|
|
147
|
+
} else {
|
|
148
|
+
const hint = lastSession?.server
|
|
149
|
+
? `(Enter = ${defaultServer}, your last session)`
|
|
150
|
+
: `(${defaultServer} or ciphermesh:// invite)`;
|
|
151
|
+
const serverInput = await rl.question(promptLabel(`Server ${promptDim(hint)}: `));
|
|
152
|
+
serverAddr = serverInput.trim() || defaultServer;
|
|
153
|
+
}
|
|
120
154
|
|
|
121
155
|
let wsUrl;
|
|
122
156
|
let inviteRoom = null;
|
|
@@ -131,6 +165,15 @@ if (invite) {
|
|
|
131
165
|
: `wss://${serverAddr}`;
|
|
132
166
|
}
|
|
133
167
|
|
|
168
|
+
// Rejoin the last room — only on the same server, and never a private one
|
|
169
|
+
// (those are not saved). An explicit invite room wins.
|
|
170
|
+
if (!inviteRoom && lastSession?.room && lastSession.room !== 'general') {
|
|
171
|
+
if (serverAddr === lastSession.server) {
|
|
172
|
+
inviteRoom = lastSession.room;
|
|
173
|
+
console.log(promptLabel(`Rejoining #${inviteRoom} ${promptDim('(--fresh starts clean)')}`));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
134
177
|
rl.close();
|
|
135
178
|
|
|
136
179
|
// ── Encrypted local history (opt-in, needs passphrase) ─────────
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import sodium from 'sodium-native';
|
|
2
|
+
|
|
3
|
+
// Domain-separation contexts — never reuse across protocols.
|
|
4
|
+
const ROOM_SALT_CONTEXT = 'ciphermesh/room-v1:';
|
|
5
|
+
const ROOM_AUTH_CONTEXT = 'ciphermesh/room-auth-v1';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Derive the secrets of a password-protected room.
|
|
9
|
+
*
|
|
10
|
+
* Argon2id(password, salt=BLAKE2b(context + roomName)) → 64 bytes, split into:
|
|
11
|
+
* - authSeed (32B) → deterministic Ed25519 keypair. The PUBLIC key is the
|
|
12
|
+
* verifier the server stores (in memory only); the secret key signs join
|
|
13
|
+
* challenges. The server never sees the password — and cracking the
|
|
14
|
+
* verifier costs a full Argon2id derivation per guess.
|
|
15
|
+
* - roomKey (32B) → symmetric content key layered UNDER the pairwise E2EE,
|
|
16
|
+
* so even a malicious relay that skips verification and injects a member
|
|
17
|
+
* cannot read the room without the password.
|
|
18
|
+
*
|
|
19
|
+
* Everyone with (roomName, password) derives the same secrets — nothing else
|
|
20
|
+
* needs to be exchanged.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} roomName - Normalized (lowercase) room name
|
|
23
|
+
* @param {string} password
|
|
24
|
+
* @returns {{ authPublicKey: Buffer, authSecretKey: Buffer, roomKey: Buffer }}
|
|
25
|
+
*/
|
|
26
|
+
export function deriveRoomSecrets(roomName, password) {
|
|
27
|
+
const salt = Buffer.alloc(sodium.crypto_pwhash_SALTBYTES);
|
|
28
|
+
sodium.crypto_generichash(salt, Buffer.from(ROOM_SALT_CONTEXT + roomName, 'utf-8'));
|
|
29
|
+
|
|
30
|
+
const seed = sodium.sodium_malloc(
|
|
31
|
+
sodium.crypto_sign_SEEDBYTES + sodium.crypto_secretbox_KEYBYTES,
|
|
32
|
+
);
|
|
33
|
+
sodium.crypto_pwhash(
|
|
34
|
+
seed,
|
|
35
|
+
Buffer.from(password, 'utf-8'),
|
|
36
|
+
salt,
|
|
37
|
+
sodium.crypto_pwhash_OPSLIMIT_MODERATE,
|
|
38
|
+
sodium.crypto_pwhash_MEMLIMIT_MODERATE,
|
|
39
|
+
sodium.crypto_pwhash_ALG_ARGON2ID13,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const authSeed = sodium.sodium_malloc(sodium.crypto_sign_SEEDBYTES);
|
|
43
|
+
seed.copy(authSeed, 0, 0, sodium.crypto_sign_SEEDBYTES);
|
|
44
|
+
const roomKey = sodium.sodium_malloc(sodium.crypto_secretbox_KEYBYTES);
|
|
45
|
+
seed.copy(roomKey, 0, sodium.crypto_sign_SEEDBYTES);
|
|
46
|
+
sodium.sodium_memzero(seed);
|
|
47
|
+
|
|
48
|
+
const authPublicKey = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
|
|
49
|
+
const authSecretKey = sodium.sodium_malloc(sodium.crypto_sign_SECRETKEYBYTES);
|
|
50
|
+
sodium.crypto_sign_seed_keypair(authPublicKey, authSecretKey, authSeed);
|
|
51
|
+
sodium.sodium_memzero(authSeed);
|
|
52
|
+
|
|
53
|
+
return { authPublicKey, authSecretKey, roomKey };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The exact bytes signed in the join challenge. Binds room + server nonce +
|
|
58
|
+
* the joiner's sessionId, so a captured signature can't be replayed for
|
|
59
|
+
* another session, room or challenge.
|
|
60
|
+
*/
|
|
61
|
+
function challengeMessage(room, nonceB64, sessionId) {
|
|
62
|
+
return Buffer.from(`${ROOM_AUTH_CONTEXT}:${room}:${nonceB64}:${sessionId}`, 'utf-8');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function signRoomChallenge(authSecretKey, room, nonceB64, sessionId) {
|
|
66
|
+
const signature = Buffer.alloc(sodium.crypto_sign_BYTES);
|
|
67
|
+
sodium.crypto_sign_detached(
|
|
68
|
+
signature,
|
|
69
|
+
challengeMessage(room, nonceB64, sessionId),
|
|
70
|
+
authSecretKey,
|
|
71
|
+
);
|
|
72
|
+
return signature;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function verifyRoomChallenge(authPublicKey, signature, room, nonceB64, sessionId) {
|
|
76
|
+
if (
|
|
77
|
+
!Buffer.isBuffer(authPublicKey) ||
|
|
78
|
+
authPublicKey.length !== sodium.crypto_sign_PUBLICKEYBYTES ||
|
|
79
|
+
!Buffer.isBuffer(signature) ||
|
|
80
|
+
signature.length !== sodium.crypto_sign_BYTES
|
|
81
|
+
) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return sodium.crypto_sign_verify_detached(
|
|
86
|
+
signature,
|
|
87
|
+
challengeMessage(room, nonceB64, sessionId),
|
|
88
|
+
authPublicKey,
|
|
89
|
+
);
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Room content layer ───────────────────────────────────────────
|
|
96
|
+
// Applied to the payload BEFORE the pairwise encryption (ratchet/sealed
|
|
97
|
+
// sender), which already pads the result — so no extra padding here.
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Wrap a payload string in the private-room symmetric layer.
|
|
101
|
+
* @returns {string} JSON `{ rk: 1, n, c }` to feed the pairwise encryption
|
|
102
|
+
*/
|
|
103
|
+
export function encryptRoomPayload(payloadStr, roomKey) {
|
|
104
|
+
const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES);
|
|
105
|
+
sodium.randombytes_buf(nonce);
|
|
106
|
+
const plaintext = Buffer.from(payloadStr, 'utf-8');
|
|
107
|
+
const ciphertext = Buffer.alloc(plaintext.length + sodium.crypto_secretbox_MACBYTES);
|
|
108
|
+
sodium.crypto_secretbox_easy(ciphertext, plaintext, nonce, roomKey);
|
|
109
|
+
sodium.sodium_memzero(plaintext);
|
|
110
|
+
return JSON.stringify({ rk: 1, n: nonce.toString('base64'), c: ciphertext.toString('base64') });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True if a decrypted payload object is a private-room wrapper. */
|
|
114
|
+
export function isRoomWrapped(data) {
|
|
115
|
+
return (
|
|
116
|
+
data !== null &&
|
|
117
|
+
typeof data === 'object' &&
|
|
118
|
+
data.rk === 1 &&
|
|
119
|
+
typeof data.n === 'string' &&
|
|
120
|
+
typeof data.c === 'string'
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Open the private-room layer. Returns the inner payload string, or null if
|
|
126
|
+
* the wrapper is malformed or the key doesn't match (wrong/stale room key).
|
|
127
|
+
*/
|
|
128
|
+
export function decryptRoomPayload(data, roomKey) {
|
|
129
|
+
if (!isRoomWrapped(data)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const nonce = Buffer.from(data.n, 'base64');
|
|
134
|
+
const ciphertext = Buffer.from(data.c, 'base64');
|
|
135
|
+
if (
|
|
136
|
+
nonce.length !== sodium.crypto_secretbox_NONCEBYTES ||
|
|
137
|
+
ciphertext.length <= sodium.crypto_secretbox_MACBYTES
|
|
138
|
+
) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const plaintext = Buffer.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES);
|
|
142
|
+
if (!sodium.crypto_secretbox_open_easy(plaintext, ciphertext, nonce, roomKey)) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return plaintext.toString('utf-8');
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Zero out room secrets when leaving a private room. */
|
|
152
|
+
export function freeRoomSecrets(secrets) {
|
|
153
|
+
if (!secrets) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (Buffer.isBuffer(secrets.authSecretKey)) {
|
|
157
|
+
sodium.sodium_memzero(secrets.authSecretKey);
|
|
158
|
+
}
|
|
159
|
+
if (Buffer.isBuffer(secrets.roomKey)) {
|
|
160
|
+
sodium.sodium_memzero(secrets.roomKey);
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/crypto/TrustStore.js
CHANGED
|
@@ -194,6 +194,53 @@ export class TrustStore {
|
|
|
194
194
|
return this.#store.get(nickname.toLowerCase()) || null;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
// ── Contacts (friendly aliases on top of trust records) ──────
|
|
198
|
+
// The alias lives on the trust record, so it survives restarts and rides
|
|
199
|
+
// along in the existing identity backup for free.
|
|
200
|
+
|
|
201
|
+
/** Set a friendly alias for an already-seen peer. False if peer unknown. */
|
|
202
|
+
setAlias(nickname, alias) {
|
|
203
|
+
const record = this.#store.get(nickname.toLowerCase());
|
|
204
|
+
if (!record) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
record.alias = String(alias).slice(0, 30);
|
|
208
|
+
this.#save();
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Remove a peer's alias. False if there was none. */
|
|
213
|
+
clearAlias(nickname) {
|
|
214
|
+
const record = this.#store.get(nickname.toLowerCase());
|
|
215
|
+
if (!record || !record.alias) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
delete record.alias;
|
|
219
|
+
this.#save();
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
getAlias(nickname) {
|
|
224
|
+
return this.#store.get(nickname.toLowerCase())?.alias || null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The contact book: aliased peers, or every known peer with `all`.
|
|
229
|
+
* Sorted by most recently seen.
|
|
230
|
+
*/
|
|
231
|
+
listContacts(all = false) {
|
|
232
|
+
return [...this.#store.entries()]
|
|
233
|
+
.filter(([, r]) => all || r.alias)
|
|
234
|
+
.map(([nickname, r]) => ({
|
|
235
|
+
nickname,
|
|
236
|
+
alias: r.alias || null,
|
|
237
|
+
verified: r.verified === true,
|
|
238
|
+
fingerprint: r.fingerprint,
|
|
239
|
+
lastSeen: r.lastSeen || 0,
|
|
240
|
+
}))
|
|
241
|
+
.sort((a, b) => b.lastSeen - a.lastSeen);
|
|
242
|
+
}
|
|
243
|
+
|
|
197
244
|
/** Export all trust records as a plain object (for identity backup). */
|
|
198
245
|
exportData() {
|
|
199
246
|
return Object.fromEntries(this.#store);
|
|
@@ -1368,7 +1368,15 @@ export class P2PChatController {
|
|
|
1368
1368
|
if (this.#pluginManager) {
|
|
1369
1369
|
const result = this.#pluginManager.handleCommand(cmd, parts.slice(1));
|
|
1370
1370
|
if (result) {
|
|
1371
|
-
|
|
1371
|
+
// Plugin API: `{ send }` goes to the room as a normal E2EE
|
|
1372
|
+
// message; `{ info }` or a plain string stays local.
|
|
1373
|
+
if (typeof result === 'object' && typeof result.send === 'string' && result.send) {
|
|
1374
|
+
this.#sendMessageToAll(result.send);
|
|
1375
|
+
} else if (typeof result === 'object' && typeof result.info === 'string') {
|
|
1376
|
+
this.#ui.addInfoMessage(result.info);
|
|
1377
|
+
} else if (typeof result === 'string') {
|
|
1378
|
+
this.#ui.addInfoMessage(result);
|
|
1379
|
+
}
|
|
1372
1380
|
break;
|
|
1373
1381
|
}
|
|
1374
1382
|
}
|