ciphermesh 1.0.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/README.pt-BR.md +253 -0
  4. package/bin/ciphermesh.js +34 -0
  5. package/docs/ARCHITECTURE.md +1188 -0
  6. package/docs/SETUP.md +305 -0
  7. package/docs/demo.svg +46 -0
  8. package/package.json +87 -0
  9. package/src/client/ChatController.js +2476 -0
  10. package/src/client/Connection.js +129 -0
  11. package/src/client/FileTransfer.js +488 -0
  12. package/src/client/ImagePreview.js +88 -0
  13. package/src/client/UI.js +1830 -0
  14. package/src/client/index.js +231 -0
  15. package/src/crypto/CertPinStore.js +79 -0
  16. package/src/crypto/DeniableEncrypt.js +53 -0
  17. package/src/crypto/DoubleRatchet.js +574 -0
  18. package/src/crypto/Handshake.js +219 -0
  19. package/src/crypto/HistoryStore.js +241 -0
  20. package/src/crypto/IdentityBackup.js +70 -0
  21. package/src/crypto/KeyManager.js +134 -0
  22. package/src/crypto/MessageCrypto.js +181 -0
  23. package/src/crypto/NonceManager.js +72 -0
  24. package/src/crypto/SealedSender.js +58 -0
  25. package/src/crypto/SenderKey.js +204 -0
  26. package/src/crypto/StateManager.js +138 -0
  27. package/src/crypto/TrustStore.js +216 -0
  28. package/src/p2p/Discovery.js +80 -0
  29. package/src/p2p/P2PChatController.js +1856 -0
  30. package/src/p2p/PeerConnectionManager.js +252 -0
  31. package/src/p2p/PeerServer.js +68 -0
  32. package/src/p2p/index.js +219 -0
  33. package/src/protocol/messages.js +138 -0
  34. package/src/protocol/validators.js +175 -0
  35. package/src/server/CertManager.js +173 -0
  36. package/src/server/MessageRouter.js +80 -0
  37. package/src/server/OfflineQueue.js +124 -0
  38. package/src/server/SessionManager.js +296 -0
  39. package/src/server/WebSocketServer.js +632 -0
  40. package/src/server/index.js +89 -0
  41. package/src/shared/AuditLog.js +91 -0
  42. package/src/shared/PluginManager.js +83 -0
  43. package/src/shared/banner.js +271 -0
  44. package/src/shared/commandSuggest.js +59 -0
  45. package/src/shared/config.js +90 -0
  46. package/src/shared/constants.js +126 -0
  47. package/src/shared/coverTraffic.js +34 -0
  48. package/src/shared/dnd.js +60 -0
  49. package/src/shared/emoji.js +17 -0
  50. package/src/shared/fuzzy.js +40 -0
  51. package/src/shared/invite.js +61 -0
  52. package/src/shared/keyArt.js +66 -0
  53. package/src/shared/logger.js +38 -0
  54. package/src/shared/panic.js +38 -0
  55. package/src/shared/prompt.js +31 -0
  56. package/src/shared/terminalGraphics.js +72 -0
  57. package/src/shared/themes.js +36 -0
  58. package/src/shared/voiceNote.js +128 -0
