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.
@@ -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
+ }