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.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/README.pt-BR.md +253 -0
- package/bin/ciphermesh.js +34 -0
- package/docs/ARCHITECTURE.md +1188 -0
- package/docs/SETUP.md +305 -0
- package/docs/demo.svg +46 -0
- package/package.json +87 -0
- package/src/client/ChatController.js +2476 -0
- package/src/client/Connection.js +129 -0
- package/src/client/FileTransfer.js +488 -0
- package/src/client/ImagePreview.js +88 -0
- package/src/client/UI.js +1830 -0
- package/src/client/index.js +231 -0
- package/src/crypto/CertPinStore.js +79 -0
- package/src/crypto/DeniableEncrypt.js +53 -0
- package/src/crypto/DoubleRatchet.js +574 -0
- package/src/crypto/Handshake.js +219 -0
- package/src/crypto/HistoryStore.js +241 -0
- package/src/crypto/IdentityBackup.js +70 -0
- package/src/crypto/KeyManager.js +134 -0
- package/src/crypto/MessageCrypto.js +181 -0
- package/src/crypto/NonceManager.js +72 -0
- package/src/crypto/SealedSender.js +58 -0
- package/src/crypto/SenderKey.js +204 -0
- package/src/crypto/StateManager.js +138 -0
- package/src/crypto/TrustStore.js +216 -0
- package/src/p2p/Discovery.js +80 -0
- package/src/p2p/P2PChatController.js +1856 -0
- package/src/p2p/PeerConnectionManager.js +252 -0
- package/src/p2p/PeerServer.js +68 -0
- package/src/p2p/index.js +219 -0
- package/src/protocol/messages.js +138 -0
- package/src/protocol/validators.js +175 -0
- package/src/server/CertManager.js +173 -0
- package/src/server/MessageRouter.js +80 -0
- package/src/server/OfflineQueue.js +124 -0
- package/src/server/SessionManager.js +296 -0
- package/src/server/WebSocketServer.js +632 -0
- package/src/server/index.js +89 -0
- package/src/shared/AuditLog.js +91 -0
- package/src/shared/PluginManager.js +83 -0
- package/src/shared/banner.js +271 -0
- package/src/shared/commandSuggest.js +59 -0
- package/src/shared/config.js +90 -0
- package/src/shared/constants.js +126 -0
- package/src/shared/coverTraffic.js +34 -0
- package/src/shared/dnd.js +60 -0
- package/src/shared/emoji.js +17 -0
- package/src/shared/fuzzy.js +40 -0
- package/src/shared/invite.js +61 -0
- package/src/shared/keyArt.js +66 -0
- package/src/shared/logger.js +38 -0
- package/src/shared/panic.js +38 -0
- package/src/shared/prompt.js +31 -0
- package/src/shared/terminalGraphics.js +72 -0
- package/src/shared/themes.js +36 -0
- 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
|
+
}
|