ciphermesh 2.2.0 → 2.3.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 CHANGED
@@ -42,6 +42,7 @@ forwarding, survives CGNAT).
42
42
  |-----|---------|----------|
43
43
  | 🔐 | **Real E2EE** | Curve25519 + XSalsa20-Poly1305 via libsodium, keys in `sodium_malloc` — never touch disk |
44
44
  | 🔄 | **Perfect Forward Secrecy** | Double Ratchet: one key per message, compromise today ≠ read yesterday |
45
+ | 🛡️ | **Hybrid post-quantum** | X25519 **+ ML-KEM-768** folded into the ratchet root — beats "harvest now, decrypt later" while staying ≥ classical security ([details](docs/ARCHITECTURE.md)) |
45
46
  | 🕶️ | **Metadata resistance** | **Sealed sender** — the relay never sees who sent a message — plus fixed-bucket length padding on every ciphertext and opt-in cover traffic (`/cover`) |
46
47
  | 🕵️ | **TOFU + SAS** | Key-change detection (MITM alarm), 6-digit voice-verifiable codes, and inline **✓/✗** trust badges next to names |
47
48
  | 🌐 | **LAN & internet** | Auto-detects Tailscale, shows the reachable address in the banner |
@@ -94,6 +95,13 @@ Prefer a prebuilt image? Pull the relay from GHCR (published on each release):
94
95
  docker run -p 3600:3600 ghcr.io/felipekreulich/secret-chat-lan:latest
95
96
  ```
96
97
 
98
+ **No Node on the host?** Every release ships a standalone relay binary for
99
+ macOS and Linux (arm64/x64) — download it from the
100
+ [releases page](https://github.com/FelipeKreulich/secret-chat-lan/releases),
101
+ `chmod +x`, run. Nothing to install. (The TUI client still needs Node/npx: it
102
+ depends on blessed, which resolves its widgets at runtime and cannot be
103
+ bundled.)
104
+
97
105
  **Everyone** (including the host):
98
106
 
99
107
  ```bash
package/README.pt-BR.md CHANGED
@@ -42,6 +42,7 @@ forwarding, imune a CGNAT).
42
42
  |-----|---------|--------|
43
43
  | 🔐 | **E2EE de verdade** | Curve25519 + XSalsa20-Poly1305 via libsodium, chaves em `sodium_malloc` — nunca tocam o disco |
44
44
  | 🔄 | **Perfect Forward Secrecy** | Double Ratchet: uma chave por mensagem — comprometer hoje ≠ ler ontem |
45
+ | 🛡️ | **Pós-quântico híbrido** | X25519 **+ ML-KEM-768** misturado na raiz do ratchet — vence o "grava hoje, decifra depois" mantendo segurança ≥ à clássica ([detalhes](docs/ARCHITECTURE.md)) |
45
46
  | 🕶️ | **Resistência a metadados** | **Sealed sender** — o relay nunca vê quem enviou a mensagem — + padding de comprimento em buckets fixos em todo ciphertext e cover traffic opcional (`/cover`) |
46
47
  | 🕵️ | **TOFU + SAS** | Alarme de troca de chave (MITM), código de 6 dígitos verificável por voz e badges de confiança **✓/✗** inline ao lado dos nomes |
47
48
  | 🌐 | **LAN e internet** | Detecta Tailscale sozinho e mostra o endereço alcançável no banner |
@@ -94,6 +95,13 @@ Prefere imagem pronta? Baixe o relay do GHCR (publicado a cada release):
94
95
  docker run -p 3600:3600 ghcr.io/felipekreulich/secret-chat-lan:latest
95
96
  ```
96
97
 
98
+ **Sem Node na máquina?** Todo release traz um binário standalone do relay para
99
+ macOS e Linux (arm64/x64) — baixe da
100
+ [página de releases](https://github.com/FelipeKreulich/secret-chat-lan/releases),
101
+ `chmod +x`, rode. Nada para instalar. (O cliente TUI ainda precisa de Node/npx:
102
+ ele depende do blessed, que resolve os widgets em runtime e não pode ser
103
+ empacotado.)
104
+
97
105
  **Todo mundo** (incluindo quem hospeda):
98
106
 
99
107
  ```bash
