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
package/src/client/UI.js
CHANGED
|
@@ -19,10 +19,13 @@ 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 (password if private)'],
|
|
28
|
+
['/create', 'Create a private room with a password'],
|
|
26
29
|
['/rooms', 'List rooms'],
|
|
27
30
|
['/room', 'Show the current room'],
|
|
28
31
|
['/invite', 'Generate an invite with QR'],
|
|
@@ -54,6 +57,8 @@ const COMMAND_INFO = [
|
|
|
54
57
|
['/receipts', 'Read receipts'],
|
|
55
58
|
['/cover', 'Cover traffic (anti-metadata)'],
|
|
56
59
|
['/theme', 'Nick color theme'],
|
|
60
|
+
['/lock', 'Lock the screen (session passphrase to unlock)'],
|
|
61
|
+
['/autolock', 'Auto-lock on inactivity'],
|
|
57
62
|
['/panic', 'Wipe everything from disk and exit (duress)'],
|
|
58
63
|
['/kick', 'Kick a user (owner)'],
|
|
59
64
|
['/mute', 'Mute a user (owner)'],
|
|
@@ -83,6 +88,8 @@ export const COMMANDS = [
|
|
|
83
88
|
'/sound',
|
|
84
89
|
'/msg',
|
|
85
90
|
'/reply',
|
|
91
|
+
'/mentions',
|
|
92
|
+
'/contacts',
|
|
86
93
|
'/away',
|
|
87
94
|
'/back',
|
|
88
95
|
'/autoaway',
|
|
@@ -90,6 +97,7 @@ export const COMMANDS = [
|
|
|
90
97
|
'/notify',
|
|
91
98
|
'/dnd',
|
|
92
99
|
'/join',
|
|
100
|
+
'/create',
|
|
93
101
|
'/rooms',
|
|
94
102
|
'/room',
|
|
95
103
|
'/invite',
|
|
@@ -110,6 +118,8 @@ export const COMMANDS = [
|
|
|
110
118
|
'/receipts',
|
|
111
119
|
'/cover',
|
|
112
120
|
'/theme',
|
|
121
|
+
'/lock',
|
|
122
|
+
'/autolock',
|
|
113
123
|
'/panic',
|
|
114
124
|
'/kick',
|
|
115
125
|
'/mute',
|
|
@@ -302,6 +312,23 @@ export function renderMarkdown(text) {
|
|
|
302
312
|
return out.join('\n');
|
|
303
313
|
}
|
|
304
314
|
|
|
315
|
+
// Sanitizes pasted text while PRESERVING its line structure — the input box is
|
|
316
|
+
// multi-line and fenced code blocks render in markdown, so pasted code must
|
|
317
|
+
// keep its newlines. Normalizes CRLF/CR, turns tabs into spaces and strips the
|
|
318
|
+
// remaining control chars (incl. stray paste markers). Pure and exported for
|
|
319
|
+
// testing.
|
|
320
|
+
export function cleanPaste(raw) {
|
|
321
|
+
return (
|
|
322
|
+
raw
|
|
323
|
+
// eslint-disable-next-line no-control-regex
|
|
324
|
+
.replace(/\x1b\[20[01]~/g, '')
|
|
325
|
+
.replace(/\r\n?/g, '\n')
|
|
326
|
+
.replace(/\t/g, ' ')
|
|
327
|
+
// eslint-disable-next-line no-control-regex
|
|
328
|
+
.replace(/[\x00-\x09\x0b-\x1f\x7f]/g, '')
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
305
332
|
// Builds the rendered content of the (possibly multi-line) input box with an
|
|
306
333
|
// inverse cursor cell, windowed so the cursor line is always visible. Pure and
|
|
307
334
|
// exported for testing. Returns { content, height } (height includes borders).
|
|
@@ -466,6 +493,11 @@ export class UI extends EventEmitter {
|
|
|
466
493
|
#emojiPicker;
|
|
467
494
|
#emojiOpen;
|
|
468
495
|
#emojiQuery;
|
|
496
|
+
#locked;
|
|
497
|
+
#lockBox;
|
|
498
|
+
#lockInput;
|
|
499
|
+
#lockError;
|
|
500
|
+
#lockVerify;
|
|
469
501
|
|
|
470
502
|
constructor(nickname) {
|
|
471
503
|
super();
|
|
@@ -511,6 +543,10 @@ export class UI extends EventEmitter {
|
|
|
511
543
|
this.#paletteQuery = '';
|
|
512
544
|
this.#emojiOpen = false;
|
|
513
545
|
this.#emojiQuery = '';
|
|
546
|
+
this.#locked = false;
|
|
547
|
+
this.#lockInput = '';
|
|
548
|
+
this.#lockError = false;
|
|
549
|
+
this.#lockVerify = null;
|
|
514
550
|
|
|
515
551
|
// blessed's terminfo parser can't compile the modern Setulc (underline
|
|
516
552
|
// colour) capability that terminals like ghostty ship, so it dumps a
|
|
@@ -670,6 +706,12 @@ export class UI extends EventEmitter {
|
|
|
670
706
|
}
|
|
671
707
|
this.#lastKeyEvent = { seq, time: now };
|
|
672
708
|
|
|
709
|
+
// Locked screen swallows everything except the passphrase entry.
|
|
710
|
+
if (this.#locked) {
|
|
711
|
+
this.#handleLockKey(ch, key);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
673
715
|
if (this.#paletteOpen) {
|
|
674
716
|
this.#handlePaletteKey(ch, key);
|
|
675
717
|
return;
|
|
@@ -833,6 +875,87 @@ export class UI extends EventEmitter {
|
|
|
833
875
|
}
|
|
834
876
|
}
|
|
835
877
|
|
|
878
|
+
// ── Screen lock ──────────────────────────────────────────────
|
|
879
|
+
// Fullscreen overlay that hides the chat and swallows all input until the
|
|
880
|
+
// session passphrase is re-entered. `verify` is a callback so the passphrase
|
|
881
|
+
// itself never lives in the UI layer.
|
|
882
|
+
showLock(verify) {
|
|
883
|
+
if (this.#locked || typeof verify !== 'function') {
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
this.#locked = true;
|
|
887
|
+
this.#lockVerify = verify;
|
|
888
|
+
this.#lockInput = '';
|
|
889
|
+
this.#lockError = false;
|
|
890
|
+
this.#lockBox = blessed.box({
|
|
891
|
+
parent: this.#screen,
|
|
892
|
+
top: 0,
|
|
893
|
+
left: 0,
|
|
894
|
+
width: '100%',
|
|
895
|
+
height: '100%',
|
|
896
|
+
tags: true,
|
|
897
|
+
style: { bg: 'black', fg: 'white' },
|
|
898
|
+
});
|
|
899
|
+
this.#lockBox.setFront();
|
|
900
|
+
this.#renderLock();
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
get isLocked() {
|
|
904
|
+
return this.#locked;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
#renderLock() {
|
|
908
|
+
if (!this.#lockBox) {
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const dots = '●'.repeat(Math.min(this.#lockInput.length, 40));
|
|
912
|
+
const error = this.#lockError ? '{red-fg}Wrong passphrase — try again{/red-fg}' : '';
|
|
913
|
+
const pad = '\n'.repeat(Math.max(1, Math.floor((this.#screen.height - 8) / 2)));
|
|
914
|
+
this.#lockBox.setContent(
|
|
915
|
+
`${pad}{center}{bold}🔒 Session locked{/bold}{/center}\n` +
|
|
916
|
+
`{center}{#8888aa-fg}Messages keep arriving encrypted underneath{/#8888aa-fg}{/center}\n\n` +
|
|
917
|
+
`{center}Passphrase: ${dots}{inverse} {/inverse}{/center}\n` +
|
|
918
|
+
`{center}${error}{/center}`,
|
|
919
|
+
);
|
|
920
|
+
this.#screen.render();
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
#handleLockKey(ch, key) {
|
|
924
|
+
const name = key.name || '';
|
|
925
|
+
if (key.ctrl && name === 'c') {
|
|
926
|
+
this.emit('quit'); // locking is privacy, not a prison
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (name === 'return' || name === 'enter') {
|
|
930
|
+
if (this.#lockVerify(this.#lockInput)) {
|
|
931
|
+
this.#lockBox.destroy();
|
|
932
|
+
this.#lockBox = null;
|
|
933
|
+
this.#locked = false;
|
|
934
|
+
this.#lockVerify = null;
|
|
935
|
+
this.#lockInput = '';
|
|
936
|
+
this.#lockError = false;
|
|
937
|
+
this.#screen.render();
|
|
938
|
+
this.emit('unlocked');
|
|
939
|
+
} else {
|
|
940
|
+
this.#lockError = true;
|
|
941
|
+
this.#lockInput = '';
|
|
942
|
+
this.emit('lock-failed');
|
|
943
|
+
this.#renderLock();
|
|
944
|
+
}
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (name === 'backspace') {
|
|
948
|
+
this.#lockInput = this.#lockInput.slice(0, -1);
|
|
949
|
+
this.#renderLock();
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const code = ch ? ch.charCodeAt(0) : 0;
|
|
953
|
+
if (ch && ch.length <= 2 && !key.ctrl && !key.meta && code > 0x1f && code !== 0x7f) {
|
|
954
|
+
this.#lockInput += ch;
|
|
955
|
+
this.#renderLock();
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
836
959
|
// Bracketed-paste state machine. Returns true when the sequence is part of a
|
|
837
960
|
// paste (start / content / end) and must not be treated as normal keypresses.
|
|
838
961
|
#handlePaste(seq) {
|
|
@@ -870,9 +993,10 @@ export class UI extends EventEmitter {
|
|
|
870
993
|
}
|
|
871
994
|
|
|
872
995
|
#insertPaste(raw) {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
996
|
+
if (this.#locked) {
|
|
997
|
+
return; // no pasting into a locked screen
|
|
998
|
+
}
|
|
999
|
+
const clean = cleanPaste(raw);
|
|
876
1000
|
if (!clean) {
|
|
877
1001
|
return;
|
|
878
1002
|
}
|
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
|
}
|
package/src/protocol/messages.js
CHANGED
|
@@ -14,6 +14,8 @@ export const MSG = {
|
|
|
14
14
|
ROOM_CHANGED: 'room_changed',
|
|
15
15
|
LIST_ROOMS: 'list_rooms',
|
|
16
16
|
ROOM_LIST: 'room_list',
|
|
17
|
+
ROOM_CHALLENGE: 'room_challenge',
|
|
18
|
+
ROOM_AUTH: 'room_auth',
|
|
17
19
|
KICK_PEER: 'kick_peer',
|
|
18
20
|
MUTE_PEER: 'mute_peer',
|
|
19
21
|
BAN_PEER: 'ban_peer',
|
|
@@ -30,6 +32,8 @@ export const ERR = {
|
|
|
30
32
|
PEER_NOT_FOUND: 'PEER_NOT_FOUND',
|
|
31
33
|
RATE_LIMITED: 'RATE_LIMITED',
|
|
32
34
|
PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
|
|
35
|
+
ROOM_AUTH_FAILED: 'ROOM_AUTH_FAILED',
|
|
36
|
+
ROOM_EXISTS: 'ROOM_EXISTS',
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
// ── Factory helpers ────────────────────────────────────────────
|
|
@@ -108,12 +112,35 @@ export function createPong() {
|
|
|
108
112
|
return base(MSG.PONG);
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
// roomAuthPk (optional): Ed25519 verifier public key — present only when
|
|
116
|
+
// CREATING a private room; the server stores it in memory as the room's
|
|
117
|
+
// password verifier (see crypto/RoomKey.js).
|
|
118
|
+
export function createChangeRoom(room, roomAuthPkB64 = null) {
|
|
119
|
+
const msg = { ...base(MSG.CHANGE_ROOM), room };
|
|
120
|
+
if (roomAuthPkB64) {
|
|
121
|
+
msg.roomAuthPk = roomAuthPkB64;
|
|
122
|
+
}
|
|
123
|
+
return msg;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createRoomChanged(room, peers, isPrivate = false) {
|
|
127
|
+
const msg = { ...base(MSG.ROOM_CHANGED), room, peers };
|
|
128
|
+
if (isPrivate) {
|
|
129
|
+
msg.private = true;
|
|
130
|
+
}
|
|
131
|
+
return msg;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Server → client: the target room is private; prove password knowledge by
|
|
135
|
+
// signing this nonce. The password itself never travels.
|
|
136
|
+
export function createRoomChallenge(room, nonceB64) {
|
|
137
|
+
return { ...base(MSG.ROOM_CHALLENGE), room, nonce: nonceB64 };
|
|
113
138
|
}
|
|
114
139
|
|
|
115
|
-
|
|
116
|
-
|
|
140
|
+
// Client → server: Ed25519 signature over (room, nonce, sessionId) with the
|
|
141
|
+
// password-derived key (see crypto/RoomKey.js#signRoomChallenge).
|
|
142
|
+
export function createRoomAuth(room, nonceB64, signatureB64) {
|
|
143
|
+
return { ...base(MSG.ROOM_AUTH), room, nonce: nonceB64, signature: signatureB64 };
|
|
117
144
|
}
|
|
118
145
|
|
|
119
146
|
export function createListRooms() {
|
|
@@ -3,6 +3,9 @@ import {
|
|
|
3
3
|
MAX_NICKNAME_LENGTH,
|
|
4
4
|
MAX_PAYLOAD_SIZE,
|
|
5
5
|
PUBLIC_KEY_SIZE,
|
|
6
|
+
ROOM_AUTH_PK_SIZE,
|
|
7
|
+
ROOM_AUTH_SIG_SIZE,
|
|
8
|
+
ROOM_CHALLENGE_NONCE_SIZE,
|
|
6
9
|
} from '../shared/constants.js';
|
|
7
10
|
|
|
8
11
|
// ── Helpers ────────────────────────────────────────────────────
|
|
@@ -115,7 +118,25 @@ export function validateChangeRoom(msg) {
|
|
|
115
118
|
if (!/^[a-zA-Z0-9_-]+$/.test(msg.room)) {
|
|
116
119
|
return { valid: false, error: 'Room name must be alphanumeric, dash or underscore' };
|
|
117
120
|
}
|
|
118
|
-
|
|
121
|
+
// Optional: Ed25519 verifier public key, present only when creating a
|
|
122
|
+
// private room.
|
|
123
|
+
if (msg.roomAuthPk !== undefined && !isValidBase64(msg.roomAuthPk, ROOM_AUTH_PK_SIZE)) {
|
|
124
|
+
return { valid: false, error: 'Invalid room verifier key' };
|
|
125
|
+
}
|
|
126
|
+
return { valid: true, room: msg.room.toLowerCase(), roomAuthPk: msg.roomAuthPk || null };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function validateRoomAuth(msg) {
|
|
130
|
+
if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
|
|
131
|
+
return { valid: false, error: 'Invalid room name (1-30 chars)' };
|
|
132
|
+
}
|
|
133
|
+
if (!isValidBase64(msg.nonce, ROOM_CHALLENGE_NONCE_SIZE)) {
|
|
134
|
+
return { valid: false, error: 'Invalid challenge nonce' };
|
|
135
|
+
}
|
|
136
|
+
if (!isValidBase64(msg.signature, ROOM_AUTH_SIG_SIZE)) {
|
|
137
|
+
return { valid: false, error: 'Invalid challenge signature' };
|
|
138
|
+
}
|
|
139
|
+
return { valid: true, room: msg.room.toLowerCase(), nonce: msg.nonce, signature: msg.signature };
|
|
119
140
|
}
|
|
120
141
|
|
|
121
142
|
export function validateListRooms() {
|
|
@@ -11,6 +11,7 @@ export class SessionManager {
|
|
|
11
11
|
#roomOwners; // Map<roomName, sessionId>
|
|
12
12
|
#muteState; // Map<sessionId, { until: timestamp }>
|
|
13
13
|
#banList; // Map<roomName, Set<nickname_lower>>
|
|
14
|
+
#roomMeta; // Map<roomName, { authPk: base64 }> — private-room verifiers (memory only)
|
|
14
15
|
|
|
15
16
|
constructor() {
|
|
16
17
|
this.#sessions = new Map();
|
|
@@ -20,6 +21,7 @@ export class SessionManager {
|
|
|
20
21
|
this.#roomOwners = new Map();
|
|
21
22
|
this.#muteState = new Map();
|
|
22
23
|
this.#banList = new Map();
|
|
24
|
+
this.#roomMeta = new Map();
|
|
23
25
|
// Ensure default room exists
|
|
24
26
|
this.#rooms.set('general', new Set());
|
|
25
27
|
}
|
|
@@ -167,9 +169,12 @@ export class SessionManager {
|
|
|
167
169
|
if (members.size === 0 && room !== 'general') {
|
|
168
170
|
// Empty non-general room — drop it and all associated moderation state
|
|
169
171
|
// (otherwise a recreated room stays owner-less and unmoderatable).
|
|
172
|
+
// The private-room verifier dies here too: the room and its password
|
|
173
|
+
// exist only while someone is inside.
|
|
170
174
|
this.#rooms.delete(room);
|
|
171
175
|
this.#roomOwners.delete(room);
|
|
172
176
|
this.#banList.delete(room);
|
|
177
|
+
this.#roomMeta.delete(room);
|
|
173
178
|
} else if (this.#roomOwners.get(room) === sessionId) {
|
|
174
179
|
// Owner left but room still has members — transfer ownership so the
|
|
175
180
|
// room keeps a moderator instead of becoming owner-less.
|
|
@@ -207,16 +212,35 @@ export class SessionManager {
|
|
|
207
212
|
const rooms = [];
|
|
208
213
|
for (const [name, members] of this.#rooms) {
|
|
209
214
|
if (members.size > 0) {
|
|
210
|
-
rooms.push({ name, memberCount: members.size });
|
|
215
|
+
rooms.push({ name, memberCount: members.size, private: this.#roomMeta.has(name) });
|
|
211
216
|
}
|
|
212
217
|
}
|
|
213
218
|
// Always include 'general' even if empty
|
|
214
219
|
if (!rooms.some((r) => r.name === 'general')) {
|
|
215
|
-
rooms.unshift({ name: 'general', memberCount: 0 });
|
|
220
|
+
rooms.unshift({ name: 'general', memberCount: 0, private: false });
|
|
216
221
|
}
|
|
217
222
|
return rooms.sort((a, b) => a.name.localeCompare(b.name));
|
|
218
223
|
}
|
|
219
224
|
|
|
225
|
+
// ── Private rooms ────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
roomHasMembers(room) {
|
|
228
|
+
const members = this.#rooms.get(room);
|
|
229
|
+
return !!members && members.size > 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
setRoomPrivate(room, authPkB64) {
|
|
233
|
+
this.#roomMeta.set(room, { authPk: authPkB64 });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
isRoomPrivate(room) {
|
|
237
|
+
return this.#roomMeta.has(room);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
getRoomAuthPk(room) {
|
|
241
|
+
return this.#roomMeta.get(room)?.authPk || null;
|
|
242
|
+
}
|
|
243
|
+
|
|
220
244
|
getSessionRoom(sessionId) {
|
|
221
245
|
const session = this.#sessions.get(sessionId);
|
|
222
246
|
return session?.room || null;
|