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/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
- // Strip any stray paste markers and control chars (a message is one line).
874
- // eslint-disable-next-line no-control-regex
875
- const clean = raw.replace(/\x1b\[20[01]~/g, '').replace(/[\x00-\x1f\x7f]/g, '');
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
- const room = `{cyan-fg}#${this.#statusRoom}{/cyan-fg}`;
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
- '{#7777aa-fg}Tab · Ctrl+K commands · Ctrl+E emoji · PgUp/PgDn scroll · /help · Ctrl+C quit{/#7777aa-fg}';
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) {
@@ -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
- let nickname = '';
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
- const defaultServer = config.server || `localhost:${SERVER_PORT}`;
116
- const serverInput = await rl.question(
117
- promptLabel(`Server ${promptDim(`(${defaultServer} or ciphermesh:// invite)`)}: `),
118
- );
119
- const serverAddr = serverInput.trim() || defaultServer;
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
+ }
@@ -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
- this.#ui.addInfoMessage(result);
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
  }