@@ -0,0 +1,7 @@
1
+ // Entry point for the standalone relay binary (`bun build --compile`).
2
+ //
3
+ // Server-only on purpose: the TUI client depends on blessed, which resolves
4
+ // its widgets through dynamic requires that no bundler can follow. The relay
5
+ // has no such dependency, and "run a CipherMesh relay without installing
6
+ // Node" is exactly what self-hosters need.
7
+ import '../src/server/index.js';
@@ -741,6 +741,42 @@ fingerprint = SHA-256(publicKey)
741
741
  = "A1B2:C3D4:E5F6:7890"
742
742
  ```
743
743
 
744
+ ### 6.10 Hybrid Post-Quantum Handshake (X25519 + ML-KEM-768)
745
+
746
+ Protects against **"harvest now, decrypt later"**: traffic recorded today stays
747
+ unreadable to a future quantum adversary. The construction is **hybrid** — the
748
+ KEM secret is folded INTO the classical root, never replacing it, so security
749
+ is at least that of X25519 even if ML-KEM were broken (`src/crypto/PQHybrid.js`).
750
+
751
+ ```
752
+ Each client publishes an ML-KEM-768 key alongside its X25519 key (join,
753
+ peer lists, peer_joined). Per pair, at ratchet creation:
754
+
755
+ initiator (lower sessionId):
756
+ (ct, ss) = ML-KEM.Encaps(peer.pqPublicKey)
757
+ root' = BLAKE2b(root ‖ ss ‖ "ciphermesh/pq-hybrid-v3")
758
+ → ct rides in the message envelope (sealed to the recipient)
759
+
760
+ responder, on the first envelope carrying ct:
761
+ ss = ML-KEM.Decaps(ct, mySecretKey)
762
+ root' = BLAKE2b(root ‖ ss ‖ "ciphermesh/pq-hybrid-v3") ← same value
763
+ ```
764
+
765
+ **Why this can't desynchronize:** the mix happens exactly once, at ratchet
766
+ initialization, before any chain key is derived — never mid-stream. There is no
767
+ window in which one side has mixed and the other has not while messages are in
768
+ flight: an envelope without the `ct` simply fails its MAC (fails closed, never
769
+ garbles). Once the peer replies successfully, the initiator stops attaching
770
+ `ct` (~1KB saved per envelope).
771
+
772
+ **Compatibility:** a peer without `pqPublicKey` (pre-2.3 client) gets a
773
+ classical-only session — same behaviour as before. `/trustlist` shows `[PQ]`
774
+ next to peers whose session is hybrid.
775
+
776
+ **Not yet hybrid (documented, planned for v3.1):** sealed-sender envelopes and
777
+ the private-room content layer remain classical; they protect metadata and
778
+ room content respectively, not the message stream's forward secrecy.
779
+
744
780
  ### 6.9 Private Rooms (password-protected, zero-knowledge)
745
781
 
746
782
  `/create <room> <password>` creates a room the server can gate **without ever
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ciphermesh",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Secure terminal chat for the local network (LAN) with real end-to-end encryption (E2EE) using libsodium",
5
5
  "type": "module",
6
6
  "main": "src/client/index.js",
@@ -51,6 +51,7 @@
51
51
  "docker:logs": "docker compose logs -f"
52
52
  },
53
53
  "dependencies": {
54
+ "@noble/post-quantum": "0.6.1",
54
55
  "blessed": "0.1.81",
55
56
  "bonjour-service": "1.4.3",
56
57
  "boxen": "8.0.1",
@@ -206,7 +206,9 @@ export class ChatController {
206
206
  // attached the listener).
207
207
  #onConnected() {
208
208
  this.#ui.setConnectionState('online');
209
- this.#connection.send(createJoin(this.#nickname, this.#keyManager.publicKeyB64));
209
+ this.#connection.send(
210
+ createJoin(this.#nickname, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
211
+ );
210
212
  }
211
213
 
212
214
  // ── Connection event handlers ─────────────────────────────────
@@ -536,7 +538,7 @@ export class ChatController {
536
538
  // Migrate ratchet from old sessionId to new sessionId
537
539
  this.#handshake.migrateRatchet(oldSid, peer.sessionId);
538
540
  } else if (!oldSid) {
539
- this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
541
+ this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
540
542
  }
541
543
 
542
544
  this.#checkTrust(peer.nickname, peer.publicKey);
@@ -740,7 +742,7 @@ export class ChatController {
740
742
  publicKey: peer.publicKey,
741
743
  rooms: new Set([room]),
742
744
  });
743
- this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
745
+ this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
744
746
  }
745
747
  this.#checkTrust(peer.nickname, peer.publicKey);
746
748
  this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname: peer.nickname, room });
@@ -864,6 +866,7 @@ export class ChatController {
864
866
  ephPub,
865
867
  msg.payload.counter,
866
868
  msg.payload.previousCounter,
869
+ msg.payload.pqCiphertext ? Buffer.from(msg.payload.pqCiphertext, 'base64') : null,
867
870
  );
868
871
  }
869
872
 
@@ -1516,7 +1519,7 @@ export class ChatController {
1516
1519
  break;
1517
1520
  }
1518
1521
  this.#ui.addInfoMessage('Trust status:');
1519
- for (const p of peerList) {
1522
+ for (const [sid, p] of this.#peers) {
1520
1523
  const record = this.#trustStore.getPeerRecord(p.nickname);
1521
1524
  let status;
1522
1525
  if (!record) {
@@ -1526,8 +1529,11 @@ export class ChatController {
1526
1529
  } else {
1527
1530
  status = 'trusted (TOFU)';
1528
1531
  }
1529
- this.#ui.addInfoMessage(` ${p.nickname}: ${status}`);
1532
+ // [PQ] = this session's ratchet root also includes an ML-KEM secret.
1533
+ const pq = this.#handshake.isHybrid(sid) ? ' {green-fg}[PQ]{/green-fg}' : '';
1534
+ this.#ui.addInfoMessage(` ${p.nickname}: ${status}${pq}`);
1530
1535
  }
1536
+ this.#ui.addInfoMessage(' [PQ] = hybrid post-quantum session (X25519 + ML-KEM-768)');
1531
1537
  break;
1532
1538
  }
1533
1539
 
@@ -2440,7 +2446,9 @@ export class ChatController {
2440
2446
  // "nickname taken"): the server still accepts a JOIN on this socket.
2441
2447
  this.#nickname = newNick;
2442
2448
  this.#ui.setNickname(newNick);
2443
- this.#connection.send(createJoin(newNick, this.#keyManager.publicKeyB64));
2449
+ this.#connection.send(
2450
+ createJoin(newNick, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
2451
+ );
2444
2452
  this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
2445
2453
  break;
2446
2454
  }
@@ -2668,7 +2676,7 @@ export class ChatController {
2668
2676
  rooms: new Set([msg.room]),
2669
2677
  });
2670
2678
  if (!this.#handshake.getRatchet(peer.sessionId)) {
2671
- this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
2679
+ this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
2672
2680
  }
2673
2681
  this.#checkTrust(peer.nickname, peer.publicKey);
2674
2682
  }
@@ -2703,7 +2711,7 @@ export class ChatController {
2703
2711
  rooms: new Set([msg.room]),
2704
2712
  });
2705
2713
  if (!this.#handshake.getRatchet(peer.sessionId)) {
2706
- this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
2714
+ this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
2707
2715
  }
2708
2716
  }
2709
2717
  this.#checkTrust(peer.nickname, peer.publicKey);
@@ -1,6 +1,7 @@
1
1
  import sodium from 'sodium-native';
2
2
  import { RATCHET_MAX_SKIP, RATCHET_SKIP_KEY_MAX_AGE_MS } from '../shared/constants.js';
3
3
  import { padMessage, unpadSecure } from './MessageCrypto.js';
4
+ import { pqEncapsulate, pqDecapsulate, mixPQIntoRoot } from './PQHybrid.js';
4
5
 
5
6
  const SCALARMULT_BYTES = 32;
6
7
  const KEY_SIZE = 32;
@@ -18,14 +19,20 @@ export class DoubleRatchet {
18
19
  #skippedKeys; // Map<"ephHex:counter", { msgKey, timestamp }>
19
20
  #initialized;
20
21
  #needSendRatchet; // true when we need a DH ratchet step before next send
22
+ #pqCiphertext; // KEM ct to advertise while the peer may not have mixed yet
23
+ #pqSecretKey; // our ML-KEM secret key (responder side)
24
+ #pqApplied; // true once a KEM secret is folded into the root
21
25
 
22
26
  /**
23
27
  * @param {string} mySessionId
24
28
  * @param {string} peerSessionId
25
29
  * @param {Buffer} myStaticSecretKey
26
30
  * @param {Buffer} peerStaticPublicKey
31
+ * @param {object} [pq] - hybrid post-quantum material (optional)
32
+ * @param {Buffer} [pq.peerPublicKey] - peer's ML-KEM public key
33
+ * @param {Buffer} [pq.mySecretKey] - our ML-KEM secret key
27
34
  */
28
- constructor(mySessionId, peerSessionId, myStaticSecretKey, peerStaticPublicKey) {
35
+ constructor(mySessionId, peerSessionId, myStaticSecretKey, peerStaticPublicKey, pq = null) {
29
36
  this.#skippedKeys = new Map();
30
37
  this.#sendCounter = 0;
31
38
  this.#recvCounter = 0;
@@ -47,6 +54,29 @@ export class DoubleRatchet {
47
54
 
48
55
  const isInitiator = mySessionId < peerSessionId;
49
56
 
57
+ // ── Hybrid post-quantum (optional) ────────────────────────
58
+ // The initiator encapsulates to the peer's ML-KEM key right here and
59
+ // folds the secret into the root BEFORE any chain is derived; the
60
+ // ciphertext rides along in outgoing envelopes so the responder can
61
+ // decapsulate and fold the same secret before its first decrypt. Because
62
+ // the mix only ever happens at initialization, no in-flight message can
63
+ // straddle the change — there is nothing to desynchronize.
64
+ this.#pqCiphertext = null;
65
+ this.#pqSecretKey = pq?.mySecretKey || null;
66
+ this.#pqApplied = false;
67
+
68
+ if (isInitiator && pq?.peerPublicKey) {
69
+ const encaps = pqEncapsulate(pq.peerPublicKey);
70
+ if (encaps) {
71
+ const mixed = mixPQIntoRoot(this.#rootKey, encaps.sharedSecret);
72
+ sodium.sodium_memzero(this.#rootKey);
73
+ this.#rootKey = mixed;
74
+ sodium.sodium_memzero(encaps.sharedSecret);
75
+ this.#pqCiphertext = encaps.ciphertext;
76
+ this.#pqApplied = true;
77
+ }
78
+ }
79
+
50
80
  if (isInitiator) {
51
81
  // Initiator generates ephemeral keypair immediately
52
82
  this.#myEphKeyPair = this.#generateEphemeralKeyPair();
@@ -185,13 +215,24 @@ export class DoubleRatchet {
185
215
  // Wipe message key immediately
186
216
  sodium.sodium_memzero(messageKey);
187
217
 
188
- return {
218
+ const envelope = {
189
219
  ciphertext,
190
220
  nonce,
191
221
  ephemeralPublicKey: this.#myEphKeyPair.publicKey,
192
222
  counter,
193
223
  previousCounter: this.#previousSendCount,
194
224
  };
225
+ // Keep advertising the KEM ciphertext until the peer answers (their reply
226
+ // proves they folded it in). Cheap: ~1KB on a handful of messages.
227
+ if (this.#pqCiphertext) {
228
+ envelope.pqCiphertext = this.#pqCiphertext;
229
+ }
230
+ return envelope;
231
+ }
232
+
233
+ /** True when this ratchet's root includes an ML-KEM secret. */
234
+ get isHybrid() {
235
+ return this.#pqApplied;
195
236
  }
196
237
 
197
238
  // ── Decrypt ─────────────────────────────────────────────────
@@ -203,13 +244,27 @@ export class DoubleRatchet {
203
244
  * @param {Buffer} ephPub - sender's ephemeral public key
204
245
  * @param {number} counter
205
246
  * @param {number} prevCounter
247
+ * @param {Buffer} [pqCt] - sender's ML-KEM ciphertext (hybrid handshake)
206
248
  * @returns {Buffer|null} plaintext or null on failure
207
249
  */
208
- decrypt(ciphertext, nonce, ephPub, counter, prevCounter) {
250
+ decrypt(ciphertext, nonce, ephPub, counter, prevCounter, pqCt = null) {
209
251
  if (!this.#initialized) {
210
252
  return null;
211
253
  }
212
254
 
255
+ // Hybrid: fold the peer's KEM secret into the root before deriving any
256
+ // chain from it. Once only — the initiator did the same at construction.
257
+ if (pqCt && !this.#pqApplied && this.#pqSecretKey) {
258
+ const ss = pqDecapsulate(pqCt, this.#pqSecretKey);
259
+ if (ss) {
260
+ const mixed = mixPQIntoRoot(this.#rootKey, ss);
261
+ sodium.sodium_memzero(this.#rootKey);
262
+ this.#rootKey = mixed;
263
+ sodium.sodium_memzero(ss);
264
+ this.#pqApplied = true;
265
+ }
266
+ }
267
+
213
268
  // Reject malformed inputs before any allocation or crypto. A short
214
269
  // ciphertext (< MAC), wrong-size nonce/key, or non-integer counter from a
215
270
  // hostile peer must return null — never crash (Buffer.alloc(-2)) or desync.
@@ -347,6 +402,10 @@ export class DoubleRatchet {
347
402
  this.#recvChainKey = txChainKey;
348
403
  this.#recvCounter = txCounter;
349
404
 
405
+ // A message we could actually decrypt proves the peer's root matches ours,
406
+ // so they already folded the KEM secret — stop paying ~1KB per envelope.
407
+ this.#pqCiphertext = null;
408
+
350
409
  this.#cleanupSkippedKeys();
351
410
  return result;
352
411
  }
@@ -456,6 +515,8 @@ export class DoubleRatchet {
456
515
  }
457
516
  this.#skippedKeys.clear();
458
517
  this.#peerEphPublicKey = null;
518
+ this.#pqCiphertext = null;
519
+ this.#pqSecretKey = null;
459
520
  this.#initialized = false;
460
521
  }
461
522
 
@@ -490,6 +551,10 @@ export class DoubleRatchet {
490
551
  initialized: this.#initialized,
491
552
  needSendRatchet: this.#needSendRatchet,
492
553
  skippedKeys: skipped,
554
+ // Hybrid state: the root is already mixed, so `pqApplied` must survive
555
+ // to keep a restored session from folding a second secret in.
556
+ pqApplied: this.#pqApplied,
557
+ pqCiphertext: this.#pqCiphertext?.toString('base64') || null,
493
558
  };
494
559
  }
495
560
 
@@ -533,6 +598,8 @@ export class DoubleRatchet {
533
598
  r.#sendCounter = data.sendCounter;
534
599
  r.#recvCounter = data.recvCounter;
535
600
  r.#previousSendCount = data.previousSendCount;
601
+ r.#pqApplied = !!data.pqApplied;
602
+ r.#pqCiphertext = data.pqCiphertext ? Buffer.from(data.pqCiphertext, 'base64') : null;
536
603
 
537
604
  if (r.#myEphKeyPair) {
538
605
  sodium.sodium_memzero(r.#myEphKeyPair.secretKey);
@@ -5,6 +5,7 @@ import { DoubleRatchet } from './DoubleRatchet.js';
5
5
  export class Handshake {
6
6
  #keyManager;
7
7
  #peerKeys; // Map<sessionId, Buffer(publicKey)>
8
+ #peerPQKeys; // Map<sessionId, Buffer(ML-KEM public key)>
8
9
  #previousPeerKeys; // Map<sessionId, { publicKey, timer }>
9
10
  #ratchets; // Map<sessionId, DoubleRatchet>
10
11
  #mySessionId;
@@ -12,11 +13,26 @@ export class Handshake {
12
13
  constructor(keyManager) {
13
14
  this.#keyManager = keyManager;
14
15
  this.#peerKeys = new Map();
16
+ this.#peerPQKeys = new Map();
15
17
  this.#previousPeerKeys = new Map();
16
18
  this.#ratchets = new Map();
17
19
  this.#mySessionId = null;
18
20
  }
19
21
 
22
+ // Hybrid material handed to every new ratchet: the peer's KEM public key
23
+ // (absent for pre-v3 clients → classical-only) plus our KEM secret key.
24
+ #pqFor(peerId) {
25
+ return {
26
+ peerPublicKey: this.#peerPQKeys.get(peerId) || null,
27
+ mySecretKey: this.#keyManager.pqSecretKey || null,
28
+ };
29
+ }
30
+
31
+ /** True when this peer's ratchet root includes an ML-KEM secret. */
32
+ isHybrid(peerId) {
33
+ return this.#ratchets.get(peerId)?.isHybrid === true;
34
+ }
35
+
20
36
  /**
21
37
  * Set our session ID (called after JOIN_ACK).
22
38
  * Also initializes ratchets for any already-registered peers.
@@ -29,7 +45,13 @@ export class Handshake {
29
45
  if (!this.#ratchets.has(peerId)) {
30
46
  this.#ratchets.set(
31
47
  peerId,
32
- new DoubleRatchet(this.#mySessionId, peerId, this.#keyManager.secretKey, pubKey),
48
+ new DoubleRatchet(
49
+ this.#mySessionId,
50
+ peerId,
51
+ this.#keyManager.secretKey,
52
+ pubKey,
53
+ this.#pqFor(peerId),
54
+ ),
33
55
  );
34
56
  }
35
57
  }
@@ -37,8 +59,9 @@ export class Handshake {
37
59
 
38
60
  /**
39
61
  * Register a peer's public key for future encryption/decryption.
62
+ * @param {string|Buffer} [peerPQPublicKey] - their ML-KEM key (hybrid)
40
63
  */
41
- registerPeer(peerId, peerPublicKey) {
64
+ registerPeer(peerId, peerPublicKey, peerPQPublicKey = null) {
42
65
  const pubBuf = Buffer.isBuffer(peerPublicKey)
43
66
  ? peerPublicKey
44
67
  : Buffer.from(peerPublicKey, 'base64');
@@ -48,12 +71,24 @@ export class Handshake {
48
71
  }
49
72
 
50
73
  this.#peerKeys.set(peerId, Buffer.from(pubBuf));
74
+ if (peerPQPublicKey) {
75
+ const pq = Buffer.isBuffer(peerPQPublicKey)
76
+ ? peerPQPublicKey
77
+ : Buffer.from(peerPQPublicKey, 'base64');
78
+ this.#peerPQKeys.set(peerId, pq);
79
+ }
51
80
 
52
81
  // Create ratchet if we already have our session ID
53
82
  if (this.#mySessionId && !this.#ratchets.has(peerId)) {
54
83
  this.#ratchets.set(
55
84
  peerId,
56
- new DoubleRatchet(this.#mySessionId, peerId, this.#keyManager.secretKey, pubBuf),
85
+ new DoubleRatchet(
86
+ this.#mySessionId,
87
+ peerId,
88
+ this.#keyManager.secretKey,
89
+ pubBuf,
90
+ this.#pqFor(peerId),
91
+ ),
57
92
  );
58
93
  }
59
94
  }
@@ -130,6 +165,7 @@ export class Handshake {
130
165
  */
131
166
  removePeer(peerId) {
132
167
  this.#peerKeys.delete(peerId);
168
+ this.#peerPQKeys.delete(peerId);
133
169
  const prev = this.#previousPeerKeys.get(peerId);
134
170
  if (prev) {
135
171
  clearTimeout(prev.timer);
@@ -157,6 +193,11 @@ export class Handshake {
157
193
  this.#peerKeys.delete(oldPeerId);
158
194
  this.#peerKeys.set(newPeerId, peerKey);
159
195
  }
196
+ const pqKey = this.#peerPQKeys.get(oldPeerId);
197
+ if (pqKey) {
198
+ this.#peerPQKeys.delete(oldPeerId);
199
+ this.#peerPQKeys.set(newPeerId, pqKey);
200
+ }
160
201
  const prevKey = this.#previousPeerKeys.get(oldPeerId);
161
202
  if (prevKey) {
162
203
  this.#previousPeerKeys.delete(oldPeerId);
@@ -2,6 +2,7 @@
2
2
  import sodium from 'sodium-native';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { KEY_ROTATION_GRACE_MS } from '../shared/constants.js';
5
+ import { generatePQKeyPair } from './PQHybrid.js';
5
6
 
6
7
  export class KeyManager {
7
8
  #publicKey;
@@ -10,6 +11,7 @@ export class KeyManager {
10
11
  #previousPublicKey;
11
12
  #previousSecretKey;
12
13
  #graceTimer;
14
+ #pqKeyPair; // ML-KEM-768 — the hybrid half (see crypto/PQHybrid.js)
13
15
 
14
16
  constructor() {
15
17
  this.#publicKey = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES);
@@ -19,10 +21,25 @@ export class KeyManager {
19
21
  this.#graceTimer = null;
20
22
 
21
23
  sodium.crypto_box_keypair(this.#publicKey, this.#secretKey);
24
+ this.#pqKeyPair = generatePQKeyPair();
22
25
 
23
26
  this.#fingerprint = KeyManager.computeFingerprint(this.#publicKey);
24
27
  }
25
28
 
29
+ // The identity fingerprint stays X25519-only on purpose: it is what users
30
+ // already verified out-of-band, and the KEM key adds no authentication.
31
+ get pqPublicKey() {
32
+ return this.#pqKeyPair.publicKey;
33
+ }
34
+
35
+ get pqPublicKeyB64() {
36
+ return this.#pqKeyPair.publicKey.toString('base64');
37
+ }
38
+
39
+ get pqSecretKey() {
40
+ return this.#pqKeyPair.secretKey;
41
+ }
42
+
26
43
  get publicKey() {
27
44
  return this.#publicKey;
28
45
  }
@@ -114,6 +131,8 @@ export class KeyManager {
114
131
  return {
115
132
  publicKey: this.#publicKey.toString('base64'),
116
133
  secretKey: this.#secretKey.toString('base64'),
134
+ pqPublicKey: this.#pqKeyPair.publicKey.toString('base64'),
135
+ pqSecretKey: this.#pqKeyPair.secretKey.toString('base64'),
117
136
  };
118
137
  }
119
138
 
@@ -129,6 +148,14 @@ export class KeyManager {
129
148
  km.#fingerprint = KeyManager.computeFingerprint(km.#publicKey);
130
149
  km.#previousPublicKey = null;
131
150
  km.#previousSecretKey = null;
151
+ // Restore the KEM pair when present; older backups simply get the fresh
152
+ // one generated above (peers re-encapsulate on the next handshake).
153
+ if (data.pqPublicKey && data.pqSecretKey) {
154
+ km.#pqKeyPair = {
155
+ publicKey: Buffer.from(data.pqPublicKey, 'base64'),
156
+ secretKey: Buffer.from(data.pqSecretKey, 'base64'),
157
+ };
158
+ }
132
159
  return km;
133
160
  }
134
161
  }
@@ -0,0 +1,76 @@
1
+ // Hybrid post-quantum layer: ML-KEM-768 (FIPS 203) mixed INTO the classical
2
+ // X25519 root key, never replacing it. Security is therefore >= the classical
3
+ // construction: an attacker must break BOTH to read a message. This is the
4
+ // conservative pattern Signal (PQXDH) and Apple (PQ3) adopted, and it targets
5
+ // "harvest now, decrypt later" — traffic recorded today stays unreadable to a
6
+ // future quantum adversary.
7
+ //
8
+ // sodium-native has no ML-KEM, so this uses @noble/post-quantum (audited,
9
+ // pure JS). Only the KEM comes from there; all symmetric crypto stays libsodium.
10
+ import sodium from 'sodium-native';
11
+ import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
12
+
13
+ export const PQ_PUBLIC_KEY_SIZE = 1184; // ML-KEM-768 encapsulation key
14
+ export const PQ_SECRET_KEY_SIZE = 2400; // ML-KEM-768 decapsulation key
15
+ export const PQ_CIPHERTEXT_SIZE = 1088;
16
+ export const PQ_SHARED_SECRET_SIZE = 32;
17
+
18
+ const PQ_MIX_CONTEXT = 'ciphermesh/pq-hybrid-v3';
19
+
20
+ /** Generate an ML-KEM-768 keypair. Buffers, to match the rest of the codebase. */
21
+ export function generatePQKeyPair() {
22
+ const { publicKey, secretKey } = ml_kem768.keygen();
23
+ return { publicKey: Buffer.from(publicKey), secretKey: Buffer.from(secretKey) };
24
+ }
25
+
26
+ /**
27
+ * Encapsulate to a peer's ML-KEM public key.
28
+ * @returns {{ ciphertext: Buffer, sharedSecret: Buffer }|null} null on bad input
29
+ */
30
+ export function pqEncapsulate(peerPublicKey) {
31
+ try {
32
+ const pk = Buffer.isBuffer(peerPublicKey)
33
+ ? peerPublicKey
34
+ : Buffer.from(peerPublicKey, 'base64');
35
+ if (pk.length !== PQ_PUBLIC_KEY_SIZE) {
36
+ return null;
37
+ }
38
+ const { cipherText, sharedSecret } = ml_kem768.encapsulate(new Uint8Array(pk));
39
+ return { ciphertext: Buffer.from(cipherText), sharedSecret: Buffer.from(sharedSecret) };
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Decapsulate a ciphertext with our ML-KEM secret key.
47
+ * @returns {Buffer|null} 32-byte shared secret, or null if it doesn't apply
48
+ */
49
+ export function pqDecapsulate(ciphertext, secretKey) {
50
+ try {
51
+ const ct = Buffer.isBuffer(ciphertext) ? ciphertext : Buffer.from(ciphertext, 'base64');
52
+ if (ct.length !== PQ_CIPHERTEXT_SIZE || !secretKey) {
53
+ return null;
54
+ }
55
+ const ss = ml_kem768.decapsulate(new Uint8Array(ct), new Uint8Array(secretKey));
56
+ return Buffer.from(ss);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Fold a KEM shared secret into an existing root key:
64
+ * root' = BLAKE2b(root ‖ ss ‖ context)
65
+ *
66
+ * Because the classical root is an input, the result is at least as strong as
67
+ * the classical key even if ML-KEM were broken outright. Returns a NEW locked
68
+ * buffer; the caller owns zeroing the old root.
69
+ */
70
+ export function mixPQIntoRoot(rootKey, sharedSecret) {
71
+ const input = Buffer.concat([rootKey, sharedSecret, Buffer.from(PQ_MIX_CONTEXT, 'utf-8')]);
72
+ const mixed = sodium.sodium_malloc(32);
73
+ sodium.crypto_generichash(mixed, input);
74
+ sodium.sodium_memzero(input);
75
+ return mixed;
76
+ }
@@ -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) {
@@ -86,7 +87,11 @@ export function validateJoin(msg) {
86
87
  if (!isValidBase64(msg.publicKey, PUBLIC_KEY_SIZE)) {
87
88
  return { valid: false, error: 'Invalid public key' };
88
89
  }
89
- return { valid: true, nickname: nick };
90
+ // Optional ML-KEM-768 key (hybrid PQ). Absent = classical-only client.
91
+ if (msg.pqPublicKey !== undefined && !isValidBase64(msg.pqPublicKey, PQ_PUBLIC_KEY_SIZE)) {
92
+ return { valid: false, error: 'Invalid post-quantum public key' };
93
+ }
94
+ return { valid: true, nickname: nick, pqPublicKey: msg.pqPublicKey || null };
90
95
  }
91
96
 
92
97
  // 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
  ),