ciphermesh 1.0.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/LICENSE +21 -0
- package/README.md +251 -0
- package/README.pt-BR.md +253 -0
- package/bin/ciphermesh.js +34 -0
- package/docs/ARCHITECTURE.md +1188 -0
- package/docs/SETUP.md +305 -0
- package/docs/demo.svg +46 -0
- package/package.json +87 -0
- package/src/client/ChatController.js +2476 -0
- package/src/client/Connection.js +129 -0
- package/src/client/FileTransfer.js +488 -0
- package/src/client/ImagePreview.js +88 -0
- package/src/client/UI.js +1830 -0
- package/src/client/index.js +231 -0
- package/src/crypto/CertPinStore.js +79 -0
- package/src/crypto/DeniableEncrypt.js +53 -0
- package/src/crypto/DoubleRatchet.js +574 -0
- package/src/crypto/Handshake.js +219 -0
- package/src/crypto/HistoryStore.js +241 -0
- package/src/crypto/IdentityBackup.js +70 -0
- package/src/crypto/KeyManager.js +134 -0
- package/src/crypto/MessageCrypto.js +181 -0
- package/src/crypto/NonceManager.js +72 -0
- package/src/crypto/SealedSender.js +58 -0
- package/src/crypto/SenderKey.js +204 -0
- package/src/crypto/StateManager.js +138 -0
- package/src/crypto/TrustStore.js +216 -0
- package/src/p2p/Discovery.js +80 -0
- package/src/p2p/P2PChatController.js +1856 -0
- package/src/p2p/PeerConnectionManager.js +252 -0
- package/src/p2p/PeerServer.js +68 -0
- package/src/p2p/index.js +219 -0
- package/src/protocol/messages.js +138 -0
- package/src/protocol/validators.js +175 -0
- package/src/server/CertManager.js +173 -0
- package/src/server/MessageRouter.js +80 -0
- package/src/server/OfflineQueue.js +124 -0
- package/src/server/SessionManager.js +296 -0
- package/src/server/WebSocketServer.js +632 -0
- package/src/server/index.js +89 -0
- package/src/shared/AuditLog.js +91 -0
- package/src/shared/PluginManager.js +83 -0
- package/src/shared/banner.js +271 -0
- package/src/shared/commandSuggest.js +59 -0
- package/src/shared/config.js +90 -0
- package/src/shared/constants.js +126 -0
- package/src/shared/coverTraffic.js +34 -0
- package/src/shared/dnd.js +60 -0
- package/src/shared/emoji.js +17 -0
- package/src/shared/fuzzy.js +40 -0
- package/src/shared/invite.js +61 -0
- package/src/shared/keyArt.js +66 -0
- package/src/shared/logger.js +38 -0
- package/src/shared/panic.js +38 -0
- package/src/shared/prompt.js +31 -0
- package/src/shared/terminalGraphics.js +72 -0
- package/src/shared/themes.js +36 -0
- package/src/shared/voiceNote.js +128 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { PROTOCOL_VERSION, RECONNECT_BASE_MS, RECONNECT_MAX_MS } from '../shared/constants.js';
|
|
4
|
+
|
|
5
|
+
const HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
export class PeerConnectionManager extends EventEmitter {
|
|
8
|
+
#myNickname;
|
|
9
|
+
#getPublicKeyB64;
|
|
10
|
+
#peers; // Map<nickname, { ws, host, port, isOutbound }>
|
|
11
|
+
#reconnectTimers; // Map<nickname, { timer, delay, host, port }>
|
|
12
|
+
#pendingOutbound; // Set<nickname>
|
|
13
|
+
#destroyed;
|
|
14
|
+
|
|
15
|
+
constructor(nickname, getPublicKeyB64) {
|
|
16
|
+
super();
|
|
17
|
+
this.#myNickname = nickname;
|
|
18
|
+
this.#getPublicKeyB64 = getPublicKeyB64;
|
|
19
|
+
this.#peers = new Map();
|
|
20
|
+
this.#reconnectTimers = new Map();
|
|
21
|
+
this.#pendingOutbound = new Set();
|
|
22
|
+
this.#destroyed = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Deduplication rule: lexicographically smaller nickname initiates.
|
|
27
|
+
*/
|
|
28
|
+
shouldInitiate(peerNickname) {
|
|
29
|
+
return this.#myNickname.toLowerCase() < peerNickname.toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Connect to a discovered peer (outbound).
|
|
34
|
+
*/
|
|
35
|
+
connectTo(peerNickname, host, port) {
|
|
36
|
+
if (this.#peers.has(peerNickname) || this.#pendingOutbound.has(peerNickname)) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!this.shouldInitiate(peerNickname)) {
|
|
41
|
+
return; // Wait for inbound from the other side
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.#pendingOutbound.add(peerNickname);
|
|
45
|
+
|
|
46
|
+
const ws = new WebSocket(`ws://${host}:${port}`);
|
|
47
|
+
|
|
48
|
+
const handshakeTimer = setTimeout(() => {
|
|
49
|
+
ws.close();
|
|
50
|
+
this.#pendingOutbound.delete(peerNickname);
|
|
51
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
52
|
+
|
|
53
|
+
ws.on('open', () => {
|
|
54
|
+
ws.send(
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
type: 'p2p_handshake',
|
|
57
|
+
nickname: this.#myNickname,
|
|
58
|
+
publicKey: this.#getPublicKeyB64(),
|
|
59
|
+
version: PROTOCOL_VERSION,
|
|
60
|
+
timestamp: Date.now(),
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
let handshakeComplete = false;
|
|
66
|
+
|
|
67
|
+
ws.on('message', (data) => {
|
|
68
|
+
try {
|
|
69
|
+
const msg = JSON.parse(data.toString('utf-8'));
|
|
70
|
+
|
|
71
|
+
if (!handshakeComplete && msg.type === 'p2p_handshake') {
|
|
72
|
+
clearTimeout(handshakeTimer);
|
|
73
|
+
handshakeComplete = true;
|
|
74
|
+
this.#pendingOutbound.delete(peerNickname);
|
|
75
|
+
this.#clearReconnect(peerNickname); // success resets backoff
|
|
76
|
+
this.#peers.set(peerNickname, { ws, host, port, isOutbound: true });
|
|
77
|
+
this.emit('peer-connected', {
|
|
78
|
+
nickname: msg.nickname,
|
|
79
|
+
publicKey: msg.publicKey,
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (handshakeComplete) {
|
|
85
|
+
this.emit('message', peerNickname, msg);
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Ignore malformed messages
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
ws.on('close', () => {
|
|
93
|
+
clearTimeout(handshakeTimer);
|
|
94
|
+
this.#pendingOutbound.delete(peerNickname);
|
|
95
|
+
|
|
96
|
+
if (handshakeComplete) {
|
|
97
|
+
this.#peers.delete(peerNickname);
|
|
98
|
+
this.emit('peer-disconnected', peerNickname);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Retry with backoff whether or not the handshake ever completed — the
|
|
102
|
+
// peer may just be starting up. connectTo() no-ops if we already have
|
|
103
|
+
// the peer or shouldn't initiate to it.
|
|
104
|
+
this.#scheduleReconnect(peerNickname, host, port);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
ws.on('error', () => {
|
|
108
|
+
// Error is followed by 'close'
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Accept an inbound connection (from PeerServer).
|
|
114
|
+
*/
|
|
115
|
+
acceptConnection(ws) {
|
|
116
|
+
// Send handshake immediately
|
|
117
|
+
ws.send(
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
type: 'p2p_handshake',
|
|
120
|
+
nickname: this.#myNickname,
|
|
121
|
+
publicKey: this.#getPublicKeyB64(),
|
|
122
|
+
version: PROTOCOL_VERSION,
|
|
123
|
+
timestamp: Date.now(),
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const handshakeTimer = setTimeout(() => {
|
|
128
|
+
ws.close();
|
|
129
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
130
|
+
|
|
131
|
+
let handshakeComplete = false;
|
|
132
|
+
let peerNickname = null;
|
|
133
|
+
let isDuplicate = false;
|
|
134
|
+
|
|
135
|
+
ws.on('message', (data) => {
|
|
136
|
+
try {
|
|
137
|
+
const msg = JSON.parse(data.toString('utf-8'));
|
|
138
|
+
|
|
139
|
+
if (!handshakeComplete && msg.type === 'p2p_handshake') {
|
|
140
|
+
clearTimeout(handshakeTimer);
|
|
141
|
+
handshakeComplete = true;
|
|
142
|
+
peerNickname = msg.nickname;
|
|
143
|
+
|
|
144
|
+
// Duplicate check — close the newer connection WITHOUT evicting the
|
|
145
|
+
// existing live one (isDuplicate guards the close handler below).
|
|
146
|
+
if (this.#peers.has(peerNickname)) {
|
|
147
|
+
isDuplicate = true;
|
|
148
|
+
ws.close();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.#peers.set(peerNickname, { ws, host: null, port: null, isOutbound: false });
|
|
153
|
+
this.emit('peer-connected', {
|
|
154
|
+
nickname: msg.nickname,
|
|
155
|
+
publicKey: msg.publicKey,
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (handshakeComplete && peerNickname) {
|
|
161
|
+
this.emit('message', peerNickname, msg);
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// Ignore malformed messages
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
ws.on('close', () => {
|
|
169
|
+
clearTimeout(handshakeTimer);
|
|
170
|
+
if (handshakeComplete && peerNickname && !isDuplicate) {
|
|
171
|
+
this.#peers.delete(peerNickname);
|
|
172
|
+
this.emit('peer-disconnected', peerNickname);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
ws.on('error', () => {
|
|
177
|
+
// Error triggers close
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#scheduleReconnect(nickname, host, port) {
|
|
182
|
+
if (this.#destroyed) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const existing = this.#reconnectTimers.get(nickname);
|
|
186
|
+
const delay = existing ? Math.min(existing.delay * 2, RECONNECT_MAX_MS) : RECONNECT_BASE_MS;
|
|
187
|
+
|
|
188
|
+
// Keep the entry (with the grown delay) so a subsequent failed attempt
|
|
189
|
+
// doubles instead of resetting to the base. connectTo() clears it on
|
|
190
|
+
// success; destroy() clears everything.
|
|
191
|
+
const timer = setTimeout(() => {
|
|
192
|
+
this.connectTo(nickname, host, port);
|
|
193
|
+
}, delay);
|
|
194
|
+
if (typeof timer.unref === 'function') {
|
|
195
|
+
timer.unref();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.#reconnectTimers.set(nickname, { timer, delay, host, port });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#clearReconnect(nickname) {
|
|
202
|
+
const entry = this.#reconnectTimers.get(nickname);
|
|
203
|
+
if (entry) {
|
|
204
|
+
clearTimeout(entry.timer);
|
|
205
|
+
this.#reconnectTimers.delete(nickname);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
send(nickname, data) {
|
|
210
|
+
const peer = this.#peers.get(nickname);
|
|
211
|
+
if (peer && peer.ws.readyState === WebSocket.OPEN) {
|
|
212
|
+
peer.ws.send(JSON.stringify(data));
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
broadcast(data) {
|
|
219
|
+
const json = JSON.stringify(data);
|
|
220
|
+
for (const [, peer] of this.#peers) {
|
|
221
|
+
if (peer.ws.readyState === WebSocket.OPEN) {
|
|
222
|
+
peer.ws.send(json);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
hasPeer(nickname) {
|
|
228
|
+
return this.#peers.has(nickname);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
get peerCount() {
|
|
232
|
+
return this.#peers.size;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
get peerNicknames() {
|
|
236
|
+
return [...this.#peers.keys()];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
destroy() {
|
|
240
|
+
this.#destroyed = true;
|
|
241
|
+
for (const [, entry] of this.#reconnectTimers) {
|
|
242
|
+
clearTimeout(entry.timer);
|
|
243
|
+
}
|
|
244
|
+
this.#reconnectTimers.clear();
|
|
245
|
+
this.#pendingOutbound.clear();
|
|
246
|
+
|
|
247
|
+
for (const [, peer] of this.#peers) {
|
|
248
|
+
peer.ws.close();
|
|
249
|
+
}
|
|
250
|
+
this.#peers.clear();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { MAX_PAYLOAD_SIZE } from '../shared/constants.js';
|
|
4
|
+
import { createLogger } from '../shared/logger.js';
|
|
5
|
+
|
|
6
|
+
const log = createLogger('peer-server');
|
|
7
|
+
|
|
8
|
+
export class PeerServer extends EventEmitter {
|
|
9
|
+
#wss;
|
|
10
|
+
#port;
|
|
11
|
+
|
|
12
|
+
constructor() {
|
|
13
|
+
super();
|
|
14
|
+
this.#wss = null;
|
|
15
|
+
this.#port = 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Start listening on a random available port.
|
|
20
|
+
* @returns {Promise<number>} The assigned port number.
|
|
21
|
+
*/
|
|
22
|
+
start() {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
this.#wss = new WebSocketServer({ port: 0, maxPayload: MAX_PAYLOAD_SIZE });
|
|
25
|
+
let settled = false;
|
|
26
|
+
|
|
27
|
+
this.#wss.on('listening', () => {
|
|
28
|
+
settled = true;
|
|
29
|
+
this.#port = this.#wss.address().port;
|
|
30
|
+
resolve(this.#port);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
this.#wss.on('connection', (ws) => {
|
|
34
|
+
this.emit('connection', ws);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Persistent error handler: reject only before 'listening'; afterwards a
|
|
38
|
+
// late server error would otherwise be unhandled and crash the process.
|
|
39
|
+
this.#wss.on('error', (err) => {
|
|
40
|
+
if (!settled) {
|
|
41
|
+
settled = true;
|
|
42
|
+
reject(err);
|
|
43
|
+
} else {
|
|
44
|
+
log.error(`PeerServer error: ${err.message}`);
|
|
45
|
+
// Only emit if someone is listening — a listener-less 'error' event
|
|
46
|
+
// would itself throw and crash the process.
|
|
47
|
+
if (this.listenerCount('error') > 0) {
|
|
48
|
+
this.emit('error', err);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get port() {
|
|
56
|
+
return this.#port;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
stop() {
|
|
60
|
+
if (this.#wss) {
|
|
61
|
+
for (const client of this.#wss.clients) {
|
|
62
|
+
client.close();
|
|
63
|
+
}
|
|
64
|
+
this.#wss.close();
|
|
65
|
+
this.#wss = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/p2p/index.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import * as readline from 'node:readline/promises';
|
|
2
|
+
import { stdin, stdout } from 'node:process';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import sodium from 'sodium-native';
|
|
5
|
+
import boxen from 'boxen';
|
|
6
|
+
import {
|
|
7
|
+
animatedBanner,
|
|
8
|
+
bootSequence,
|
|
9
|
+
promptLabel,
|
|
10
|
+
promptDim,
|
|
11
|
+
promptError,
|
|
12
|
+
chalk,
|
|
13
|
+
mint,
|
|
14
|
+
} from '../shared/banner.js';
|
|
15
|
+
import { KeyManager } from '../crypto/KeyManager.js';
|
|
16
|
+
import { StateManager } from '../crypto/StateManager.js';
|
|
17
|
+
import { questionHidden } from '../shared/prompt.js';
|
|
18
|
+
import { loadConfig, startupCommands } from '../shared/config.js';
|
|
19
|
+
import { setTheme } from '../shared/themes.js';
|
|
20
|
+
import { importBackup } from '../crypto/IdentityBackup.js';
|
|
21
|
+
import { Discovery } from './Discovery.js';
|
|
22
|
+
import { PeerServer } from './PeerServer.js';
|
|
23
|
+
import { PeerConnectionManager } from './PeerConnectionManager.js';
|
|
24
|
+
import { UI } from '../client/UI.js';
|
|
25
|
+
import { P2PChatController } from './P2PChatController.js';
|
|
26
|
+
import { PluginManager } from '../shared/PluginManager.js';
|
|
27
|
+
|
|
28
|
+
// ── Banner ──────────────────────────────────────────────────────
|
|
29
|
+
await animatedBanner(' ░▒▓ End-to-End Encrypted P2P Chat ▓▒░');
|
|
30
|
+
|
|
31
|
+
// ── Config (optional defaults from ~/.ciphermesh/config.json) ────
|
|
32
|
+
const config = loadConfig();
|
|
33
|
+
if (config.theme) {
|
|
34
|
+
setTheme(config.theme);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Prompt setup ────────────────────────────────────────────────
|
|
38
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
39
|
+
|
|
40
|
+
let nickname = '';
|
|
41
|
+
while (!nickname) {
|
|
42
|
+
const hint = config.nickname ? `(${config.nickname})` : '(a-z, 0-9, _, -)';
|
|
43
|
+
const raw = await rl.question(promptLabel(`Nickname ${promptDim(hint)}: `));
|
|
44
|
+
const clean = (raw.trim() || config.nickname || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
45
|
+
if (clean.length >= 1 && clean.length <= 20) {
|
|
46
|
+
nickname = clean;
|
|
47
|
+
} else {
|
|
48
|
+
console.log(promptError('Invalid nickname. Use 1-20 alphanumeric characters.'));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── State restoration ────────────────────────────────────────────
|
|
53
|
+
const stateManager = new StateManager();
|
|
54
|
+
let restoredState = null;
|
|
55
|
+
|
|
56
|
+
if (stateManager.hasState()) {
|
|
57
|
+
const passphrase = await questionHidden(
|
|
58
|
+
rl,
|
|
59
|
+
promptLabel(`Passphrase to restore session ${promptDim('(Enter to skip)')}: `),
|
|
60
|
+
);
|
|
61
|
+
if (passphrase.trim()) {
|
|
62
|
+
restoredState = stateManager.loadState(passphrase.trim());
|
|
63
|
+
if (restoredState) {
|
|
64
|
+
restoredState.passphrase = passphrase.trim();
|
|
65
|
+
console.log(promptLabel('Previous session restored successfully!'));
|
|
66
|
+
} else {
|
|
67
|
+
console.log(promptError('Wrong passphrase or corrupted state. New session.'));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
const passphrase = await questionHidden(
|
|
72
|
+
rl,
|
|
73
|
+
promptLabel(`Passphrase to protect session ${promptDim('(Enter to skip)')}: `),
|
|
74
|
+
);
|
|
75
|
+
if (passphrase.trim()) {
|
|
76
|
+
const confirm = await questionHidden(rl, promptLabel('Confirm the passphrase: '));
|
|
77
|
+
if (confirm.trim() === passphrase.trim()) {
|
|
78
|
+
restoredState = { passphrase: passphrase.trim() };
|
|
79
|
+
} else {
|
|
80
|
+
console.log(
|
|
81
|
+
promptError('Passphrases do not match — the session will not be protected this time.'),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Offer to restore identity + trust from an encrypted backup.
|
|
88
|
+
if (!restoredState?.keyManager) {
|
|
89
|
+
const backupPath = await rl.question(
|
|
90
|
+
promptLabel(`Restore identity from a backup? ${promptDim('(path or Enter)')}: `),
|
|
91
|
+
);
|
|
92
|
+
if (backupPath.trim()) {
|
|
93
|
+
try {
|
|
94
|
+
const raw = readFileSync(backupPath.trim(), 'utf-8');
|
|
95
|
+
const pass = await questionHidden(rl, promptLabel('Backup passphrase: '));
|
|
96
|
+
const data = importBackup(raw, pass.trim());
|
|
97
|
+
if (data?.identity) {
|
|
98
|
+
restoredState = {
|
|
99
|
+
...(restoredState || {}),
|
|
100
|
+
keyManager: data.identity,
|
|
101
|
+
trust: data.trust,
|
|
102
|
+
passphrase: pass.trim(),
|
|
103
|
+
};
|
|
104
|
+
console.log(promptLabel('Identity + trust restored from backup!'));
|
|
105
|
+
} else {
|
|
106
|
+
console.log(promptError('Invalid backup or wrong passphrase.'));
|
|
107
|
+
}
|
|
108
|
+
} catch (e) {
|
|
109
|
+
console.log(promptError(`Could not read the backup: ${e.message}`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
rl.close();
|
|
115
|
+
|
|
116
|
+
// ── Initialize crypto ──────────────────────────────────────────
|
|
117
|
+
const keyManager = restoredState?.keyManager
|
|
118
|
+
? KeyManager.deserialize(restoredState.keyManager)
|
|
119
|
+
: new KeyManager();
|
|
120
|
+
|
|
121
|
+
// ── Start P2P server ──────────────────────────────────────────
|
|
122
|
+
const peerServer = new PeerServer();
|
|
123
|
+
const port = await peerServer.start();
|
|
124
|
+
|
|
125
|
+
// ── Info box ────────────────────────────────────────────────────
|
|
126
|
+
console.log();
|
|
127
|
+
const lines = [];
|
|
128
|
+
lines.push(chalk.hex('#4cc9f0')(' Mode ') + chalk.bold.white('P2P (mDNS LAN)'));
|
|
129
|
+
lines.push(chalk.hex('#4cc9f0')(' Port ') + chalk.bold.white(port));
|
|
130
|
+
lines.push(chalk.hex('#4cc9f0')(' Fingerprint ') + mint(keyManager.fingerprint));
|
|
131
|
+
lines.push(chalk.hex('#4cc9f0')(' Crypto ') + chalk.white('X25519 + XSalsa20-Poly1305'));
|
|
132
|
+
lines.push(
|
|
133
|
+
chalk.hex('#4cc9f0')(' Status ') + chalk.bold.green('● Searching for peers on the LAN...'),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
console.log(
|
|
137
|
+
boxen(lines.join('\n'), {
|
|
138
|
+
padding: { left: 1, right: 1, top: 0, bottom: 0 },
|
|
139
|
+
borderColor: '#7b2dff',
|
|
140
|
+
borderStyle: 'round',
|
|
141
|
+
title: chalk.bold.hex('#00ff9f')(' P2P '),
|
|
142
|
+
titleAlignment: 'center',
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
console.log();
|
|
146
|
+
|
|
147
|
+
// ── Real boot sequence ──────────────────────────────────────────
|
|
148
|
+
// The spinner gates on genuine startup work: the peer server is already
|
|
149
|
+
// listening (bound above) and plugins actually load. Peer discovery starts
|
|
150
|
+
// later, after the controller is wired, so no early mDNS events are missed.
|
|
151
|
+
const pluginManager = new PluginManager();
|
|
152
|
+
|
|
153
|
+
await bootSequence([
|
|
154
|
+
'Curve25519 key exchange',
|
|
155
|
+
'XSalsa20-Poly1305 cipher',
|
|
156
|
+
'Double Ratchet — forward secrecy',
|
|
157
|
+
'TOFU trust store',
|
|
158
|
+
{
|
|
159
|
+
label: `Peer server on :${port}`,
|
|
160
|
+
task: async () => {
|
|
161
|
+
if (!port) {
|
|
162
|
+
throw new Error('peer server is not listening');
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{ label: 'Loading plugins', task: () => pluginManager.loadAll() },
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
// ── Initialize components ──────────────────────────────────────
|
|
170
|
+
const connManager = new PeerConnectionManager(nickname, () => keyManager.publicKeyB64);
|
|
171
|
+
const discovery = new Discovery();
|
|
172
|
+
const ui = new UI(nickname);
|
|
173
|
+
const controller = new P2PChatController(
|
|
174
|
+
nickname,
|
|
175
|
+
peerServer,
|
|
176
|
+
connManager,
|
|
177
|
+
discovery,
|
|
178
|
+
ui,
|
|
179
|
+
keyManager,
|
|
180
|
+
restoredState,
|
|
181
|
+
pluginManager,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
ui.setFingerprint(controller.fingerprint);
|
|
185
|
+
ui.addInfoMessage(`Your fingerprint: ${controller.fingerprint}`);
|
|
186
|
+
ui.addInfoMessage('P2P mode — peers discovered automatically via mDNS');
|
|
187
|
+
ui.addInfoMessage('Use /help to see available commands');
|
|
188
|
+
|
|
189
|
+
if (restoredState?.handshake) {
|
|
190
|
+
ui.addSystemMessage('Previous session restored — ratchets preserved');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Start mDNS discovery
|
|
194
|
+
discovery.start(nickname, port, keyManager.publicKeyB64);
|
|
195
|
+
|
|
196
|
+
// Apply config toggles by replaying their slash-commands through the controller.
|
|
197
|
+
for (const cmd of startupCommands(config)) {
|
|
198
|
+
ui.emit('input', cmd);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Graceful shutdown ───────────────────────────────────────────
|
|
202
|
+
function shutdown() {
|
|
203
|
+
const passphrase = controller.passphrase;
|
|
204
|
+
if (passphrase) {
|
|
205
|
+
try {
|
|
206
|
+
const state = controller.serializeState();
|
|
207
|
+
const { kek, salt, opslimit, memlimit } = stateManager.deriveKEK(passphrase);
|
|
208
|
+
stateManager.saveState(state, kek, salt, opslimit, memlimit);
|
|
209
|
+
sodium.sodium_memzero(kek);
|
|
210
|
+
} catch {
|
|
211
|
+
// Best effort — don't block shutdown
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
controller.destroy();
|
|
215
|
+
process.exit(0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
process.on('SIGINT', shutdown);
|
|
219
|
+
process.on('SIGTERM', shutdown);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { PROTOCOL_VERSION } from '../shared/constants.js';
|
|
2
|
+
|
|
3
|
+
// ── Message types ──────────────────────────────────────────────
|
|
4
|
+
export const MSG = {
|
|
5
|
+
JOIN: 'join',
|
|
6
|
+
JOIN_ACK: 'join_ack',
|
|
7
|
+
PEER_JOINED: 'peer_joined',
|
|
8
|
+
PEER_LEFT: 'peer_left',
|
|
9
|
+
ENCRYPTED_MESSAGE: 'encrypted_message',
|
|
10
|
+
ERROR: 'error',
|
|
11
|
+
KEY_UPDATE: 'key_update',
|
|
12
|
+
PEER_KEY_UPDATED: 'peer_key_updated',
|
|
13
|
+
CHANGE_ROOM: 'change_room',
|
|
14
|
+
ROOM_CHANGED: 'room_changed',
|
|
15
|
+
LIST_ROOMS: 'list_rooms',
|
|
16
|
+
ROOM_LIST: 'room_list',
|
|
17
|
+
KICK_PEER: 'kick_peer',
|
|
18
|
+
MUTE_PEER: 'mute_peer',
|
|
19
|
+
BAN_PEER: 'ban_peer',
|
|
20
|
+
PEER_KICKED: 'peer_kicked',
|
|
21
|
+
PEER_MUTED: 'peer_muted',
|
|
22
|
+
PING: 'ping',
|
|
23
|
+
PONG: 'pong',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ── Error codes ────────────────────────────────────────────────
|
|
27
|
+
export const ERR = {
|
|
28
|
+
NICKNAME_TAKEN: 'NICKNAME_TAKEN',
|
|
29
|
+
INVALID_MESSAGE: 'INVALID_MESSAGE',
|
|
30
|
+
PEER_NOT_FOUND: 'PEER_NOT_FOUND',
|
|
31
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
32
|
+
PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ── Factory helpers ────────────────────────────────────────────
|
|
36
|
+
function base(type) {
|
|
37
|
+
return { type, version: PROTOCOL_VERSION, timestamp: Date.now() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createJoin(nickname, publicKeyB64) {
|
|
41
|
+
return { ...base(MSG.JOIN), nickname, publicKey: publicKeyB64 };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createJoinAck(sessionId, peers, queuedCount = 0, room = 'general') {
|
|
45
|
+
const ack = { ...base(MSG.JOIN_ACK), sessionId, peers, room };
|
|
46
|
+
if (queuedCount > 0) {
|
|
47
|
+
ack.queuedCount = queuedCount;
|
|
48
|
+
}
|
|
49
|
+
return ack;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createPeerJoined(peer) {
|
|
53
|
+
return { ...base(MSG.PEER_JOINED), peer };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createPeerLeft(sessionId, nickname) {
|
|
57
|
+
return { ...base(MSG.PEER_LEFT), sessionId, nickname };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createEncryptedMessage(from, to, ciphertextB64, nonceB64) {
|
|
61
|
+
return {
|
|
62
|
+
...base(MSG.ENCRYPTED_MESSAGE),
|
|
63
|
+
from,
|
|
64
|
+
to,
|
|
65
|
+
payload: { ciphertext: ciphertextB64, nonce: nonceB64 },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createRatchetedMessage(from, to, payload) {
|
|
70
|
+
return {
|
|
71
|
+
...base(MSG.ENCRYPTED_MESSAGE),
|
|
72
|
+
from,
|
|
73
|
+
to,
|
|
74
|
+
payload: {
|
|
75
|
+
ephemeralPublicKey: payload.ephemeralPublicKey.toString('base64'),
|
|
76
|
+
counter: payload.counter,
|
|
77
|
+
previousCounter: payload.previousCounter,
|
|
78
|
+
ciphertext: payload.ciphertext.toString('base64'),
|
|
79
|
+
nonce: payload.nonce.toString('base64'),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createError(code, message) {
|
|
85
|
+
return { ...base(MSG.ERROR), code, message };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createKeyUpdate(publicKeyB64) {
|
|
89
|
+
return { ...base(MSG.KEY_UPDATE), publicKey: publicKeyB64 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createPeerKeyUpdated(sessionId, publicKeyB64) {
|
|
93
|
+
return { ...base(MSG.PEER_KEY_UPDATED), sessionId, publicKey: publicKeyB64 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createPing() {
|
|
97
|
+
return base(MSG.PING);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createPong() {
|
|
101
|
+
return base(MSG.PONG);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createChangeRoom(room) {
|
|
105
|
+
return { ...base(MSG.CHANGE_ROOM), room };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createRoomChanged(room, peers) {
|
|
109
|
+
return { ...base(MSG.ROOM_CHANGED), room, peers };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createListRooms() {
|
|
113
|
+
return base(MSG.LIST_ROOMS);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createRoomList(rooms) {
|
|
117
|
+
return { ...base(MSG.ROOM_LIST), rooms };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createKickPeer(targetNickname, reason = '') {
|
|
121
|
+
return { ...base(MSG.KICK_PEER), targetNickname, reason };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createMutePeer(targetNickname, durationMs) {
|
|
125
|
+
return { ...base(MSG.MUTE_PEER), targetNickname, durationMs };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createBanPeer(targetNickname, reason = '') {
|
|
129
|
+
return { ...base(MSG.BAN_PEER), targetNickname, reason };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function createPeerKicked(nickname, reason = '') {
|
|
133
|
+
return { ...base(MSG.PEER_KICKED), nickname, reason };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function createPeerMuted(nickname, durationMs) {
|
|
137
|
+
return { ...base(MSG.PEER_MUTED), nickname, durationMs };
|
|
138
|
+
}
|