ciphermesh 2.2.0 → 2.4.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 +23 -2
- package/README.pt-BR.md +23 -2
- package/bin/server-binary.js +7 -0
- package/docs/ARCHITECTURE.md +36 -0
- package/package.json +2 -1
- package/src/client/ChatController.js +217 -27
- package/src/client/Connection.js +31 -1
- package/src/client/FileTransfer.js +53 -24
- package/src/client/UI.js +211 -4
- package/src/crypto/CertPinStore.js +39 -3
- package/src/crypto/DoubleRatchet.js +70 -3
- package/src/crypto/Handshake.js +44 -3
- package/src/crypto/KeyManager.js +27 -0
- package/src/crypto/PQHybrid.js +76 -0
- package/src/p2p/P2PChatController.js +632 -21
- package/src/p2p/index.js +12 -0
- package/src/protocol/messages.js +21 -13
- package/src/protocol/validators.js +16 -2
- package/src/server/SessionManager.js +3 -1
- package/src/server/WebSocketServer.js +15 -2
- package/src/shared/dnd.js +13 -0
package/src/p2p/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from '../shared/banner.js';
|
|
15
15
|
import { KeyManager } from '../crypto/KeyManager.js';
|
|
16
16
|
import { StateManager } from '../crypto/StateManager.js';
|
|
17
|
+
import { HistoryStore } from '../crypto/HistoryStore.js';
|
|
17
18
|
import { questionHidden } from '../shared/prompt.js';
|
|
18
19
|
import { loadConfig, startupCommands } from '../shared/config.js';
|
|
19
20
|
import { randomTip } from '../shared/tips.js';
|
|
@@ -171,6 +172,16 @@ await bootSequence([
|
|
|
171
172
|
const connManager = new PeerConnectionManager(nickname, () => keyManager.publicKeyB64);
|
|
172
173
|
const discovery = new Discovery();
|
|
173
174
|
const ui = new UI(nickname);
|
|
175
|
+
// Encrypted local history (opt-in — same passphrase that protects the session)
|
|
176
|
+
let historyStore = null;
|
|
177
|
+
if (restoredState?.passphrase) {
|
|
178
|
+
historyStore = new HistoryStore();
|
|
179
|
+
if (!historyStore.open(restoredState.passphrase)) {
|
|
180
|
+
console.log(promptError('History: passphrase mismatch — history disabled for this session'));
|
|
181
|
+
historyStore = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
174
185
|
const controller = new P2PChatController(
|
|
175
186
|
nickname,
|
|
176
187
|
peerServer,
|
|
@@ -180,6 +191,7 @@ const controller = new P2PChatController(
|
|
|
180
191
|
keyManager,
|
|
181
192
|
restoredState,
|
|
182
193
|
pluginManager,
|
|
194
|
+
historyStore,
|
|
183
195
|
);
|
|
184
196
|
|
|
185
197
|
ui.setFingerprint(controller.fingerprint);
|
package/src/protocol/messages.js
CHANGED
|
@@ -45,8 +45,14 @@ function base(type) {
|
|
|
45
45
|
return { type, version: PROTOCOL_VERSION, timestamp: Date.now() };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// pqPublicKey (v3, optional): ML-KEM-768 encapsulation key for the hybrid
|
|
49
|
+
// post-quantum handshake. Absent = classical-only peer (pre-2.3 client).
|
|
50
|
+
export function createJoin(nickname, publicKeyB64, pqPublicKeyB64 = null) {
|
|
51
|
+
const msg = { ...base(MSG.JOIN), nickname, publicKey: publicKeyB64 };
|
|
52
|
+
if (pqPublicKeyB64) {
|
|
53
|
+
msg.pqPublicKey = pqPublicKeyB64;
|
|
54
|
+
}
|
|
55
|
+
return msg;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
export function createJoinAck(sessionId, peers, queuedCount = 0, room = 'general') {
|
|
@@ -85,18 +91,20 @@ export function createEncryptedMessage(from, to, ciphertextB64, nonceB64) {
|
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
export function createRatchetedMessage(from, to, payload) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
counter: payload.counter,
|
|
95
|
-
previousCounter: payload.previousCounter,
|
|
96
|
-
ciphertext: payload.ciphertext.toString('base64'),
|
|
97
|
-
nonce: payload.nonce.toString('base64'),
|
|
98
|
-
},
|
|
94
|
+
const inner = {
|
|
95
|
+
ephemeralPublicKey: payload.ephemeralPublicKey.toString('base64'),
|
|
96
|
+
counter: payload.counter,
|
|
97
|
+
previousCounter: payload.previousCounter,
|
|
98
|
+
ciphertext: payload.ciphertext.toString('base64'),
|
|
99
|
+
nonce: payload.nonce.toString('base64'),
|
|
99
100
|
};
|
|
101
|
+
// Hybrid PQ: the KEM ciphertext rides along until the peer has folded it in.
|
|
102
|
+
// It is sealed to the recipient like everything else — the relay sees only
|
|
103
|
+
// an opaque blob.
|
|
104
|
+
if (payload.pqCiphertext) {
|
|
105
|
+
inner.pqCiphertext = payload.pqCiphertext.toString('base64');
|
|
106
|
+
}
|
|
107
|
+
return { ...base(MSG.ENCRYPTED_MESSAGE), from, to, payload: inner };
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
// Sealed-sender envelope (protocol v2): the relay sees only the recipient and an
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
ROOM_AUTH_SIG_SIZE,
|
|
8
8
|
ROOM_CHALLENGE_NONCE_SIZE,
|
|
9
9
|
} from '../shared/constants.js';
|
|
10
|
+
import { PQ_PUBLIC_KEY_SIZE } from '../crypto/PQHybrid.js';
|
|
10
11
|
|
|
11
12
|
// ── Helpers ────────────────────────────────────────────────────
|
|
12
13
|
function isString(v) {
|
|
@@ -65,7 +66,16 @@ export function parseMessage(raw) {
|
|
|
65
66
|
return { valid: false, error: 'Message must be an object' };
|
|
66
67
|
}
|
|
67
68
|
if (msg.version !== PROTOCOL_VERSION) {
|
|
68
|
-
|
|
69
|
+
// Spell out what to do: this reaches a human staring at a chat that just
|
|
70
|
+
// refuses to work, and "unsupported protocol version" alone tells them
|
|
71
|
+
// nothing about which side is behind or how to fix it.
|
|
72
|
+
const side = msg.version < PROTOCOL_VERSION ? 'client is older' : 'server is older';
|
|
73
|
+
return {
|
|
74
|
+
valid: false,
|
|
75
|
+
error:
|
|
76
|
+
`Protocol mismatch: this ${side} (got v${msg.version}, expected v${PROTOCOL_VERSION}). ` +
|
|
77
|
+
'Update both sides — `npx ciphermesh@latest`, or `git pull && npm install` if running from source.',
|
|
78
|
+
};
|
|
69
79
|
}
|
|
70
80
|
if (!isString(msg.type)) {
|
|
71
81
|
return { valid: false, error: 'Missing message type' };
|
|
@@ -86,7 +96,11 @@ export function validateJoin(msg) {
|
|
|
86
96
|
if (!isValidBase64(msg.publicKey, PUBLIC_KEY_SIZE)) {
|
|
87
97
|
return { valid: false, error: 'Invalid public key' };
|
|
88
98
|
}
|
|
89
|
-
|
|
99
|
+
// Optional ML-KEM-768 key (hybrid PQ). Absent = classical-only client.
|
|
100
|
+
if (msg.pqPublicKey !== undefined && !isValidBase64(msg.pqPublicKey, PQ_PUBLIC_KEY_SIZE)) {
|
|
101
|
+
return { valid: false, error: 'Invalid post-quantum public key' };
|
|
102
|
+
}
|
|
103
|
+
return { valid: true, nickname: nick, pqPublicKey: msg.pqPublicKey || null };
|
|
90
104
|
}
|
|
91
105
|
|
|
92
106
|
// Sealed sender (protocol v2): the relay only ever sees the recipient and an
|
|
@@ -30,12 +30,13 @@ export class SessionManager {
|
|
|
30
30
|
return this.#nicknames.has(nickname.toLowerCase());
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
addSession(ws, nickname, publicKey, room = 'general') {
|
|
33
|
+
addSession(ws, nickname, publicKey, room = 'general', pqPublicKey = null) {
|
|
34
34
|
const sessionId = randomUUID();
|
|
35
35
|
const session = {
|
|
36
36
|
ws,
|
|
37
37
|
nickname,
|
|
38
38
|
publicKey,
|
|
39
|
+
pqPublicKey, // ML-KEM-768 key, relayed verbatim (server never uses it)
|
|
39
40
|
connectedAt: Date.now(),
|
|
40
41
|
rooms: new Set(),
|
|
41
42
|
};
|
|
@@ -102,6 +103,7 @@ export class SessionManager {
|
|
|
102
103
|
sessionId: id,
|
|
103
104
|
nickname: session.nickname,
|
|
104
105
|
publicKey: session.publicKey,
|
|
106
|
+
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
105
107
|
});
|
|
106
108
|
}
|
|
107
109
|
}
|
|
@@ -259,7 +259,13 @@ export class SecureWSServer {
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
const room = 'general';
|
|
262
|
-
const sessionId = this.#sessionManager.addSession(
|
|
262
|
+
const sessionId = this.#sessionManager.addSession(
|
|
263
|
+
ws,
|
|
264
|
+
validation.nickname,
|
|
265
|
+
msg.publicKey,
|
|
266
|
+
room,
|
|
267
|
+
validation.pqPublicKey,
|
|
268
|
+
);
|
|
263
269
|
ws.sessionId = sessionId;
|
|
264
270
|
ws.hasJoined = true;
|
|
265
271
|
clearTimeout(ws.joinTimer);
|
|
@@ -292,6 +298,7 @@ export class SecureWSServer {
|
|
|
292
298
|
sessionId,
|
|
293
299
|
nickname: validation.nickname,
|
|
294
300
|
publicKey: msg.publicKey,
|
|
301
|
+
...(validation.pqPublicKey ? { pqPublicKey: validation.pqPublicKey } : {}),
|
|
295
302
|
}),
|
|
296
303
|
sessionId,
|
|
297
304
|
);
|
|
@@ -508,7 +515,12 @@ export class SecureWSServer {
|
|
|
508
515
|
this.#sessionManager.broadcastToRoom(
|
|
509
516
|
room,
|
|
510
517
|
createPeerJoined(
|
|
511
|
-
{
|
|
518
|
+
{
|
|
519
|
+
sessionId: ws.sessionId,
|
|
520
|
+
nickname: session.nickname,
|
|
521
|
+
publicKey: session.publicKey,
|
|
522
|
+
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
523
|
+
},
|
|
512
524
|
room,
|
|
513
525
|
),
|
|
514
526
|
ws.sessionId,
|
|
@@ -642,6 +654,7 @@ export class SecureWSServer {
|
|
|
642
654
|
sessionId: ws.sessionId,
|
|
643
655
|
nickname: session.nickname,
|
|
644
656
|
publicKey: session.publicKey,
|
|
657
|
+
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
645
658
|
},
|
|
646
659
|
result.newRoom,
|
|
647
660
|
),
|
package/src/shared/dnd.js
CHANGED
|
@@ -58,3 +58,16 @@ export function mentionsMe(text, nickname) {
|
|
|
58
58
|
}
|
|
59
59
|
return new RegExp(`(^|[^a-z0-9_-])${nick}([^a-z0-9_-]|$)`).test(t);
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* True if `text` contains `word` as a whole word (case-insensitive) — the
|
|
64
|
+
* matcher behind /watch. Whole-word so "dev" doesn't fire on "development",
|
|
65
|
+
* and the keyword is escaped so punctuation can never build a stray regex.
|
|
66
|
+
*/
|
|
67
|
+
export function matchesKeyword(text, word) {
|
|
68
|
+
if (typeof text !== 'string' || typeof word !== 'string' || !word) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const escaped = word.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
72
|
+
return new RegExp(`(^|[^a-z0-9_-])${escaped}([^a-z0-9_-]|$)`).test(text.toLowerCase());
|
|
73
|
+
}
|