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/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);
@@ -45,8 +45,14 @@ function base(type) {
45
45
  return { type, version: PROTOCOL_VERSION, timestamp: Date.now() };
46
46
  }
47
47
 
48
- export function createJoin(nickname, publicKeyB64) {
49
- return { ...base(MSG.JOIN), nickname, publicKey: publicKeyB64 };
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
- return {
89
- ...base(MSG.ENCRYPTED_MESSAGE),
90
- from,
91
- to,
92
- payload: {
93
- ephemeralPublicKey: payload.ephemeralPublicKey.toString('base64'),
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
- return { valid: false, error: `Unsupported protocol version: ${msg.version}` };
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
- return { valid: true, nickname: nick };
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(ws, validation.nickname, msg.publicKey, room);
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
- { sessionId: ws.sessionId, nickname: session.nickname, publicKey: session.publicKey },
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
+ }