@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.
@@ -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
- await ratchetStepSend(aliceState);
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
- await ratchetStepReceive(bobState, h1.dhPub, h1.pn);
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
- await ratchetStepSend(bobState);
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
- await ratchetStepReceive(aliceState, h2.dhPub, h2.pn);
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
- await ratchetStepSend(s);
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
- await ratchetStepReceive(r, h1.dhPub, h1.pn);
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
+ }
@@ -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 = this.parseSkippedKeys(session.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,
@@ -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 CKs =
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: CKs ? XUtils.encodeHex(CKs) : null,
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[skippedKeyId(state.DHr, state.Nr)] =
126
- XUtils.encodeHex(messageKey);
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 = xHMAC(
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[skippedKeyId(state.DHr, state.Nr)] =
242
- XUtils.encodeHex(messageKey);
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 = parseSkippedKeys(session.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
- }