@vex-chat/libvex 6.0.0 → 6.0.2
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/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +21 -4
- package/dist/Client.js.map +1 -1
- package/dist/storage/sqlite.d.ts +0 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +2 -19
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/utils/ratchet.d.ts +4 -0
- package/dist/utils/ratchet.d.ts.map +1 -1
- package/dist/utils/ratchet.js +56 -10
- package/dist/utils/ratchet.js.map +1 -1
- package/dist/utils/sqlSessionToCrypto.d.ts.map +1 -1
- package/dist/utils/sqlSessionToCrypto.js +2 -19
- package/dist/utils/sqlSessionToCrypto.js.map +1 -1
- package/package.json +1 -1
- package/src/Client.ts +23 -3
- package/src/__tests__/harness/shared-suite.ts +74 -0
- package/src/__tests__/ratchet.test.ts +390 -6
- package/src/storage/sqlite.ts +3 -19
- package/src/utils/ratchet.ts +73 -14
- package/src/utils/sqlSessionToCrypto.ts +3 -19
|
@@ -10,14 +10,20 @@ import { describe, expect, it } from "vitest";
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
decodeRatchetHeader,
|
|
13
|
+
deriveBootstrapSendChain,
|
|
13
14
|
encodeRatchetHeader,
|
|
14
15
|
hasRemoteDhChanged,
|
|
15
16
|
initRatchetSession,
|
|
17
|
+
MAX_SKIP_MESSAGE_GAP,
|
|
18
|
+
MAX_SKIPPED_KEYS,
|
|
19
|
+
parseSkippedKeysStrict,
|
|
16
20
|
ratchetStepReceive,
|
|
17
21
|
ratchetStepSend,
|
|
22
|
+
sessionToSqlPatch,
|
|
18
23
|
takeReceiveMessageKey,
|
|
19
24
|
takeSendMessageKey,
|
|
20
25
|
} from "../utils/ratchet.js";
|
|
26
|
+
import { sqlSessionToCrypto } from "../utils/sqlSessionToCrypto.js";
|
|
21
27
|
|
|
22
28
|
describe("double ratchet helpers", () => {
|
|
23
29
|
it("derives matching message keys for first exchange and reply", async () => {
|
|
@@ -52,7 +58,9 @@ describe("double ratchet helpers", () => {
|
|
|
52
58
|
skippedKeys: {} as Record<string, string>,
|
|
53
59
|
};
|
|
54
60
|
|
|
55
|
-
|
|
61
|
+
if (!aliceState.CKs) {
|
|
62
|
+
await ratchetStepSend(aliceState);
|
|
63
|
+
}
|
|
56
64
|
const a1 = takeSendMessageKey(aliceState);
|
|
57
65
|
const h1 = decodeRatchetHeader(
|
|
58
66
|
encodeRatchetHeader({
|
|
@@ -64,11 +72,17 @@ describe("double ratchet helpers", () => {
|
|
|
64
72
|
);
|
|
65
73
|
|
|
66
74
|
expect(hasRemoteDhChanged(bobState.DHr, h1.dhPub)).toBe(true);
|
|
67
|
-
|
|
75
|
+
if (!bobState.DHr && bobState.CKr) {
|
|
76
|
+
bobState.DHr = h1.dhPub;
|
|
77
|
+
} else {
|
|
78
|
+
await ratchetStepReceive(bobState, h1.dhPub, h1.pn);
|
|
79
|
+
}
|
|
68
80
|
const b1 = takeReceiveMessageKey(bobState, h1.dhPub, h1.n);
|
|
69
81
|
expect(XUtils.bytesEqual(a1.messageKey, b1)).toBe(true);
|
|
70
82
|
|
|
71
|
-
|
|
83
|
+
if (!bobState.CKs) {
|
|
84
|
+
await ratchetStepSend(bobState);
|
|
85
|
+
}
|
|
72
86
|
const bReply = takeSendMessageKey(bobState);
|
|
73
87
|
const h2 = decodeRatchetHeader(
|
|
74
88
|
encodeRatchetHeader({
|
|
@@ -78,7 +92,11 @@ describe("double ratchet helpers", () => {
|
|
|
78
92
|
version: 1,
|
|
79
93
|
}),
|
|
80
94
|
);
|
|
81
|
-
|
|
95
|
+
if (!aliceState.DHr && aliceState.CKr) {
|
|
96
|
+
aliceState.DHr = h2.dhPub;
|
|
97
|
+
} else if (hasRemoteDhChanged(aliceState.DHr, h2.dhPub)) {
|
|
98
|
+
await ratchetStepReceive(aliceState, h2.dhPub, h2.pn);
|
|
99
|
+
}
|
|
82
100
|
const aReply = takeReceiveMessageKey(aliceState, h2.dhPub, h2.n);
|
|
83
101
|
expect(XUtils.bytesEqual(aReply, bReply.messageKey)).toBe(true);
|
|
84
102
|
});
|
|
@@ -115,7 +133,9 @@ describe("double ratchet helpers", () => {
|
|
|
115
133
|
skippedKeys: {} as Record<string, string>,
|
|
116
134
|
};
|
|
117
135
|
|
|
118
|
-
|
|
136
|
+
if (!s.CKs) {
|
|
137
|
+
await ratchetStepSend(s);
|
|
138
|
+
}
|
|
119
139
|
const m0 = takeSendMessageKey(s);
|
|
120
140
|
const m1 = takeSendMessageKey(s);
|
|
121
141
|
const h0 = {
|
|
@@ -131,11 +151,375 @@ describe("double ratchet helpers", () => {
|
|
|
131
151
|
version: 1 as const,
|
|
132
152
|
};
|
|
133
153
|
|
|
134
|
-
|
|
154
|
+
if (!r.DHr && r.CKr) {
|
|
155
|
+
r.DHr = h1.dhPub;
|
|
156
|
+
} else {
|
|
157
|
+
await ratchetStepReceive(r, h1.dhPub, h1.pn);
|
|
158
|
+
}
|
|
135
159
|
const r1 = takeReceiveMessageKey(r, h1.dhPub, h1.n);
|
|
136
160
|
expect(XUtils.bytesEqual(r1, m1.messageKey)).toBe(true);
|
|
137
161
|
|
|
138
162
|
const r0 = takeReceiveMessageKey(r, h0.dhPub, h0.n);
|
|
139
163
|
expect(XUtils.bytesEqual(r0, m0.messageKey)).toBe(true);
|
|
140
164
|
});
|
|
165
|
+
|
|
166
|
+
it("advances sending chain for every message within same DH epoch", async () => {
|
|
167
|
+
const sk = XUtils.decodeHex(
|
|
168
|
+
"5555555555555555555555555555555555555555555555555555555555555555",
|
|
169
|
+
);
|
|
170
|
+
const initiator = await initRatchetSession(sk, "initiator");
|
|
171
|
+
const sender = {
|
|
172
|
+
CKr: initiator.CKr ? XUtils.decodeHex(initiator.CKr) : null,
|
|
173
|
+
CKs: initiator.CKs ? XUtils.decodeHex(initiator.CKs) : null,
|
|
174
|
+
DHr: initiator.DHr ? XUtils.decodeHex(initiator.DHr) : null,
|
|
175
|
+
DHsPrivate: XUtils.decodeHex(initiator.DHsPrivate),
|
|
176
|
+
DHsPublic: XUtils.decodeHex(initiator.DHsPublic),
|
|
177
|
+
Nr: initiator.Nr,
|
|
178
|
+
Ns: initiator.Ns,
|
|
179
|
+
PN: initiator.PN,
|
|
180
|
+
RK: XUtils.decodeHex(initiator.RK),
|
|
181
|
+
skippedKeys: {} as Record<string, string>,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
if (!sender.CKs) {
|
|
185
|
+
await ratchetStepSend(sender);
|
|
186
|
+
}
|
|
187
|
+
const dhPubBefore = XUtils.encodeHex(sender.DHsPublic);
|
|
188
|
+
|
|
189
|
+
const m0 = takeSendMessageKey(sender);
|
|
190
|
+
const m1 = takeSendMessageKey(sender);
|
|
191
|
+
const m2 = takeSendMessageKey(sender);
|
|
192
|
+
|
|
193
|
+
expect(m0.n).toBe(0);
|
|
194
|
+
expect(m1.n).toBe(1);
|
|
195
|
+
expect(m2.n).toBe(2);
|
|
196
|
+
expect(sender.Ns).toBe(3);
|
|
197
|
+
expect(XUtils.bytesEqual(m0.messageKey, m1.messageKey)).toBe(false);
|
|
198
|
+
expect(XUtils.bytesEqual(m1.messageKey, m2.messageKey)).toBe(false);
|
|
199
|
+
expect(XUtils.encodeHex(sender.DHsPublic)).toBe(dhPubBefore);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("decrypts first subsequent reply after initial mail via bootstrap chain", async () => {
|
|
203
|
+
const sk = XUtils.decodeHex(
|
|
204
|
+
"6666666666666666666666666666666666666666666666666666666666666666",
|
|
205
|
+
);
|
|
206
|
+
const initiator = await initRatchetSession(sk, "initiator");
|
|
207
|
+
const receiver = await initRatchetSession(sk, "receiver");
|
|
208
|
+
|
|
209
|
+
const alice = {
|
|
210
|
+
CKr: initiator.CKr ? XUtils.decodeHex(initiator.CKr) : null,
|
|
211
|
+
CKs: initiator.CKs ? XUtils.decodeHex(initiator.CKs) : null,
|
|
212
|
+
DHr: initiator.DHr ? XUtils.decodeHex(initiator.DHr) : null,
|
|
213
|
+
DHsPrivate: XUtils.decodeHex(initiator.DHsPrivate),
|
|
214
|
+
DHsPublic: XUtils.decodeHex(initiator.DHsPublic),
|
|
215
|
+
Nr: initiator.Nr,
|
|
216
|
+
Ns: initiator.Ns,
|
|
217
|
+
PN: initiator.PN,
|
|
218
|
+
RK: XUtils.decodeHex(initiator.RK),
|
|
219
|
+
skippedKeys: {} as Record<string, string>,
|
|
220
|
+
};
|
|
221
|
+
const bob = {
|
|
222
|
+
CKr: receiver.CKr ? XUtils.decodeHex(receiver.CKr) : null,
|
|
223
|
+
CKs: receiver.CKs ? XUtils.decodeHex(receiver.CKs) : null,
|
|
224
|
+
DHr: receiver.DHr ? XUtils.decodeHex(receiver.DHr) : null,
|
|
225
|
+
DHsPrivate: XUtils.decodeHex(receiver.DHsPrivate),
|
|
226
|
+
DHsPublic: XUtils.decodeHex(receiver.DHsPublic),
|
|
227
|
+
Nr: receiver.Nr,
|
|
228
|
+
Ns: receiver.Ns,
|
|
229
|
+
PN: receiver.PN,
|
|
230
|
+
RK: XUtils.decodeHex(receiver.RK),
|
|
231
|
+
skippedKeys: {} as Record<string, string>,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
if (!bob.CKs) {
|
|
235
|
+
await ratchetStepSend(bob);
|
|
236
|
+
}
|
|
237
|
+
const outbound = takeSendMessageKey(bob);
|
|
238
|
+
const header = decodeRatchetHeader(
|
|
239
|
+
encodeRatchetHeader({
|
|
240
|
+
dhPub: bob.DHsPublic,
|
|
241
|
+
n: outbound.n,
|
|
242
|
+
pn: bob.PN,
|
|
243
|
+
version: 1,
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if (!alice.DHr) {
|
|
248
|
+
alice.DHr = header.dhPub;
|
|
249
|
+
if (!alice.CKr) {
|
|
250
|
+
alice.CKr = deriveBootstrapSendChain(alice.RK);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const inbound = takeReceiveMessageKey(alice, header.dhPub, header.n);
|
|
254
|
+
expect(XUtils.bytesEqual(inbound, outbound.messageKey)).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("keeps sessions robust over long back-and-forth with persistence", async () => {
|
|
258
|
+
const sk = XUtils.decodeHex(
|
|
259
|
+
"3333333333333333333333333333333333333333333333333333333333333333",
|
|
260
|
+
);
|
|
261
|
+
const initiator = await initRatchetSession(sk, "initiator");
|
|
262
|
+
const receiver = await initRatchetSession(sk, "receiver");
|
|
263
|
+
|
|
264
|
+
let alice = {
|
|
265
|
+
CKr: initiator.CKr ? XUtils.decodeHex(initiator.CKr) : null,
|
|
266
|
+
CKs: initiator.CKs ? XUtils.decodeHex(initiator.CKs) : null,
|
|
267
|
+
DHr: initiator.DHr ? XUtils.decodeHex(initiator.DHr) : null,
|
|
268
|
+
DHsPrivate: XUtils.decodeHex(initiator.DHsPrivate),
|
|
269
|
+
DHsPublic: XUtils.decodeHex(initiator.DHsPublic),
|
|
270
|
+
Nr: initiator.Nr,
|
|
271
|
+
Ns: initiator.Ns,
|
|
272
|
+
PN: initiator.PN,
|
|
273
|
+
RK: XUtils.decodeHex(initiator.RK),
|
|
274
|
+
skippedKeys: {} as Record<string, string>,
|
|
275
|
+
};
|
|
276
|
+
let bob = {
|
|
277
|
+
CKr: receiver.CKr ? XUtils.decodeHex(receiver.CKr) : null,
|
|
278
|
+
CKs: receiver.CKs ? XUtils.decodeHex(receiver.CKs) : null,
|
|
279
|
+
DHr: receiver.DHr ? XUtils.decodeHex(receiver.DHr) : null,
|
|
280
|
+
DHsPrivate: XUtils.decodeHex(receiver.DHsPrivate),
|
|
281
|
+
DHsPublic: XUtils.decodeHex(receiver.DHsPublic),
|
|
282
|
+
Nr: receiver.Nr,
|
|
283
|
+
Ns: receiver.Ns,
|
|
284
|
+
PN: receiver.PN,
|
|
285
|
+
RK: XUtils.decodeHex(receiver.RK),
|
|
286
|
+
skippedKeys: {} as Record<string, string>,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const rounds = 120;
|
|
290
|
+
for (let i = 0; i < rounds; i += 1) {
|
|
291
|
+
if (!alice.CKs) {
|
|
292
|
+
await ratchetStepSend(alice);
|
|
293
|
+
}
|
|
294
|
+
const aOut = takeSendMessageKey(alice);
|
|
295
|
+
const aHdr = decodeRatchetHeader(
|
|
296
|
+
encodeRatchetHeader({
|
|
297
|
+
dhPub: alice.DHsPublic,
|
|
298
|
+
n: aOut.n,
|
|
299
|
+
pn: alice.PN,
|
|
300
|
+
version: 1,
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
if (!bob.DHr && bob.CKr) {
|
|
304
|
+
bob.DHr = aHdr.dhPub;
|
|
305
|
+
} else if (hasRemoteDhChanged(bob.DHr, aHdr.dhPub)) {
|
|
306
|
+
await ratchetStepReceive(bob, aHdr.dhPub, aHdr.pn);
|
|
307
|
+
}
|
|
308
|
+
const bIn = takeReceiveMessageKey(bob, aHdr.dhPub, aHdr.n);
|
|
309
|
+
expect(XUtils.bytesEqual(aOut.messageKey, bIn)).toBe(true);
|
|
310
|
+
|
|
311
|
+
if (!bob.CKs) {
|
|
312
|
+
await ratchetStepSend(bob);
|
|
313
|
+
}
|
|
314
|
+
const bOut = takeSendMessageKey(bob);
|
|
315
|
+
const bHdr = decodeRatchetHeader(
|
|
316
|
+
encodeRatchetHeader({
|
|
317
|
+
dhPub: bob.DHsPublic,
|
|
318
|
+
n: bOut.n,
|
|
319
|
+
pn: bob.PN,
|
|
320
|
+
version: 1,
|
|
321
|
+
}),
|
|
322
|
+
);
|
|
323
|
+
if (!alice.DHr && alice.CKr) {
|
|
324
|
+
alice.DHr = bHdr.dhPub;
|
|
325
|
+
} else if (hasRemoteDhChanged(alice.DHr, bHdr.dhPub)) {
|
|
326
|
+
await ratchetStepReceive(alice, bHdr.dhPub, bHdr.pn);
|
|
327
|
+
}
|
|
328
|
+
const aIn = takeReceiveMessageKey(alice, bHdr.dhPub, bHdr.n);
|
|
329
|
+
expect(XUtils.bytesEqual(aIn, bOut.messageKey)).toBe(true);
|
|
330
|
+
|
|
331
|
+
// Simulate periodic app restarts by serializing and reloading session state.
|
|
332
|
+
if (i > 0 && i % 10 === 0) {
|
|
333
|
+
alice = hydrateState(alice, "alice-session");
|
|
334
|
+
bob = hydrateState(bob, "bob-session");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
expect(alice.Nr + alice.Ns).toBeGreaterThan(0);
|
|
339
|
+
expect(bob.Nr + bob.Ns).toBeGreaterThan(0);
|
|
340
|
+
expect(alice.CKr ?? alice.CKs).not.toBeNull();
|
|
341
|
+
expect(bob.CKr ?? bob.CKs).not.toBeNull();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("nightly: survives 1000-message randomized streaks with persistence", async () => {
|
|
345
|
+
if (process.env["LIBVEX_NIGHTLY_STRESS"] !== "1") {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const sk = XUtils.decodeHex(
|
|
349
|
+
"4444444444444444444444444444444444444444444444444444444444444444",
|
|
350
|
+
);
|
|
351
|
+
const initiator = await initRatchetSession(sk, "initiator");
|
|
352
|
+
const receiver = await initRatchetSession(sk, "receiver");
|
|
353
|
+
|
|
354
|
+
let alice = {
|
|
355
|
+
CKr: initiator.CKr ? XUtils.decodeHex(initiator.CKr) : null,
|
|
356
|
+
CKs: initiator.CKs ? XUtils.decodeHex(initiator.CKs) : null,
|
|
357
|
+
DHr: initiator.DHr ? XUtils.decodeHex(initiator.DHr) : null,
|
|
358
|
+
DHsPrivate: XUtils.decodeHex(initiator.DHsPrivate),
|
|
359
|
+
DHsPublic: XUtils.decodeHex(initiator.DHsPublic),
|
|
360
|
+
Nr: initiator.Nr,
|
|
361
|
+
Ns: initiator.Ns,
|
|
362
|
+
PN: initiator.PN,
|
|
363
|
+
RK: XUtils.decodeHex(initiator.RK),
|
|
364
|
+
skippedKeys: {} as Record<string, string>,
|
|
365
|
+
};
|
|
366
|
+
let bob = {
|
|
367
|
+
CKr: receiver.CKr ? XUtils.decodeHex(receiver.CKr) : null,
|
|
368
|
+
CKs: receiver.CKs ? XUtils.decodeHex(receiver.CKs) : null,
|
|
369
|
+
DHr: receiver.DHr ? XUtils.decodeHex(receiver.DHr) : null,
|
|
370
|
+
DHsPrivate: XUtils.decodeHex(receiver.DHsPrivate),
|
|
371
|
+
DHsPublic: XUtils.decodeHex(receiver.DHsPublic),
|
|
372
|
+
Nr: receiver.Nr,
|
|
373
|
+
Ns: receiver.Ns,
|
|
374
|
+
PN: receiver.PN,
|
|
375
|
+
RK: XUtils.decodeHex(receiver.RK),
|
|
376
|
+
skippedKeys: {} as Record<string, string>,
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const rng = mulberry32(0xdecafbad);
|
|
380
|
+
const totalMessages = 1000;
|
|
381
|
+
for (let i = 0; i < totalMessages; i += 1) {
|
|
382
|
+
const aliceSends = rng() < 0.5;
|
|
383
|
+
const sender = aliceSends ? alice : bob;
|
|
384
|
+
const receiverState = aliceSends ? bob : alice;
|
|
385
|
+
|
|
386
|
+
if (!sender.CKs) {
|
|
387
|
+
await ratchetStepSend(sender);
|
|
388
|
+
}
|
|
389
|
+
const outbound = takeSendMessageKey(sender);
|
|
390
|
+
const header = decodeRatchetHeader(
|
|
391
|
+
encodeRatchetHeader({
|
|
392
|
+
dhPub: sender.DHsPublic,
|
|
393
|
+
n: outbound.n,
|
|
394
|
+
pn: sender.PN,
|
|
395
|
+
version: 1,
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
if (!receiverState.DHr && receiverState.CKr) {
|
|
400
|
+
receiverState.DHr = header.dhPub;
|
|
401
|
+
} else if (hasRemoteDhChanged(receiverState.DHr, header.dhPub)) {
|
|
402
|
+
await ratchetStepReceive(
|
|
403
|
+
receiverState,
|
|
404
|
+
header.dhPub,
|
|
405
|
+
header.pn,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const inbound = takeReceiveMessageKey(
|
|
409
|
+
receiverState,
|
|
410
|
+
header.dhPub,
|
|
411
|
+
header.n,
|
|
412
|
+
);
|
|
413
|
+
expect(XUtils.bytesEqual(outbound.messageKey, inbound)).toBe(true);
|
|
414
|
+
|
|
415
|
+
if (i > 0 && i % 25 === 0) {
|
|
416
|
+
alice = hydrateState(alice, "alice-nightly");
|
|
417
|
+
bob = hydrateState(bob, "bob-nightly");
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
expect(alice.Nr + alice.Ns + bob.Nr + bob.Ns).toBeGreaterThan(500);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("rejects excessive receive message gap", () => {
|
|
425
|
+
const state = {
|
|
426
|
+
CKr: XUtils.decodeHex(
|
|
427
|
+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
428
|
+
),
|
|
429
|
+
DHr: XUtils.decodeHex(
|
|
430
|
+
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
431
|
+
),
|
|
432
|
+
Nr: 0,
|
|
433
|
+
skippedKeys: {} as Record<string, string>,
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
expect(() =>
|
|
437
|
+
takeReceiveMessageKey(state, state.DHr, MAX_SKIP_MESSAGE_GAP + 1),
|
|
438
|
+
).toThrow("Ratchet skip window exceeded");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("sanitizes skipped-keys payload bounds and format", () => {
|
|
442
|
+
const validDh = "aa".repeat(32);
|
|
443
|
+
const validValue = "bb".repeat(32);
|
|
444
|
+
const oversized = Object.fromEntries(
|
|
445
|
+
Array.from({ length: MAX_SKIPPED_KEYS + 50 }, (_v, i) => [
|
|
446
|
+
`${validDh}:${String(i)}`,
|
|
447
|
+
validValue,
|
|
448
|
+
]),
|
|
449
|
+
) as Record<string, string>;
|
|
450
|
+
const bounded = parseSkippedKeysStrict(JSON.stringify(oversized));
|
|
451
|
+
expect(Object.keys(bounded).length).toBe(MAX_SKIPPED_KEYS);
|
|
452
|
+
|
|
453
|
+
const filtered = parseSkippedKeysStrict(
|
|
454
|
+
JSON.stringify({
|
|
455
|
+
[`${validDh}:0`]: "not-hex",
|
|
456
|
+
[`${validDh}:1`]: validValue,
|
|
457
|
+
"bad-key-format": validValue,
|
|
458
|
+
}),
|
|
459
|
+
);
|
|
460
|
+
expect(filtered["bad-key-format"]).toBeUndefined();
|
|
461
|
+
expect(filtered[`${validDh}:0`]).toBeUndefined();
|
|
462
|
+
expect(filtered[`${validDh}:1`]).toBe(validValue);
|
|
463
|
+
});
|
|
141
464
|
});
|
|
465
|
+
|
|
466
|
+
function hydrateState(
|
|
467
|
+
state: {
|
|
468
|
+
CKr: null | Uint8Array;
|
|
469
|
+
CKs: null | Uint8Array;
|
|
470
|
+
DHr: null | Uint8Array;
|
|
471
|
+
DHsPrivate: Uint8Array;
|
|
472
|
+
DHsPublic: Uint8Array;
|
|
473
|
+
Nr: number;
|
|
474
|
+
Ns: number;
|
|
475
|
+
PN: number;
|
|
476
|
+
RK: Uint8Array;
|
|
477
|
+
skippedKeys: Record<string, string>;
|
|
478
|
+
},
|
|
479
|
+
sessionID: string,
|
|
480
|
+
) {
|
|
481
|
+
const sql = sessionToSqlPatch(state);
|
|
482
|
+
const roundTripped = sqlSessionToCrypto({
|
|
483
|
+
CKr: sql.CKr,
|
|
484
|
+
CKs: sql.CKs,
|
|
485
|
+
deviceID: "device",
|
|
486
|
+
DHr: sql.DHr,
|
|
487
|
+
DHsPrivate: sql.DHsPrivate,
|
|
488
|
+
DHsPublic: sql.DHsPublic,
|
|
489
|
+
fingerprint: "00",
|
|
490
|
+
lastUsed: new Date().toISOString(),
|
|
491
|
+
mode: "initiator",
|
|
492
|
+
Nr: sql.Nr,
|
|
493
|
+
Ns: sql.Ns,
|
|
494
|
+
PN: sql.PN,
|
|
495
|
+
publicKey: "00",
|
|
496
|
+
RK: sql.RK,
|
|
497
|
+
sessionID,
|
|
498
|
+
SK: "00",
|
|
499
|
+
skippedKeys: sql.skippedKeys,
|
|
500
|
+
userID: "user",
|
|
501
|
+
verified: false,
|
|
502
|
+
});
|
|
503
|
+
return {
|
|
504
|
+
CKr: roundTripped.CKr,
|
|
505
|
+
CKs: roundTripped.CKs,
|
|
506
|
+
DHr: roundTripped.DHr,
|
|
507
|
+
DHsPrivate: roundTripped.DHsPrivate,
|
|
508
|
+
DHsPublic: roundTripped.DHsPublic,
|
|
509
|
+
Nr: roundTripped.Nr,
|
|
510
|
+
Ns: roundTripped.Ns,
|
|
511
|
+
PN: roundTripped.PN,
|
|
512
|
+
RK: roundTripped.RK,
|
|
513
|
+
skippedKeys: roundTripped.skippedKeys,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function mulberry32(seed: number): () => number {
|
|
518
|
+
let t = seed >>> 0;
|
|
519
|
+
return () => {
|
|
520
|
+
t = (t + 0x6d2b79f5) | 0;
|
|
521
|
+
let x = Math.imul(t ^ (t >>> 15), 1 | t);
|
|
522
|
+
x ^= x + Math.imul(x ^ (x >>> 7), 61 | x);
|
|
523
|
+
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
|
|
524
|
+
};
|
|
525
|
+
}
|
package/src/storage/sqlite.ts
CHANGED
|
@@ -44,6 +44,8 @@ import {
|
|
|
44
44
|
import { EventEmitter } from "eventemitter3";
|
|
45
45
|
import { type Kysely, sql } from "kysely";
|
|
46
46
|
|
|
47
|
+
import { parseSkippedKeysStrict } from "../utils/ratchet.js";
|
|
48
|
+
|
|
47
49
|
export class SqliteStorage extends EventEmitter implements Storage {
|
|
48
50
|
public ready = false;
|
|
49
51
|
/** 32-byte AES-256 (or nacl) key for local at-rest `secretbox` (see `XUtils.deriveLocalAtRestAesKey`). */
|
|
@@ -730,24 +732,6 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
730
732
|
return false;
|
|
731
733
|
}
|
|
732
734
|
|
|
733
|
-
private parseSkippedKeys(raw: string): Record<string, string> {
|
|
734
|
-
try {
|
|
735
|
-
const parsed: unknown = JSON.parse(raw);
|
|
736
|
-
if (typeof parsed !== "object" || parsed === null) {
|
|
737
|
-
return {};
|
|
738
|
-
}
|
|
739
|
-
const out: Record<string, string> = {};
|
|
740
|
-
for (const [k, v] of Object.entries(parsed)) {
|
|
741
|
-
if (typeof v === "string") {
|
|
742
|
-
out[k] = v;
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
return out;
|
|
746
|
-
} catch {
|
|
747
|
-
return {};
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
|
|
751
735
|
/**
|
|
752
736
|
* Encrypt a hex-encoded secret for at-rest storage.
|
|
753
737
|
* Returns hex(nonce || ciphertext) where nonce is 24 random bytes.
|
|
@@ -797,7 +781,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
797
781
|
}
|
|
798
782
|
|
|
799
783
|
private sqlToCrypto(session: SessionSQL): SessionCrypto {
|
|
800
|
-
const skippedKeys =
|
|
784
|
+
const skippedKeys = parseSkippedKeysStrict(session.skippedKeys);
|
|
801
785
|
return {
|
|
802
786
|
CKr: session.CKr ? XUtils.decodeHex(session.CKr) : null,
|
|
803
787
|
CKs: session.CKs ? XUtils.decodeHex(session.CKs) : null,
|
package/src/utils/ratchet.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
} from "@vex-chat/crypto";
|
|
17
17
|
|
|
18
18
|
const VERSION = 1;
|
|
19
|
+
export const MAX_SKIP_MESSAGE_GAP = 1024;
|
|
20
|
+
export const MAX_SKIPPED_KEYS = 4096;
|
|
19
21
|
|
|
20
22
|
const encoder = new TextEncoder();
|
|
21
23
|
|
|
@@ -39,6 +41,10 @@ export function decodeRatchetHeader(extra: Uint8Array): RatchetHeader {
|
|
|
39
41
|
return { dhPub, n, pn, version: 1 };
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
export function deriveBootstrapSendChain(rootKey: Uint8Array): Uint8Array {
|
|
45
|
+
return xHMAC({ label: "bootstrap-send-chain", version: VERSION }, rootKey);
|
|
46
|
+
}
|
|
47
|
+
|
|
42
48
|
export function deriveInitialRootKey(sk: Uint8Array): Uint8Array {
|
|
43
49
|
return xKDF(xConcat(sk, encoder.encode("dr-root-v1")));
|
|
44
50
|
}
|
|
@@ -84,13 +90,10 @@ export async function initRatchetSession(
|
|
|
84
90
|
}> {
|
|
85
91
|
const RK = deriveInitialRootKey(sk);
|
|
86
92
|
const DHs = await xBoxKeyPairAsync();
|
|
87
|
-
const
|
|
88
|
-
mode === "initiator"
|
|
89
|
-
? xHMAC({ label: "init-send-chain", version: VERSION }, RK)
|
|
90
|
-
: null;
|
|
93
|
+
const initialChain = xHMAC({ label: "init-chain", version: VERSION }, RK);
|
|
91
94
|
return {
|
|
92
|
-
CKr: null,
|
|
93
|
-
CKs:
|
|
95
|
+
CKr: mode === "receiver" ? XUtils.encodeHex(initialChain) : null,
|
|
96
|
+
CKs: mode === "initiator" ? XUtils.encodeHex(initialChain) : null,
|
|
94
97
|
DHr: null,
|
|
95
98
|
DHsPrivate: XUtils.encodeHex(DHs.secretKey),
|
|
96
99
|
DHsPublic: XUtils.encodeHex(DHs.publicKey),
|
|
@@ -102,6 +105,25 @@ export async function initRatchetSession(
|
|
|
102
105
|
};
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
export function parseSkippedKeysStrict(raw: string): Record<string, string> {
|
|
109
|
+
try {
|
|
110
|
+
const parsed: unknown = JSON.parse(raw);
|
|
111
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
const entries = Object.entries(parsed).slice(0, MAX_SKIPPED_KEYS);
|
|
115
|
+
const out: Record<string, string> = {};
|
|
116
|
+
for (const [k, v] of entries) {
|
|
117
|
+
if (typeof v === "string" && isHex(v) && isSkippedKeyIdFormat(k)) {
|
|
118
|
+
out[k] = v;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
} catch {
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
105
127
|
export async function ratchetStepReceive(
|
|
106
128
|
state: {
|
|
107
129
|
CKr: null | Uint8Array;
|
|
@@ -119,11 +141,17 @@ export async function ratchetStepReceive(
|
|
|
119
141
|
pn: number,
|
|
120
142
|
): Promise<void> {
|
|
121
143
|
if (state.CKr && state.DHr) {
|
|
144
|
+
if (pn - state.Nr > MAX_SKIP_MESSAGE_GAP) {
|
|
145
|
+
throw new Error("Ratchet skip window exceeded for PN.");
|
|
146
|
+
}
|
|
122
147
|
while (state.Nr < pn) {
|
|
123
148
|
const { chainKey, messageKey } = kdfChain(state.CKr);
|
|
124
149
|
state.CKr = chainKey;
|
|
125
|
-
state.skippedKeys
|
|
126
|
-
|
|
150
|
+
state.skippedKeys = putSkippedMessageKey(
|
|
151
|
+
state.skippedKeys,
|
|
152
|
+
skippedKeyId(state.DHr, state.Nr),
|
|
153
|
+
XUtils.encodeHex(messageKey),
|
|
154
|
+
);
|
|
127
155
|
state.Nr += 1;
|
|
128
156
|
}
|
|
129
157
|
}
|
|
@@ -154,10 +182,7 @@ export async function ratchetStepSend(state: {
|
|
|
154
182
|
}): Promise<void> {
|
|
155
183
|
if (!state.DHr) {
|
|
156
184
|
if (!state.CKs) {
|
|
157
|
-
state.CKs =
|
|
158
|
-
{ label: "bootstrap-send-chain", version: VERSION },
|
|
159
|
-
state.RK,
|
|
160
|
-
);
|
|
185
|
+
state.CKs = deriveBootstrapSendChain(state.RK);
|
|
161
186
|
}
|
|
162
187
|
return;
|
|
163
188
|
}
|
|
@@ -232,14 +257,21 @@ export function takeReceiveMessageKey(
|
|
|
232
257
|
throw new Error("Missing receiving chain key.");
|
|
233
258
|
}
|
|
234
259
|
|
|
260
|
+
if (n - state.Nr > MAX_SKIP_MESSAGE_GAP) {
|
|
261
|
+
throw new Error("Ratchet skip window exceeded for message index.");
|
|
262
|
+
}
|
|
263
|
+
|
|
235
264
|
while (state.Nr < n) {
|
|
236
265
|
const { chainKey, messageKey } = kdfChain(state.CKr);
|
|
237
266
|
state.CKr = chainKey;
|
|
238
267
|
if (!state.DHr) {
|
|
239
268
|
throw new Error("Missing DHr when storing skipped key.");
|
|
240
269
|
}
|
|
241
|
-
state.skippedKeys
|
|
242
|
-
|
|
270
|
+
state.skippedKeys = putSkippedMessageKey(
|
|
271
|
+
state.skippedKeys,
|
|
272
|
+
skippedKeyId(state.DHr, state.Nr),
|
|
273
|
+
XUtils.encodeHex(messageKey),
|
|
274
|
+
);
|
|
243
275
|
state.Nr += 1;
|
|
244
276
|
}
|
|
245
277
|
|
|
@@ -263,6 +295,20 @@ export function takeSendMessageKey(state: {
|
|
|
263
295
|
return { messageKey, n };
|
|
264
296
|
}
|
|
265
297
|
|
|
298
|
+
function isHex(value: string): boolean {
|
|
299
|
+
return value.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(value);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isSkippedKeyIdFormat(value: string): boolean {
|
|
303
|
+
const idx = value.lastIndexOf(":");
|
|
304
|
+
if (idx <= 0 || idx === value.length - 1) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
const dhHex = value.slice(0, idx);
|
|
308
|
+
const nPart = value.slice(idx + 1);
|
|
309
|
+
return isHex(dhHex) && /^[0-9]+$/.test(nPart);
|
|
310
|
+
}
|
|
311
|
+
|
|
266
312
|
function kdfChain(ck: Uint8Array): {
|
|
267
313
|
chainKey: Uint8Array;
|
|
268
314
|
messageKey: Uint8Array;
|
|
@@ -284,6 +330,19 @@ function kdfRoot(
|
|
|
284
330
|
};
|
|
285
331
|
}
|
|
286
332
|
|
|
333
|
+
function putSkippedMessageKey(
|
|
334
|
+
skippedKeys: Record<string, string>,
|
|
335
|
+
id: string,
|
|
336
|
+
keyHex: string,
|
|
337
|
+
): Record<string, string> {
|
|
338
|
+
let entries = Object.entries(skippedKeys).filter(([key]) => key !== id);
|
|
339
|
+
if (entries.length >= MAX_SKIPPED_KEYS) {
|
|
340
|
+
entries = entries.slice(entries.length - MAX_SKIPPED_KEYS + 1);
|
|
341
|
+
}
|
|
342
|
+
entries.push([id, keyHex]);
|
|
343
|
+
return Object.fromEntries(entries);
|
|
344
|
+
}
|
|
345
|
+
|
|
287
346
|
function skippedKeyId(dhPub: Uint8Array, n: number): string {
|
|
288
347
|
return `${XUtils.encodeHex(dhPub)}:${String(n)}`;
|
|
289
348
|
}
|
|
@@ -9,8 +9,10 @@ import type { SessionSQL } from "@vex-chat/types";
|
|
|
9
9
|
|
|
10
10
|
import { XUtils } from "@vex-chat/crypto";
|
|
11
11
|
|
|
12
|
+
import { parseSkippedKeysStrict } from "./ratchet.js";
|
|
13
|
+
|
|
12
14
|
export function sqlSessionToCrypto(session: SessionSQL): SessionCrypto {
|
|
13
|
-
const skippedKeys =
|
|
15
|
+
const skippedKeys = parseSkippedKeysStrict(session.skippedKeys);
|
|
14
16
|
return {
|
|
15
17
|
CKr: session.CKr ? XUtils.decodeHex(session.CKr) : null,
|
|
16
18
|
CKs: session.CKs ? XUtils.decodeHex(session.CKs) : null,
|
|
@@ -32,21 +34,3 @@ export function sqlSessionToCrypto(session: SessionSQL): SessionCrypto {
|
|
|
32
34
|
verified: session.verified,
|
|
33
35
|
};
|
|
34
36
|
}
|
|
35
|
-
|
|
36
|
-
function parseSkippedKeys(raw: string): Record<string, string> {
|
|
37
|
-
try {
|
|
38
|
-
const parsed: unknown = JSON.parse(raw);
|
|
39
|
-
if (typeof parsed !== "object" || parsed === null) {
|
|
40
|
-
return {};
|
|
41
|
-
}
|
|
42
|
-
const out: Record<string, string> = {};
|
|
43
|
-
for (const [k, v] of Object.entries(parsed)) {
|
|
44
|
-
if (typeof v === "string") {
|
|
45
|
-
out[k] = v;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return out;
|
|
49
|
-
} catch {
|
|
50
|
-
return {};
|
|
51
|
-
}
|
|
52
|
-
}
|