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
|
@@ -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
|
-
|
|
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);
|
package/src/crypto/Handshake.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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);
|
package/src/crypto/KeyManager.js
CHANGED
|
@@ -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
|
+
}
|