ciphermesh 1.2.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -7
- package/README.pt-BR.md +34 -7
- package/bin/ciphermesh.js +5 -0
- package/docs/ARCHITECTURE.md +58 -10
- package/docs/PLUGINS.md +69 -0
- package/examples/plugins/poll.js +28 -0
- package/examples/plugins/roll.js +24 -0
- package/package.json +2 -1
- package/src/client/ChatController.js +429 -14
- package/src/client/UI.js +128 -4
- package/src/client/index.js +50 -7
- package/src/crypto/RoomKey.js +162 -0
- package/src/crypto/TrustStore.js +47 -0
- package/src/p2p/P2PChatController.js +9 -1
- package/src/protocol/messages.js +38 -4
- package/src/protocol/validators.js +31 -25
- package/src/server/MessageRouter.js +3 -1
- package/src/server/SessionManager.js +26 -2
- package/src/server/WebSocketServer.js +143 -3
- package/src/shared/AuditLog.js +3 -0
- package/src/shared/PluginManager.js +9 -6
- package/src/shared/config.js +27 -2
- package/src/shared/constants.js +9 -1
- package/src/shared/lastSession.js +55 -0
- package/src/shared/onboarding.js +111 -0
|
@@ -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;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createServer as createHttpsServer } from 'node:https';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { WebSocketServer as WSServer } from 'ws';
|
|
3
4
|
import { createLogger } from '../shared/logger.js';
|
|
4
5
|
import {
|
|
@@ -8,6 +9,10 @@ import {
|
|
|
8
9
|
MAX_CONNECTIONS_PER_IP,
|
|
9
10
|
JOIN_TIMEOUT_MS,
|
|
10
11
|
MESSAGE_RATE_LIMIT_PER_SECOND,
|
|
12
|
+
ROOM_CHALLENGE_NONCE_SIZE,
|
|
13
|
+
ROOM_CHALLENGE_TTL_MS,
|
|
14
|
+
ROOM_AUTH_MAX_FAILS,
|
|
15
|
+
ROOM_AUTH_FAIL_WINDOW_MS,
|
|
11
16
|
} from '../shared/constants.js';
|
|
12
17
|
import {
|
|
13
18
|
MSG,
|
|
@@ -16,6 +21,7 @@ import {
|
|
|
16
21
|
createPeerLeft,
|
|
17
22
|
createPeerKeyUpdated,
|
|
18
23
|
createRoomChanged,
|
|
24
|
+
createRoomChallenge,
|
|
19
25
|
createRoomList,
|
|
20
26
|
createPeerKicked,
|
|
21
27
|
createPeerMuted,
|
|
@@ -28,10 +34,12 @@ import {
|
|
|
28
34
|
validateEncryptedMessage,
|
|
29
35
|
validateKeyUpdate,
|
|
30
36
|
validateChangeRoom,
|
|
37
|
+
validateRoomAuth,
|
|
31
38
|
validateKickPeer,
|
|
32
39
|
validateMutePeer,
|
|
33
40
|
validateBanPeer,
|
|
34
41
|
} from '../protocol/validators.js';
|
|
42
|
+
import { verifyRoomChallenge } from '../crypto/RoomKey.js';
|
|
35
43
|
|
|
36
44
|
const log = createLogger('ws-server');
|
|
37
45
|
|
|
@@ -188,6 +196,10 @@ export class SecureWSServer {
|
|
|
188
196
|
this.#handleChangeRoom(ws, msg);
|
|
189
197
|
break;
|
|
190
198
|
|
|
199
|
+
case MSG.ROOM_AUTH:
|
|
200
|
+
this.#handleRoomAuth(ws, msg);
|
|
201
|
+
break;
|
|
202
|
+
|
|
191
203
|
case MSG.LIST_ROOMS:
|
|
192
204
|
this.#handleListRooms(ws);
|
|
193
205
|
break;
|
|
@@ -293,8 +305,11 @@ export class SecureWSServer {
|
|
|
293
305
|
return;
|
|
294
306
|
}
|
|
295
307
|
|
|
296
|
-
//
|
|
297
|
-
|
|
308
|
+
// Sealed sender: the relay routes purely by `to`. It deliberately does NOT
|
|
309
|
+
// learn, stamp, store, or log who sent this — the sender's identity is
|
|
310
|
+
// sealed inside the envelope for the recipient only. Strip any stray `from`
|
|
311
|
+
// a client might set so it can never be forwarded.
|
|
312
|
+
delete msg.from;
|
|
298
313
|
|
|
299
314
|
this.#messageRouter.route(ws.sessionId, msg);
|
|
300
315
|
}
|
|
@@ -350,6 +365,41 @@ export class SecureWSServer {
|
|
|
350
365
|
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
|
|
351
366
|
return;
|
|
352
367
|
}
|
|
368
|
+
|
|
369
|
+
// Creating a private room: register the password verifier, but only for
|
|
370
|
+
// a room that doesn't exist yet (rooms die when the last member leaves).
|
|
371
|
+
if (validation.roomAuthPk) {
|
|
372
|
+
if (validation.room === 'general' || this.#sessionManager.roomHasMembers(validation.room)) {
|
|
373
|
+
ws.send(
|
|
374
|
+
JSON.stringify(
|
|
375
|
+
createError(ERR.ROOM_EXISTS, 'Room already exists — join it with /join instead'),
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
|
|
381
|
+
if (!result) {
|
|
382
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
this.#sessionManager.setRoomPrivate(validation.room, validation.roomAuthPk);
|
|
386
|
+
this.#finishRoomSwitch(ws, session, result);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Joining a private room: don't switch yet — issue a challenge the client
|
|
391
|
+
// must sign with the password-derived key (see #handleRoomAuth).
|
|
392
|
+
if (this.#sessionManager.isRoomPrivate(validation.room)) {
|
|
393
|
+
const nonce = randomBytes(ROOM_CHALLENGE_NONCE_SIZE).toString('base64');
|
|
394
|
+
ws.roomChallenge = {
|
|
395
|
+
room: validation.room,
|
|
396
|
+
nonce,
|
|
397
|
+
expiresAt: Date.now() + ROOM_CHALLENGE_TTL_MS,
|
|
398
|
+
};
|
|
399
|
+
ws.send(JSON.stringify(createRoomChallenge(validation.room, nonce)));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
353
403
|
const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
|
|
354
404
|
if (!result) {
|
|
355
405
|
// Already in this room
|
|
@@ -357,6 +407,92 @@ export class SecureWSServer {
|
|
|
357
407
|
return;
|
|
358
408
|
}
|
|
359
409
|
|
|
410
|
+
this.#finishRoomSwitch(ws, session, result);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#handleRoomAuth(ws, msg) {
|
|
414
|
+
if (!ws.hasJoined || !ws.sessionId) {
|
|
415
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const validation = validateRoomAuth(msg);
|
|
420
|
+
if (!validation.valid) {
|
|
421
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Throttle wrong-password attempts per connection.
|
|
426
|
+
const now = Date.now();
|
|
427
|
+
if (
|
|
428
|
+
!ws.roomAuthFailWindowStart ||
|
|
429
|
+
now - ws.roomAuthFailWindowStart >= ROOM_AUTH_FAIL_WINDOW_MS
|
|
430
|
+
) {
|
|
431
|
+
ws.roomAuthFailWindowStart = now;
|
|
432
|
+
ws.roomAuthFails = 0;
|
|
433
|
+
}
|
|
434
|
+
if (ws.roomAuthFails >= ROOM_AUTH_MAX_FAILS) {
|
|
435
|
+
ws.send(
|
|
436
|
+
JSON.stringify(createError(ERR.RATE_LIMITED, 'Too many failed attempts — wait a minute')),
|
|
437
|
+
);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const challenge = ws.roomChallenge;
|
|
442
|
+
if (
|
|
443
|
+
!challenge ||
|
|
444
|
+
challenge.room !== validation.room ||
|
|
445
|
+
challenge.nonce !== validation.nonce ||
|
|
446
|
+
now > challenge.expiresAt
|
|
447
|
+
) {
|
|
448
|
+
ws.send(JSON.stringify(createError(ERR.ROOM_AUTH_FAILED, 'Challenge expired — /join again')));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
ws.roomChallenge = null;
|
|
452
|
+
|
|
453
|
+
const authPkB64 = this.#sessionManager.getRoomAuthPk(validation.room);
|
|
454
|
+
if (!authPkB64) {
|
|
455
|
+
// Room emptied (and died) between challenge and answer.
|
|
456
|
+
ws.send(
|
|
457
|
+
JSON.stringify(
|
|
458
|
+
createError(ERR.ROOM_AUTH_FAILED, 'Room no longer exists — /join again to create it'),
|
|
459
|
+
),
|
|
460
|
+
);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const ok = verifyRoomChallenge(
|
|
465
|
+
Buffer.from(authPkB64, 'base64'),
|
|
466
|
+
Buffer.from(validation.signature, 'base64'),
|
|
467
|
+
validation.room,
|
|
468
|
+
validation.nonce,
|
|
469
|
+
ws.sessionId,
|
|
470
|
+
);
|
|
471
|
+
if (!ok) {
|
|
472
|
+
ws.roomAuthFails++;
|
|
473
|
+
ws.send(JSON.stringify(createError(ERR.ROOM_AUTH_FAILED, 'Wrong room password')));
|
|
474
|
+
log.warn(`Failed room auth for ${validation.room} (${ws.sessionId.slice(0, 8)})`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const session = this.#sessionManager.getSession(ws.sessionId);
|
|
479
|
+
if (this.#sessionManager.isBanned(validation.room, session.nickname)) {
|
|
480
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
|
|
485
|
+
if (!result) {
|
|
486
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
this.#finishRoomSwitch(ws, session, result);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Shared tail of a successful room switch: notify both rooms and send the
|
|
494
|
+
// ROOM_CHANGED (with the private flag) to the mover.
|
|
495
|
+
#finishRoomSwitch(ws, session, result) {
|
|
360
496
|
// Notify old room that peer left
|
|
361
497
|
this.#sessionManager.broadcastToRoom(
|
|
362
498
|
result.oldRoom,
|
|
@@ -377,7 +513,11 @@ export class SecureWSServer {
|
|
|
377
513
|
|
|
378
514
|
// Send new room info to the client
|
|
379
515
|
const newPeers = this.#sessionManager.getRoomPeers(result.newRoom, ws.sessionId);
|
|
380
|
-
const roomChanged = createRoomChanged(
|
|
516
|
+
const roomChanged = createRoomChanged(
|
|
517
|
+
result.newRoom,
|
|
518
|
+
newPeers,
|
|
519
|
+
this.#sessionManager.isRoomPrivate(result.newRoom),
|
|
520
|
+
);
|
|
381
521
|
const newOwnerSid = this.#sessionManager.getRoomOwner(result.newRoom);
|
|
382
522
|
if (newOwnerSid) {
|
|
383
523
|
const ownerSess = this.#sessionManager.getSession(newOwnerSid);
|
package/src/shared/AuditLog.js
CHANGED
|
@@ -26,6 +26,9 @@ export const AuditEvent = {
|
|
|
26
26
|
ADMIN_KICK: 'ADMIN_KICK',
|
|
27
27
|
ADMIN_MUTE: 'ADMIN_MUTE',
|
|
28
28
|
ADMIN_BAN: 'ADMIN_BAN',
|
|
29
|
+
SCREEN_LOCKED: 'SCREEN_LOCKED',
|
|
30
|
+
SCREEN_UNLOCKED: 'SCREEN_UNLOCKED',
|
|
31
|
+
SCREEN_UNLOCK_FAILED: 'SCREEN_UNLOCK_FAILED',
|
|
29
32
|
};
|
|
30
33
|
|
|
31
34
|
export class AuditLog {
|
|
@@ -4,7 +4,10 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Resolved lazily so tests (and future flags) can point HOME elsewhere.
|
|
8
|
+
export function pluginDir() {
|
|
9
|
+
return join(homedir(), '.ciphermesh', 'plugins');
|
|
10
|
+
}
|
|
8
11
|
|
|
9
12
|
export class PluginManager {
|
|
10
13
|
#plugins; // Map<name, module>
|
|
@@ -15,15 +18,15 @@ export class PluginManager {
|
|
|
15
18
|
this.#commands = new Map();
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
async loadAll() {
|
|
19
|
-
if (!existsSync(
|
|
20
|
-
mkdirSync(
|
|
21
|
+
async loadAll(dir = pluginDir()) {
|
|
22
|
+
if (!existsSync(dir)) {
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
21
24
|
return;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
let files;
|
|
25
28
|
try {
|
|
26
|
-
files = await readdir(
|
|
29
|
+
files = await readdir(dir);
|
|
27
30
|
} catch {
|
|
28
31
|
return;
|
|
29
32
|
}
|
|
@@ -32,7 +35,7 @@ export class PluginManager {
|
|
|
32
35
|
|
|
33
36
|
for (const file of jsFiles) {
|
|
34
37
|
try {
|
|
35
|
-
const filePath = join(
|
|
38
|
+
const filePath = join(dir, file);
|
|
36
39
|
const fileUrl = pathToFileURL(filePath).href;
|
|
37
40
|
const mod = await import(fileUrl);
|
|
38
41
|
const plugin = mod.default || mod;
|
package/src/shared/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
4
|
|
|
5
5
|
// Optional user config at ~/.ciphermesh/config.json. Everything is a default the
|
|
6
6
|
// user can still override at the prompt or with a slash-command. Unknown keys
|
|
@@ -15,6 +15,7 @@ const ALLOWED = [
|
|
|
15
15
|
'deniable',
|
|
16
16
|
'theme',
|
|
17
17
|
'autoAway',
|
|
18
|
+
'autoLock',
|
|
18
19
|
'dnd',
|
|
19
20
|
];
|
|
20
21
|
|
|
@@ -51,6 +52,27 @@ export function loadConfig(path = configPath()) {
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/** True if a config file already exists (used to detect the first run). */
|
|
56
|
+
export function hasConfigFile(path = configPath()) {
|
|
57
|
+
return existsSync(path);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Persist config (whitelisted keys only, merged over what's on disk so a
|
|
62
|
+
* partial save never wipes hand-edited settings). Returns what was written.
|
|
63
|
+
*/
|
|
64
|
+
export function saveConfig(cfg, path = configPath()) {
|
|
65
|
+
const merged = { ...loadConfig(path) };
|
|
66
|
+
for (const k of ALLOWED) {
|
|
67
|
+
if (cfg[k] !== undefined) {
|
|
68
|
+
merged[k] = cfg[k];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
72
|
+
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
|
|
73
|
+
return merged;
|
|
74
|
+
}
|
|
75
|
+
|
|
54
76
|
/**
|
|
55
77
|
* Translate config toggles into the slash-commands that apply them, so startup
|
|
56
78
|
* reuses the exact command handlers (no duplicated logic). Pure — testable.
|
|
@@ -81,6 +103,9 @@ export function startupCommands(config) {
|
|
|
81
103
|
if (Number.isInteger(config.autoAway) && config.autoAway > 0) {
|
|
82
104
|
cmds.push(`/autoaway ${config.autoAway}`);
|
|
83
105
|
}
|
|
106
|
+
if (Number.isInteger(config.autoLock) && config.autoLock > 0) {
|
|
107
|
+
cmds.push(`/autolock ${config.autoLock}`);
|
|
108
|
+
}
|
|
84
109
|
if (config.dnd === 'on' || config.dnd === 'mentions') {
|
|
85
110
|
cmds.push(`/dnd ${config.dnd}`);
|
|
86
111
|
} else if (typeof config.dnd === 'string' && /^\d/.test(config.dnd)) {
|
package/src/shared/constants.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const PROTOCOL_VERSION =
|
|
1
|
+
export const PROTOCOL_VERSION = 2; // v2: sealed sender (encrypted_message carries `sealed`, no `from`)
|
|
2
2
|
|
|
3
3
|
// Network
|
|
4
4
|
export const SERVER_PORT = 3600;
|
|
@@ -41,6 +41,14 @@ export const OFFLINE_QUEUE_MAX_PER_PEER = 100;
|
|
|
41
41
|
export const OFFLINE_QUEUE_MAX_AGE_MS = 3_600_000; // 1h
|
|
42
42
|
export const OFFLINE_QUEUE_MAX_TOTAL = 1000;
|
|
43
43
|
|
|
44
|
+
// Private rooms (password-protected, zero-knowledge)
|
|
45
|
+
export const ROOM_AUTH_PK_SIZE = 32; // Ed25519 verifier public key
|
|
46
|
+
export const ROOM_AUTH_SIG_SIZE = 64; // Ed25519 detached signature
|
|
47
|
+
export const ROOM_CHALLENGE_NONCE_SIZE = 24;
|
|
48
|
+
export const ROOM_CHALLENGE_TTL_MS = 60_000; // challenge must be answered within this
|
|
49
|
+
export const ROOM_AUTH_MAX_FAILS = 5; // wrong-password attempts per connection…
|
|
50
|
+
export const ROOM_AUTH_FAIL_WINDOW_MS = 60_000; // …within this window
|
|
51
|
+
|
|
44
52
|
// Message padding (anti-metadata): every ciphertext is padded up to one of
|
|
45
53
|
// these bucket sizes so the relay can't read the true plaintext length.
|
|
46
54
|
export const MESSAGE_PAD_BUCKETS = [128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Remembers where the user was (server + room) so the next launch can offer to
|
|
2
|
+
// reconnect. Deliberately tiny and best-effort: losing this file only costs a
|
|
3
|
+
// couple of keystrokes. Privacy: callers must NOT pass the room of a private
|
|
4
|
+
// room — its name never touches disk (see ChatController#saveLastSession).
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export function lastSessionPath() {
|
|
10
|
+
return join(homedir(), '.ciphermesh', 'last-session.json');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Load { server, room?, at } or null when missing/corrupt. */
|
|
14
|
+
export function loadLastSession(path = lastSessionPath()) {
|
|
15
|
+
try {
|
|
16
|
+
const obj = JSON.parse(readFileSync(path, 'utf-8'));
|
|
17
|
+
if (obj === null || typeof obj !== 'object' || typeof obj.server !== 'string') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const out = { server: obj.server, at: Number(obj.at) || 0 };
|
|
21
|
+
if (typeof obj.room === 'string' && /^[a-zA-Z0-9_-]{1,30}$/.test(obj.room)) {
|
|
22
|
+
out.room = obj.room;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Persist { server, room? }. Room is optional on purpose (private rooms). */
|
|
31
|
+
export function saveLastSession({ server, room }, path = lastSessionPath()) {
|
|
32
|
+
if (typeof server !== 'string' || !server) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const data = { server, at: Date.now() };
|
|
36
|
+
if (typeof room === 'string' && room) {
|
|
37
|
+
data.room = room;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
41
|
+
writeFileSync(path, `${JSON.stringify(data)}\n`, { mode: 0o600 });
|
|
42
|
+
} catch {
|
|
43
|
+
// Best effort — never break the chat over this.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function clearLastSession(path = lastSessionPath()) {
|
|
48
|
+
try {
|
|
49
|
+
if (existsSync(path)) {
|
|
50
|
+
unlinkSync(path);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// Best effort.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// First-run setup wizard: ~30 seconds from `npx ciphermesh` to chatting, with
|
|
2
|
+
// just enough context to use the security features. Runs when no config file
|
|
3
|
+
// exists yet (or on demand via --setup) and persists the answers so it never
|
|
4
|
+
// asks twice.
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { SERVER_PORT } from './constants.js';
|
|
7
|
+
import { promptLabel, promptDim, promptError } from './banner.js';
|
|
8
|
+
import { themeNames, setTheme, getThemeName, THEMES } from './themes.js';
|
|
9
|
+
import { parseInvite } from './invite.js';
|
|
10
|
+
import { configPath, saveConfig } from './config.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a theme answer: a number from the printed list ("2"), a name
|
|
14
|
+
* ("matrix"), or empty → fallback. Pure — exported for testing.
|
|
15
|
+
*/
|
|
16
|
+
export function parseThemeChoice(input, names, fallback) {
|
|
17
|
+
const clean = (input || '').trim().toLowerCase();
|
|
18
|
+
if (!clean) {
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
if (/^\d+$/.test(clean)) {
|
|
22
|
+
const idx = Number(clean) - 1;
|
|
23
|
+
return names[idx] || fallback;
|
|
24
|
+
}
|
|
25
|
+
return names.includes(clean) ? clean : fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the server answer into what to use this session and what to save
|
|
30
|
+
* as the default. Invites are used as-is for the session but saved as their
|
|
31
|
+
* host:port (a room invite is one-shot, the host is worth keeping).
|
|
32
|
+
* Pure — exported for testing.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveServerAnswer(input, fallback = `localhost:${SERVER_PORT}`) {
|
|
35
|
+
const clean = (input || '').trim();
|
|
36
|
+
if (!clean) {
|
|
37
|
+
return { session: fallback, save: fallback };
|
|
38
|
+
}
|
|
39
|
+
const invite = parseInvite(clean);
|
|
40
|
+
if (invite) {
|
|
41
|
+
return { session: clean, save: invite.wsUrl.replace(/^wss?:\/\//, '') };
|
|
42
|
+
}
|
|
43
|
+
return { session: clean, save: clean };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Interactive first-run wizard. Uses the same readline interface as the rest
|
|
48
|
+
* of startup. Returns `{ nickname, server, theme }` — the caller uses them
|
|
49
|
+
* directly for this session (no duplicate prompts) — after persisting them
|
|
50
|
+
* to the config file.
|
|
51
|
+
*/
|
|
52
|
+
export async function runOnboarding(rl, { savePath = configPath() } = {}) {
|
|
53
|
+
const dim = (t) => console.log(promptDim(` ${t}`));
|
|
54
|
+
|
|
55
|
+
console.log();
|
|
56
|
+
console.log(chalk.bold.white(' First time here? Quick setup — 30 seconds.'));
|
|
57
|
+
dim(`Everything is saved to ${savePath} (re-run anytime with: ciphermesh --setup)`);
|
|
58
|
+
console.log();
|
|
59
|
+
console.log(chalk.white(' How CipherMesh works, in three lines:'));
|
|
60
|
+
dim('• Everything is end-to-end encrypted — the relay only ever sees ciphertext.');
|
|
61
|
+
dim('• Your identity is a keypair; its short fingerprint is shown when you connect.');
|
|
62
|
+
dim('• Verify friends out-of-band with /verify — a green ✓ appears next to their name.');
|
|
63
|
+
console.log();
|
|
64
|
+
|
|
65
|
+
// 1. Nickname
|
|
66
|
+
let nickname = '';
|
|
67
|
+
while (!nickname) {
|
|
68
|
+
const raw = await rl.question(promptLabel(`Nickname ${promptDim('(a-z, 0-9, _, -)')}: `));
|
|
69
|
+
const clean = raw.trim().replace(/[^a-zA-Z0-9_-]/g, '');
|
|
70
|
+
if (clean.length >= 1 && clean.length <= 20) {
|
|
71
|
+
nickname = clean;
|
|
72
|
+
} else {
|
|
73
|
+
console.log(promptError('Invalid nickname. Use 1-20 alphanumeric characters.'));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. Theme
|
|
78
|
+
const names = themeNames();
|
|
79
|
+
console.log();
|
|
80
|
+
console.log(chalk.white(' Colour theme for nicknames:'));
|
|
81
|
+
names.forEach((name, i) => {
|
|
82
|
+
const swatch = THEMES[name]
|
|
83
|
+
.slice(0, 5)
|
|
84
|
+
.map((c) => (c.startsWith('#') ? chalk.hex(c)('█') : chalk[c]?.('█') || '█'))
|
|
85
|
+
.join('');
|
|
86
|
+
console.log(promptDim(` ${i + 1}. ${name.padEnd(8)} ${swatch}`));
|
|
87
|
+
});
|
|
88
|
+
const themeRaw = await rl.question(
|
|
89
|
+
promptLabel(`Theme ${promptDim(`(1-${names.length} or name, Enter = ${getThemeName()})`)}: `),
|
|
90
|
+
);
|
|
91
|
+
const theme = parseThemeChoice(themeRaw, names, getThemeName());
|
|
92
|
+
setTheme(theme);
|
|
93
|
+
|
|
94
|
+
// 3. Default server
|
|
95
|
+
console.log();
|
|
96
|
+
console.log(chalk.white(' Which server should be your default?'));
|
|
97
|
+
dim(`• Same machine as the relay → localhost:${SERVER_PORT}`);
|
|
98
|
+
dim(`• Someone else hosts it (LAN/Tailscale) → their IP, e.g. 100.64.0.9:${SERVER_PORT}`);
|
|
99
|
+
dim('• Got a ciphermesh:// invite? Paste it here.');
|
|
100
|
+
const serverRaw = await rl.question(
|
|
101
|
+
promptLabel(`Server ${promptDim(`(Enter = localhost:${SERVER_PORT})`)}: `),
|
|
102
|
+
);
|
|
103
|
+
const { session: server, save: serverToSave } = resolveServerAnswer(serverRaw);
|
|
104
|
+
|
|
105
|
+
saveConfig({ nickname, theme, server: serverToSave }, savePath);
|
|
106
|
+
console.log();
|
|
107
|
+
console.log(promptLabel('Setup saved — next time you go straight to the chat.'));
|
|
108
|
+
console.log();
|
|
109
|
+
|
|
110
|
+
return { nickname, server, theme };
|
|
111
|
+
}
|