@@ -0,0 +1,574 @@
1
+ import sodium from 'sodium-native';
2
+ import { RATCHET_MAX_SKIP, RATCHET_SKIP_KEY_MAX_AGE_MS } from '../shared/constants.js';
3
+ import { padMessage, unpadSecure } from './MessageCrypto.js';
4
+
5
+ const SCALARMULT_BYTES = 32;
6
+ const KEY_SIZE = 32;
7
+ const NONCE_SIZE = 24;
8
+
9
+ export class DoubleRatchet {
10
+ #rootKey;
11
+ #sendChainKey;
12
+ #recvChainKey;
13
+ #sendCounter;
14
+ #recvCounter;
15
+ #previousSendCount;
16
+ #myEphKeyPair;
17
+ #peerEphPublicKey;
18
+ #skippedKeys; // Map<"ephHex:counter", { msgKey, timestamp }>
19
+ #initialized;
20
+ #needSendRatchet; // true when we need a DH ratchet step before next send
21
+
22
+ /**
23
+ * @param {string} mySessionId
24
+ * @param {string} peerSessionId
25
+ * @param {Buffer} myStaticSecretKey
26
+ * @param {Buffer} peerStaticPublicKey
27
+ */
28
+ constructor(mySessionId, peerSessionId, myStaticSecretKey, peerStaticPublicKey) {
29
+ this.#skippedKeys = new Map();
30
+ this.#sendCounter = 0;
31
+ this.#recvCounter = 0;
32
+ this.#previousSendCount = 0;
33
+ this.#sendChainKey = null;
34
+ this.#recvChainKey = null;
35
+ this.#myEphKeyPair = null;
36
+ this.#peerEphPublicKey = null;
37
+ this.#initialized = false;
38
+ this.#needSendRatchet = true;
39
+
40
+ // Derive initial rootKey from static DH
41
+ const dhOutput = sodium.sodium_malloc(SCALARMULT_BYTES);
42
+ sodium.crypto_scalarmult(dhOutput, myStaticSecretKey, peerStaticPublicKey);
43
+
44
+ this.#rootKey = sodium.sodium_malloc(KEY_SIZE);
45
+ sodium.crypto_generichash(this.#rootKey, dhOutput);
46
+ sodium.sodium_memzero(dhOutput);
47
+
48
+ const isInitiator = mySessionId < peerSessionId;
49
+
50
+ if (isInitiator) {
51
+ // Initiator generates ephemeral keypair immediately
52
+ this.#myEphKeyPair = this.#generateEphemeralKeyPair();
53
+ // Use peer's static public key as initial "ephemeral" until they respond
54
+ this.#peerEphPublicKey = Buffer.from(peerStaticPublicKey);
55
+ } else {
56
+ // Responder: use copy of static secret key for initial DH on first receive.
57
+ // Will be wiped and replaced with a true ephemeral on first send.
58
+ const secretCopy = sodium.sodium_malloc(sodium.crypto_box_SECRETKEYBYTES);
59
+ myStaticSecretKey.copy(secretCopy);
60
+ this.#myEphKeyPair = { publicKey: null, secretKey: secretCopy };
61
+ this.#peerEphPublicKey = null;
62
+ }
63
+
64
+ this.#initialized = true;
65
+ }
66
+
67
+ get isInitialized() {
68
+ return this.#initialized;
69
+ }
70
+
71
+ // ── Key generation ──────────────────────────────────────────
72
+
73
+ #generateEphemeralKeyPair() {
74
+ const publicKey = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES);
75
+ const secretKey = sodium.sodium_malloc(sodium.crypto_box_SECRETKEYBYTES);
76
+ sodium.crypto_box_keypair(publicKey, secretKey);
77
+ return { publicKey, secretKey };
78
+ }
79
+
80
+ // ── KDF functions ───────────────────────────────────────────
81
+
82
+ /**
83
+ * KDF_RK: Root Key ratchet. Produces new rootKey + chainKey from DH output.
84
+ * BLAKE2b-512(key=rootKey, input=dhOutput) → 64 bytes
85
+ * First 32B = newRootKey, last 32B = chainKey
86
+ */
87
+ #kdfRK(dhOutput) {
88
+ const output = sodium.sodium_malloc(64);
89
+ sodium.crypto_generichash(output, dhOutput, this.#rootKey);
90
+
91
+ const newRootKey = sodium.sodium_malloc(KEY_SIZE);
92
+ output.copy(newRootKey, 0, 0, 32);
93
+
94
+ const chainKey = sodium.sodium_malloc(KEY_SIZE);
95
+ output.copy(chainKey, 0, 32, 64);
96
+
97
+ sodium.sodium_memzero(this.#rootKey);
98
+ this.#rootKey = newRootKey;
99
+
100
+ sodium.sodium_memzero(output);
101
+ return chainKey;
102
+ }
103
+
104
+ /**
105
+ * KDF_CK: Chain Key ratchet. Produces messageKey + nextChainKey.
106
+ * messageKey = BLAKE2b-256(key=chainKey, input=0x01)
107
+ * nextChainKey = BLAKE2b-256(key=chainKey, input=0x02)
108
+ */
109
+ #kdfCK(chainKey) {
110
+ const msgKeyInput = Buffer.from([0x01]);
111
+ const nextCKInput = Buffer.from([0x02]);
112
+
113
+ const messageKey = sodium.sodium_malloc(KEY_SIZE);
114
+ sodium.crypto_generichash(messageKey, msgKeyInput, chainKey);
115
+
116
+ const nextChainKey = sodium.sodium_malloc(KEY_SIZE);
117
+ sodium.crypto_generichash(nextChainKey, nextCKInput, chainKey);
118
+
119
+ sodium.sodium_memzero(chainKey);
120
+ return { messageKey, nextChainKey };
121
+ }
122
+
123
+ // ── DH helper ───────────────────────────────────────────────
124
+
125
+ #dh(mySecretKey, theirPublicKey) {
126
+ const output = sodium.sodium_malloc(SCALARMULT_BYTES);
127
+ sodium.crypto_scalarmult(output, mySecretKey, theirPublicKey);
128
+ return output;
129
+ }
130
+
131
+ // ── Encrypt ─────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Encrypt plaintext for the peer.
135
+ * @param {string|Buffer} plaintext
136
+ * @returns {{ ciphertext: Buffer, nonce: Buffer, ephemeralPublicKey: Buffer, counter: number, previousCounter: number }}
137
+ */
138
+ encrypt(plaintext) {
139
+ if (!this.#initialized) {
140
+ throw new Error('Ratchet not initialized');
141
+ }
142
+
143
+ // DH ratchet step if needed (first send or after receiving)
144
+ if (this.#needSendRatchet) {
145
+ // Must have peer's ephemeral key before we can ratchet
146
+ if (!this.#peerEphPublicKey) {
147
+ throw new Error('No peer ephemeral key yet');
148
+ }
149
+
150
+ this.#previousSendCount = this.#sendCounter;
151
+ this.#sendCounter = 0;
152
+
153
+ // Generate new ephemeral keypair
154
+ if (this.#myEphKeyPair) {
155
+ // Wipe old secret key
156
+ sodium.sodium_memzero(this.#myEphKeyPair.secretKey);
157
+ }
158
+ this.#myEphKeyPair = this.#generateEphemeralKeyPair();
159
+
160
+ // DH ratchet: derive new send chain key
161
+ const dhOut = this.#dh(this.#myEphKeyPair.secretKey, this.#peerEphPublicKey);
162
+ this.#sendChainKey = this.#kdfRK(dhOut);
163
+ sodium.sodium_memzero(dhOut);
164
+
165
+ this.#needSendRatchet = false;
166
+ }
167
+
168
+ // Advance send chain
169
+ const { messageKey, nextChainKey } = this.#kdfCK(this.#sendChainKey);
170
+ this.#sendChainKey = nextChainKey;
171
+
172
+ // Encrypt
173
+ const message = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext, 'utf-8');
174
+ const padded = padMessage(message);
175
+ const nonce = Buffer.alloc(NONCE_SIZE);
176
+ sodium.randombytes_buf(nonce);
177
+
178
+ const ciphertext = Buffer.alloc(padded.length + sodium.crypto_secretbox_MACBYTES);
179
+ sodium.crypto_secretbox_easy(ciphertext, padded, nonce, messageKey);
180
+ sodium.sodium_memzero(padded);
181
+
182
+ const counter = this.#sendCounter;
183
+ this.#sendCounter++;
184
+
185
+ // Wipe message key immediately
186
+ sodium.sodium_memzero(messageKey);
187
+
188
+ return {
189
+ ciphertext,
190
+ nonce,
191
+ ephemeralPublicKey: this.#myEphKeyPair.publicKey,
192
+ counter,
193
+ previousCounter: this.#previousSendCount,
194
+ };
195
+ }
196
+
197
+ // ── Decrypt ─────────────────────────────────────────────────
198
+
199
+ /**
200
+ * Decrypt a ratcheted message.
201
+ * @param {Buffer} ciphertext
202
+ * @param {Buffer} nonce
203
+ * @param {Buffer} ephPub - sender's ephemeral public key
204
+ * @param {number} counter
205
+ * @param {number} prevCounter
206
+ * @returns {Buffer|null} plaintext or null on failure
207
+ */
208
+ decrypt(ciphertext, nonce, ephPub, counter, prevCounter) {
209
+ if (!this.#initialized) {
210
+ return null;
211
+ }
212
+
213
+ // Reject malformed inputs before any allocation or crypto. A short
214
+ // ciphertext (< MAC), wrong-size nonce/key, or non-integer counter from a
215
+ // hostile peer must return null — never crash (Buffer.alloc(-2)) or desync.
216
+ if (
217
+ !Buffer.isBuffer(ciphertext) ||
218
+ ciphertext.length < sodium.crypto_secretbox_MACBYTES ||
219
+ !Buffer.isBuffer(nonce) ||
220
+ nonce.length !== NONCE_SIZE ||
221
+ !Buffer.isBuffer(ephPub) ||
222
+ ephPub.length !== sodium.crypto_box_PUBLICKEYBYTES ||
223
+ !Number.isInteger(counter) ||
224
+ counter < 0 ||
225
+ !Number.isInteger(prevCounter) ||
226
+ prevCounter < 0
227
+ ) {
228
+ return null;
229
+ }
230
+
231
+ // 1. Try skipped keys first (consumes the key only if the MAC verifies).
232
+ const skippedResult = this.#trySkippedKeys(ephPub, counter, ciphertext, nonce);
233
+ if (skippedResult) {
234
+ return skippedResult;
235
+ }
236
+
237
+ // Everything below is computed on a TRANSACTION — no instance state is
238
+ // mutated until the MAC verifies. This prevents a forged or replayed
239
+ // message from desyncing the ratchet (a permanent delivery DoS).
240
+ const isNewEph = !this.#peerEphPublicKey || !ephPub.equals(this.#peerEphPublicKey);
241
+
242
+ let txRootKey = null; // set only when DH-ratcheting
243
+ let txPeerEph = null;
244
+ let txChainKey; // owned working copy of the receive chain key
245
+ let txCounter;
246
+ const txSkipped = []; // skipped keys generated in the (new/current) chain
247
+ const oldChainSkipped = []; // skipped keys from the OLD chain (new-eph case)
248
+
249
+ if (isNewEph) {
250
+ // Stash any unreceived messages of the CURRENT chain (up to prevCounter),
251
+ // derived on a COPY so the live chain is untouched until commit.
252
+ if (this.#recvChainKey && this.#peerEphPublicKey) {
253
+ const oldEphHex = this.#peerEphPublicKey.toString('hex');
254
+ let oldChain = this.#copyKey(this.#recvChainKey);
255
+ let oldCounter = this.#recvCounter;
256
+ const skip = prevCounter - oldCounter;
257
+ if (skip > RATCHET_MAX_SKIP) {
258
+ sodium.sodium_memzero(oldChain);
259
+ return null;
260
+ }
261
+ for (let i = 0; i < skip; i++) {
262
+ const { messageKey, nextChainKey } = this.#kdfCKPure(oldChain);
263
+ sodium.sodium_memzero(oldChain);
264
+ oldChain = nextChainKey;
265
+ oldChainSkipped.push({ key: `${oldEphHex}:${oldCounter}`, msgKey: messageKey });
266
+ oldCounter++;
267
+ }
268
+ sodium.sodium_memzero(oldChain);
269
+ }
270
+
271
+ // DH ratchet (pure): new root + new receive chain, without mutating state.
272
+ const dhOut = this.#dh(this.#myEphKeyPair.secretKey, ephPub);
273
+ const { newRootKey, chainKey } = this.#kdfRKPure(this.#rootKey, dhOut);
274
+ sodium.sodium_memzero(dhOut);
275
+ txRootKey = newRootKey;
276
+ txChainKey = chainKey;
277
+ txCounter = 0;
278
+ txPeerEph = Buffer.from(ephPub);
279
+ } else {
280
+ if (!this.#recvChainKey) {
281
+ return null;
282
+ }
283
+ txChainKey = this.#copyKey(this.#recvChainKey);
284
+ txCounter = this.#recvCounter;
285
+ }
286
+
287
+ // Replay / stale guard: a counter below the current chain position that was
288
+ // not covered by a stored skipped key (tried above) is a replay — reject.
289
+ if (counter < txCounter) {
290
+ this.#abortTx(txRootKey, txChainKey, txSkipped, oldChainSkipped);
291
+ return null;
292
+ }
293
+
294
+ // Skip forward within the (new/current) chain up to `counter`.
295
+ const skipInChain = counter - txCounter;
296
+ if (skipInChain > RATCHET_MAX_SKIP) {
297
+ this.#abortTx(txRootKey, txChainKey, txSkipped, oldChainSkipped);
298
+ return null;
299
+ }
300
+ const ephHex = ephPub.toString('hex');
301
+ for (let i = 0; i < skipInChain; i++) {
302
+ const { messageKey, nextChainKey } = this.#kdfCKPure(txChainKey);
303
+ sodium.sodium_memzero(txChainKey);
304
+ txChainKey = nextChainKey;
305
+ txSkipped.push({ key: `${ephHex}:${txCounter}`, msgKey: messageKey });
306
+ txCounter++;
307
+ }
308
+
309
+ // Derive the message key for `counter` and attempt decryption.
310
+ const { messageKey, nextChainKey } = this.#kdfCKPure(txChainKey);
311
+ const padded = Buffer.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES);
312
+ const valid = sodium.crypto_secretbox_open_easy(padded, ciphertext, nonce, messageKey);
313
+ sodium.sodium_memzero(messageKey);
314
+
315
+ if (!valid) {
316
+ // MAC failed → discard the whole transaction, leave state UNTOUCHED.
317
+ sodium.sodium_memzero(padded);
318
+ sodium.sodium_memzero(nextChainKey);
319
+ this.#abortTx(txRootKey, txChainKey, txSkipped, oldChainSkipped);
320
+ return null;
321
+ }
322
+
323
+ // ── COMMIT (MAC verified) ──
324
+ const result = unpadSecure(padded);
325
+
326
+ sodium.sodium_memzero(txChainKey);
327
+ txChainKey = nextChainKey;
328
+ txCounter++;
329
+
330
+ const now = Date.now();
331
+ for (const { key, msgKey } of oldChainSkipped) {
332
+ this.#skippedKeys.set(key, { msgKey, timestamp: now });
333
+ }
334
+ for (const { key, msgKey } of txSkipped) {
335
+ this.#skippedKeys.set(key, { msgKey, timestamp: now });
336
+ }
337
+
338
+ if (isNewEph) {
339
+ sodium.sodium_memzero(this.#rootKey);
340
+ this.#rootKey = txRootKey;
341
+ this.#peerEphPublicKey = txPeerEph;
342
+ this.#needSendRatchet = true;
343
+ }
344
+ if (this.#recvChainKey) {
345
+ sodium.sodium_memzero(this.#recvChainKey);
346
+ }
347
+ this.#recvChainKey = txChainKey;
348
+ this.#recvCounter = txCounter;
349
+
350
+ this.#cleanupSkippedKeys();
351
+ return result;
352
+ }
353
+
354
+ // ── Skipped keys management ─────────────────────────────────
355
+
356
+ #trySkippedKeys(ephPub, counter, ciphertext, nonce) {
357
+ const key = `${ephPub.toString('hex')}:${counter}`;
358
+ const entry = this.#skippedKeys.get(key);
359
+ if (!entry) {
360
+ return null;
361
+ }
362
+
363
+ const padded = Buffer.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES);
364
+ const valid = sodium.crypto_secretbox_open_easy(padded, ciphertext, nonce, entry.msgKey);
365
+
366
+ if (!valid) {
367
+ // Do NOT consume the key on failure — a forged message must not burn it.
368
+ sodium.sodium_memzero(padded);
369
+ return null;
370
+ }
371
+
372
+ sodium.sodium_memzero(entry.msgKey);
373
+ this.#skippedKeys.delete(key);
374
+ return unpadSecure(padded);
375
+ }
376
+
377
+ // ── Transaction helpers (non-mutating derivations) ──────────
378
+
379
+ #copyKey(key) {
380
+ const copy = sodium.sodium_malloc(key.length);
381
+ key.copy(copy);
382
+ return copy;
383
+ }
384
+
385
+ // KDF_RK without mutating this.#rootKey — returns fresh {newRootKey, chainKey}.
386
+ #kdfRKPure(rootKey, dhOutput) {
387
+ const output = sodium.sodium_malloc(64);
388
+ sodium.crypto_generichash(output, dhOutput, rootKey);
389
+
390
+ const newRootKey = sodium.sodium_malloc(KEY_SIZE);
391
+ output.copy(newRootKey, 0, 0, 32);
392
+ const chainKey = sodium.sodium_malloc(KEY_SIZE);
393
+ output.copy(chainKey, 0, 32, 64);
394
+
395
+ sodium.sodium_memzero(output);
396
+ return { newRootKey, chainKey };
397
+ }
398
+
399
+ // KDF_CK without wiping the input chainKey — returns {messageKey, nextChainKey}.
400
+ #kdfCKPure(chainKey) {
401
+ const messageKey = sodium.sodium_malloc(KEY_SIZE);
402
+ sodium.crypto_generichash(messageKey, Buffer.from([0x01]), chainKey);
403
+
404
+ const nextChainKey = sodium.sodium_malloc(KEY_SIZE);
405
+ sodium.crypto_generichash(nextChainKey, Buffer.from([0x02]), chainKey);
406
+
407
+ return { messageKey, nextChainKey };
408
+ }
409
+
410
+ #abortTx(txRootKey, txChainKey, txSkipped, oldChainSkipped) {
411
+ if (txRootKey) {
412
+ sodium.sodium_memzero(txRootKey);
413
+ }
414
+ if (txChainKey) {
415
+ sodium.sodium_memzero(txChainKey);
416
+ }
417
+ for (const { msgKey } of txSkipped) {
418
+ sodium.sodium_memzero(msgKey);
419
+ }
420
+ for (const { msgKey } of oldChainSkipped) {
421
+ sodium.sodium_memzero(msgKey);
422
+ }
423
+ }
424
+
425
+ #cleanupSkippedKeys() {
426
+ const now = Date.now();
427
+ for (const [key, entry] of this.#skippedKeys) {
428
+ if (now - entry.timestamp > RATCHET_SKIP_KEY_MAX_AGE_MS) {
429
+ sodium.sodium_memzero(entry.msgKey);
430
+ this.#skippedKeys.delete(key);
431
+ }
432
+ }
433
+ }
434
+
435
+ // ── Destroy ─────────────────────────────────────────────────
436
+
437
+ destroy() {
438
+ if (this.#rootKey) {
439
+ sodium.sodium_memzero(this.#rootKey);
440
+ this.#rootKey = null;
441
+ }
442
+ if (this.#sendChainKey) {
443
+ sodium.sodium_memzero(this.#sendChainKey);
444
+ this.#sendChainKey = null;
445
+ }
446
+ if (this.#recvChainKey) {
447
+ sodium.sodium_memzero(this.#recvChainKey);
448
+ this.#recvChainKey = null;
449
+ }
450
+ if (this.#myEphKeyPair) {
451
+ sodium.sodium_memzero(this.#myEphKeyPair.secretKey);
452
+ this.#myEphKeyPair = null;
453
+ }
454
+ for (const [, entry] of this.#skippedKeys) {
455
+ sodium.sodium_memzero(entry.msgKey);
456
+ }
457
+ this.#skippedKeys.clear();
458
+ this.#peerEphPublicKey = null;
459
+ this.#initialized = false;
460
+ }
461
+
462
+ // ── Serialization ─────────────────────────────────────────────
463
+
464
+ /**
465
+ * Serialize ratchet state to a plain object (for encrypted persistence).
466
+ */
467
+ serialize() {
468
+ const skipped = {};
469
+ for (const [key, entry] of this.#skippedKeys) {
470
+ skipped[key] = {
471
+ msgKey: entry.msgKey.toString('base64'),
472
+ timestamp: entry.timestamp,
473
+ };
474
+ }
475
+
476
+ return {
477
+ rootKey: this.#rootKey?.toString('base64') || null,
478
+ sendChainKey: this.#sendChainKey?.toString('base64') || null,
479
+ recvChainKey: this.#recvChainKey?.toString('base64') || null,
480
+ sendCounter: this.#sendCounter,
481
+ recvCounter: this.#recvCounter,
482
+ previousSendCount: this.#previousSendCount,
483
+ myEphKeyPair: this.#myEphKeyPair
484
+ ? {
485
+ publicKey: this.#myEphKeyPair.publicKey?.toString('base64') || null,
486
+ secretKey: this.#myEphKeyPair.secretKey.toString('base64'),
487
+ }
488
+ : null,
489
+ peerEphPublicKey: this.#peerEphPublicKey?.toString('base64') || null,
490
+ initialized: this.#initialized,
491
+ needSendRatchet: this.#needSendRatchet,
492
+ skippedKeys: skipped,
493
+ };
494
+ }
495
+
496
+ /**
497
+ * Reconstruct a DoubleRatchet from serialized data.
498
+ */
499
+ static deserialize(data) {
500
+ // Create a dummy instance to gain access to private fields
501
+ const dummyPub = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES);
502
+ const dummySec = sodium.sodium_malloc(sodium.crypto_box_SECRETKEYBYTES);
503
+ sodium.crypto_box_keypair(dummyPub, dummySec);
504
+
505
+ const r = new DoubleRatchet('_a', '_b', dummySec, dummyPub);
506
+
507
+ // Helper: base64 → sodium_malloc buffer
508
+ function secureFromB64(b64) {
509
+ if (!b64) {
510
+ return null;
511
+ }
512
+ const raw = Buffer.from(b64, 'base64');
513
+ const secure = sodium.sodium_malloc(raw.length);
514
+ raw.copy(secure);
515
+ sodium.sodium_memzero(raw);
516
+ return secure;
517
+ }
518
+
519
+ // Overwrite all fields with deserialized data
520
+ sodium.sodium_memzero(r.#rootKey);
521
+ r.#rootKey = secureFromB64(data.rootKey);
522
+
523
+ if (r.#sendChainKey) {
524
+ sodium.sodium_memzero(r.#sendChainKey);
525
+ }
526
+ r.#sendChainKey = secureFromB64(data.sendChainKey);
527
+
528
+ if (r.#recvChainKey) {
529
+ sodium.sodium_memzero(r.#recvChainKey);
530
+ }
531
+ r.#recvChainKey = secureFromB64(data.recvChainKey);
532
+
533
+ r.#sendCounter = data.sendCounter;
534
+ r.#recvCounter = data.recvCounter;
535
+ r.#previousSendCount = data.previousSendCount;
536
+
537
+ if (r.#myEphKeyPair) {
538
+ sodium.sodium_memzero(r.#myEphKeyPair.secretKey);
539
+ }
540
+ if (data.myEphKeyPair) {
541
+ r.#myEphKeyPair = {
542
+ publicKey: data.myEphKeyPair.publicKey
543
+ ? Buffer.from(data.myEphKeyPair.publicKey, 'base64')
544
+ : null,
545
+ secretKey: secureFromB64(data.myEphKeyPair.secretKey),
546
+ };
547
+ } else {
548
+ r.#myEphKeyPair = null;
549
+ }
550
+
551
+ r.#peerEphPublicKey = data.peerEphPublicKey
552
+ ? Buffer.from(data.peerEphPublicKey, 'base64')
553
+ : null;
554
+
555
+ r.#initialized = data.initialized;
556
+ r.#needSendRatchet = data.needSendRatchet;
557
+
558
+ // Restore skipped keys
559
+ for (const [, entry] of r.#skippedKeys) {
560
+ sodium.sodium_memzero(entry.msgKey);
561
+ }
562
+ r.#skippedKeys.clear();
563
+ if (data.skippedKeys) {
564
+ for (const [key, entry] of Object.entries(data.skippedKeys)) {
565
+ r.#skippedKeys.set(key, {
566
+ msgKey: secureFromB64(entry.msgKey),
567
+ timestamp: entry.timestamp,
568
+ });
569
+ }
570
+ }
571
+
572
+ return r;
573
+ }
574
+ }