@vex-chat/libvex 5.5.2 → 6.0.1

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.
@@ -18,7 +18,6 @@ import type {
18
18
  SessionRow,
19
19
  } from "./schema.js";
20
20
  import type { Device, PreKeysSQL, SessionSQL } from "@vex-chat/types";
21
- import type { Kysely } from "kysely";
22
21
 
23
22
  /**
24
23
  * Unified Kysely-based SQLite storage implementation.
@@ -43,6 +42,7 @@ import {
43
42
  } from "@vex-chat/crypto";
44
43
 
45
44
  import { EventEmitter } from "eventemitter3";
45
+ import { type Kysely, sql } from "kysely";
46
46
 
47
47
  export class SqliteStorage extends EventEmitter implements Storage {
48
48
  public ready = false;
@@ -273,7 +273,9 @@ export class SqliteStorage extends EventEmitter implements Storage {
273
273
  const rows = await this.db
274
274
  .selectFrom("sessions")
275
275
  .selectAll()
276
- .where("publicKey", "=", hex)
276
+ .where((eb) =>
277
+ eb.or([eb("publicKey", "=", hex), eb("DHr", "=", hex)]),
278
+ )
277
279
  .limit(1)
278
280
  .execute();
279
281
 
@@ -331,7 +333,20 @@ export class SqliteStorage extends EventEmitter implements Storage {
331
333
  .addColumn("mode", "text")
332
334
  .addColumn("lastUsed", "text")
333
335
  .addColumn("verified", "integer")
336
+ .addColumn("RK", "text")
337
+ .addColumn("DHsPublic", "text")
338
+ .addColumn("DHsPrivate", "text")
339
+ .addColumn("DHr", "text")
340
+ .addColumn("CKs", "text")
341
+ .addColumn("CKr", "text")
342
+ .addColumn("Ns", "integer", (col) => col.defaultTo(0))
343
+ .addColumn("Nr", "integer", (col) => col.defaultTo(0))
344
+ .addColumn("PN", "integer", (col) => col.defaultTo(0))
345
+ .addColumn("skippedKeys", "text", (col) =>
346
+ col.defaultTo("{}"),
347
+ )
334
348
  .execute();
349
+ await this.ensureSessionRatchetColumns();
335
350
 
336
351
  await this.db.schema
337
352
  .createTable("preKeys")
@@ -531,24 +546,62 @@ export class SqliteStorage extends EventEmitter implements Storage {
531
546
  if (this.closing) {
532
547
  return;
533
548
  }
549
+ const sealedCKr = session.CKr ? await this.sealHex(session.CKr) : null;
550
+ const sealedCKs = session.CKs ? await this.sealHex(session.CKs) : null;
551
+ const sealedDHsPrivate = await this.sealHex(session.DHsPrivate);
552
+ const sealedRK = await this.sealHex(session.RK);
553
+ const sealedSK = await this.sealHex(session.SK);
534
554
  try {
535
555
  await this.db
536
556
  .insertInto("sessions")
537
557
  .values({
558
+ CKr: sealedCKr,
559
+ CKs: sealedCKs,
538
560
  deviceID: session.deviceID,
561
+ DHr: session.DHr,
562
+ DHsPrivate: sealedDHsPrivate,
563
+ DHsPublic: session.DHsPublic,
539
564
  fingerprint: session.fingerprint,
540
565
  lastUsed: session.lastUsed,
541
566
  mode: session.mode,
567
+ Nr: session.Nr,
568
+ Ns: session.Ns,
569
+ PN: session.PN,
542
570
  publicKey: session.publicKey,
571
+ RK: sealedRK,
543
572
  sessionID: session.sessionID,
544
- SK: await this.sealHex(session.SK),
573
+ SK: sealedSK,
574
+ skippedKeys: session.skippedKeys,
545
575
  userID: session.userID,
546
576
  verified: session.verified ? 1 : 0,
547
577
  })
548
578
  .execute();
549
579
  } catch (err: unknown) {
550
580
  if (this.isDuplicateError(err)) {
551
- // duplicate SK — ignore
581
+ await this.db
582
+ .updateTable("sessions")
583
+ .set({
584
+ CKr: sealedCKr,
585
+ CKs: sealedCKs,
586
+ deviceID: session.deviceID,
587
+ DHr: session.DHr,
588
+ DHsPrivate: sealedDHsPrivate,
589
+ DHsPublic: session.DHsPublic,
590
+ fingerprint: session.fingerprint,
591
+ lastUsed: session.lastUsed,
592
+ mode: session.mode,
593
+ Nr: session.Nr,
594
+ Ns: session.Ns,
595
+ PN: session.PN,
596
+ publicKey: session.publicKey,
597
+ RK: sealedRK,
598
+ SK: sealedSK,
599
+ skippedKeys: session.skippedKeys,
600
+ userID: session.userID,
601
+ verified: session.verified ? 1 : 0,
602
+ })
603
+ .where("sessionID", "=", session.sessionID)
604
+ .execute();
552
605
  } else {
553
606
  throw err;
554
607
  }
@@ -614,6 +667,35 @@ export class SqliteStorage extends EventEmitter implements Storage {
614
667
  };
615
668
  }
616
669
 
670
+ private async ensureSessionRatchetColumns(): Promise<void> {
671
+ const add = async (
672
+ column: string,
673
+ type: "integer" | "text",
674
+ defaultSql: null | string = null,
675
+ ) => {
676
+ const defaultClause = defaultSql ? ` DEFAULT ${defaultSql}` : "";
677
+ try {
678
+ await sql
679
+ .raw(
680
+ `ALTER TABLE sessions ADD COLUMN ${column} ${type}${defaultClause}`,
681
+ )
682
+ .execute(this.db);
683
+ } catch {
684
+ // Existing databases may already have this column.
685
+ }
686
+ };
687
+ await add("RK", "text");
688
+ await add("DHsPublic", "text");
689
+ await add("DHsPrivate", "text");
690
+ await add("DHr", "text");
691
+ await add("CKs", "text");
692
+ await add("CKr", "text");
693
+ await add("Ns", "integer", "0");
694
+ await add("Nr", "integer", "0");
695
+ await add("PN", "integer", "0");
696
+ await add("skippedKeys", "text", "'{}'");
697
+ }
698
+
617
699
  /**
618
700
  * Read `closing` where TypeScript would incorrectly assume it cannot
619
701
  * become true after an earlier guard (e.g. across `await`).
@@ -648,6 +730,24 @@ export class SqliteStorage extends EventEmitter implements Storage {
648
730
  return false;
649
731
  }
650
732
 
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
+
651
751
  /**
652
752
  * Encrypt a hex-encoded secret for at-rest storage.
653
753
  * Returns hex(nonce || ciphertext) where nonce is 24 random bytes.
@@ -669,28 +769,54 @@ export class SqliteStorage extends EventEmitter implements Storage {
669
769
  }
670
770
 
671
771
  private async sessionRowToSQLAsync(row: SessionRow): Promise<SessionSQL> {
772
+ const rawSK = await this.unsealHex(row.SK);
773
+ const rawRK = row.RK ? await this.unsealHex(row.RK) : rawSK;
672
774
  return {
775
+ CKr: row.CKr ? await this.unsealHex(row.CKr) : null,
776
+ CKs: row.CKs ? await this.unsealHex(row.CKs) : null,
673
777
  deviceID: row.deviceID,
778
+ DHr: row.DHr,
779
+ DHsPrivate: row.DHsPrivate
780
+ ? await this.unsealHex(row.DHsPrivate)
781
+ : rawSK,
782
+ DHsPublic: row.DHsPublic,
674
783
  fingerprint: row.fingerprint,
675
784
  lastUsed: row.lastUsed,
676
785
  mode: row.mode === "initiator" ? "initiator" : "receiver",
786
+ Nr: row.Nr,
787
+ Ns: row.Ns,
788
+ PN: row.PN,
677
789
  publicKey: row.publicKey,
790
+ RK: rawRK,
678
791
  sessionID: row.sessionID,
679
- SK: await this.unsealHex(row.SK),
792
+ SK: rawSK,
793
+ skippedKeys: row.skippedKeys,
680
794
  userID: row.userID,
681
795
  verified: row.verified !== 0,
682
796
  };
683
797
  }
684
798
 
685
799
  private sqlToCrypto(session: SessionSQL): SessionCrypto {
800
+ const skippedKeys = this.parseSkippedKeys(session.skippedKeys);
686
801
  return {
802
+ CKr: session.CKr ? XUtils.decodeHex(session.CKr) : null,
803
+ CKs: session.CKs ? XUtils.decodeHex(session.CKs) : null,
804
+ DHr: session.DHr ? XUtils.decodeHex(session.DHr) : null,
805
+ DHsPrivate: XUtils.decodeHex(session.DHsPrivate),
806
+ DHsPublic: XUtils.decodeHex(session.DHsPublic),
687
807
  fingerprint: XUtils.decodeHex(session.fingerprint),
688
808
  lastUsed: session.lastUsed,
689
809
  mode: session.mode,
810
+ Nr: session.Nr,
811
+ Ns: session.Ns,
812
+ PN: session.PN,
690
813
  publicKey: XUtils.decodeHex(session.publicKey),
814
+ RK: XUtils.decodeHex(session.RK),
691
815
  sessionID: session.sessionID,
692
816
  SK: XUtils.decodeHex(session.SK),
817
+ skippedKeys,
693
818
  userID: session.userID,
819
+ verified: session.verified,
694
820
  };
695
821
  }
696
822
 
@@ -25,14 +25,25 @@ export interface PreKeysCrypto extends UnsavedPreKey {
25
25
 
26
26
  /** In-memory representation of an encryption session (not yet persisted to SQL). */
27
27
  export interface SessionCrypto {
28
+ CKr: null | Uint8Array;
29
+ CKs: null | Uint8Array;
30
+ DHr: null | Uint8Array;
31
+ DHsPrivate: Uint8Array;
32
+ DHsPublic: Uint8Array;
28
33
  fingerprint: Uint8Array;
29
34
  lastUsed: string;
30
35
  mode: "initiator" | "receiver";
36
+ Nr: number;
37
+ Ns: number;
38
+ PN: number;
31
39
  publicKey: Uint8Array;
40
+ RK: Uint8Array;
32
41
  sessionID: string;
33
42
  /** Shared secret key derived during X3DH. */
34
43
  SK: Uint8Array;
44
+ skippedKeys: Record<string, string>;
35
45
  userID: string;
46
+ verified: boolean;
36
47
  }
37
48
 
38
49
  /** Prekey before DB storage — no index yet. */
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { RatchetHeader, SessionSQL } from "@vex-chat/types";
8
+
9
+ import {
10
+ xBoxKeyPairAsync,
11
+ xConcat,
12
+ xDHAsync,
13
+ xHMAC,
14
+ xKDF,
15
+ XUtils,
16
+ } from "@vex-chat/crypto";
17
+
18
+ const VERSION = 1;
19
+
20
+ const encoder = new TextEncoder();
21
+
22
+ export function decodeRatchetHeader(extra: Uint8Array): RatchetHeader {
23
+ if (extra.length < 11) {
24
+ throw new Error("Malformed ratchet header: too short.");
25
+ }
26
+ const view = new DataView(extra.buffer, extra.byteOffset, extra.byteLength);
27
+ const version = view.getUint8(0);
28
+ if (version !== VERSION) {
29
+ throw new Error("Unsupported ratchet header version.");
30
+ }
31
+ const dhLen = view.getUint16(1, false);
32
+ const expected = 3 + dhLen + 8;
33
+ if (extra.length !== expected) {
34
+ throw new Error("Malformed ratchet header length.");
35
+ }
36
+ const dhPub = extra.slice(3, 3 + dhLen);
37
+ const pn = view.getUint32(3 + dhLen, false);
38
+ const n = view.getUint32(7 + dhLen, false);
39
+ return { dhPub, n, pn, version: 1 };
40
+ }
41
+
42
+ export function deriveBootstrapSendChain(rootKey: Uint8Array): Uint8Array {
43
+ return xHMAC({ label: "bootstrap-send-chain", version: VERSION }, rootKey);
44
+ }
45
+
46
+ export function deriveInitialRootKey(sk: Uint8Array): Uint8Array {
47
+ return xKDF(xConcat(sk, encoder.encode("dr-root-v1")));
48
+ }
49
+
50
+ export function encodeRatchetHeader(header: RatchetHeader): Uint8Array {
51
+ if (header.dhPub.length > 65535) {
52
+ throw new Error("Ratchet header dhPub too large.");
53
+ }
54
+ const out = new Uint8Array(3 + header.dhPub.length + 8);
55
+ const view = new DataView(out.buffer);
56
+ view.setUint8(0, VERSION);
57
+ view.setUint16(1, header.dhPub.length, false);
58
+ out.set(header.dhPub, 3);
59
+ view.setUint32(3 + header.dhPub.length, header.pn, false);
60
+ view.setUint32(7 + header.dhPub.length, header.n, false);
61
+ return out;
62
+ }
63
+
64
+ export function hasRemoteDhChanged(
65
+ current: null | Uint8Array,
66
+ incoming: Uint8Array,
67
+ ): boolean {
68
+ if (!current) {
69
+ return true;
70
+ }
71
+ return !XUtils.bytesEqual(current, incoming);
72
+ }
73
+
74
+ export async function initRatchetSession(
75
+ sk: Uint8Array,
76
+ mode: "initiator" | "receiver",
77
+ ): Promise<{
78
+ CKr: null | string;
79
+ CKs: null | string;
80
+ DHr: null | string;
81
+ DHsPrivate: string;
82
+ DHsPublic: string;
83
+ Nr: number;
84
+ Ns: number;
85
+ PN: number;
86
+ RK: string;
87
+ skippedKeys: string;
88
+ }> {
89
+ const RK = deriveInitialRootKey(sk);
90
+ const DHs = await xBoxKeyPairAsync();
91
+ const initialChain = xHMAC({ label: "init-chain", version: VERSION }, RK);
92
+ return {
93
+ CKr: mode === "receiver" ? XUtils.encodeHex(initialChain) : null,
94
+ CKs: mode === "initiator" ? XUtils.encodeHex(initialChain) : null,
95
+ DHr: null,
96
+ DHsPrivate: XUtils.encodeHex(DHs.secretKey),
97
+ DHsPublic: XUtils.encodeHex(DHs.publicKey),
98
+ Nr: 0,
99
+ Ns: 0,
100
+ PN: 0,
101
+ RK: XUtils.encodeHex(RK),
102
+ skippedKeys: "{}",
103
+ };
104
+ }
105
+
106
+ export async function ratchetStepReceive(
107
+ state: {
108
+ CKr: null | Uint8Array;
109
+ CKs: null | Uint8Array;
110
+ DHr: null | Uint8Array;
111
+ DHsPrivate: Uint8Array;
112
+ DHsPublic: Uint8Array;
113
+ Nr: number;
114
+ Ns: number;
115
+ PN: number;
116
+ RK: Uint8Array;
117
+ skippedKeys: Record<string, string>;
118
+ },
119
+ remoteDhPub: Uint8Array,
120
+ pn: number,
121
+ ): Promise<void> {
122
+ if (state.CKr && state.DHr) {
123
+ while (state.Nr < pn) {
124
+ const { chainKey, messageKey } = kdfChain(state.CKr);
125
+ state.CKr = chainKey;
126
+ state.skippedKeys[skippedKeyId(state.DHr, state.Nr)] =
127
+ XUtils.encodeHex(messageKey);
128
+ state.Nr += 1;
129
+ }
130
+ }
131
+
132
+ state.PN = state.Ns;
133
+ state.Ns = 0;
134
+ state.Nr = 0;
135
+ state.CKs = null;
136
+ state.DHr = remoteDhPub;
137
+
138
+ const dhOut = await xDHAsync(state.DHsPrivate, remoteDhPub);
139
+ const recv = kdfRoot(state.RK, dhOut);
140
+ state.RK = recv.rootKey;
141
+ state.CKr = recv.chainKey;
142
+ }
143
+
144
+ export async function ratchetStepSend(state: {
145
+ CKr: null | Uint8Array;
146
+ CKs: null | Uint8Array;
147
+ DHr: null | Uint8Array;
148
+ DHsPrivate: Uint8Array;
149
+ DHsPublic: Uint8Array;
150
+ Nr: number;
151
+ Ns: number;
152
+ PN: number;
153
+ RK: Uint8Array;
154
+ skippedKeys: Record<string, string>;
155
+ }): Promise<void> {
156
+ if (!state.DHr) {
157
+ if (!state.CKs) {
158
+ state.CKs = deriveBootstrapSendChain(state.RK);
159
+ }
160
+ return;
161
+ }
162
+ const nextDh = await xBoxKeyPairAsync();
163
+ state.PN = state.Ns;
164
+ state.Ns = 0;
165
+ state.DHsPrivate = nextDh.secretKey;
166
+ state.DHsPublic = nextDh.publicKey;
167
+ const dhOut = await xDHAsync(state.DHsPrivate, state.DHr);
168
+ const send = kdfRoot(state.RK, dhOut);
169
+ state.RK = send.rootKey;
170
+ state.CKs = send.chainKey;
171
+ }
172
+
173
+ export function sessionToSqlPatch(session: {
174
+ CKr: null | Uint8Array;
175
+ CKs: null | Uint8Array;
176
+ DHr: null | Uint8Array;
177
+ DHsPrivate: Uint8Array;
178
+ DHsPublic: Uint8Array;
179
+ Nr: number;
180
+ Ns: number;
181
+ PN: number;
182
+ RK: Uint8Array;
183
+ skippedKeys: Record<string, string>;
184
+ }): Pick<
185
+ SessionSQL,
186
+ | "CKr"
187
+ | "CKs"
188
+ | "DHr"
189
+ | "DHsPrivate"
190
+ | "DHsPublic"
191
+ | "Nr"
192
+ | "Ns"
193
+ | "PN"
194
+ | "RK"
195
+ | "skippedKeys"
196
+ > {
197
+ return {
198
+ CKr: session.CKr ? XUtils.encodeHex(session.CKr) : null,
199
+ CKs: session.CKs ? XUtils.encodeHex(session.CKs) : null,
200
+ DHr: session.DHr ? XUtils.encodeHex(session.DHr) : null,
201
+ DHsPrivate: XUtils.encodeHex(session.DHsPrivate),
202
+ DHsPublic: XUtils.encodeHex(session.DHsPublic),
203
+ Nr: session.Nr,
204
+ Ns: session.Ns,
205
+ PN: session.PN,
206
+ RK: XUtils.encodeHex(session.RK),
207
+ skippedKeys: JSON.stringify(session.skippedKeys),
208
+ };
209
+ }
210
+
211
+ export function takeReceiveMessageKey(
212
+ state: {
213
+ CKr: null | Uint8Array;
214
+ DHr: null | Uint8Array;
215
+ Nr: number;
216
+ skippedKeys: Record<string, string>;
217
+ },
218
+ remoteDhPub: Uint8Array,
219
+ n: number,
220
+ ): Uint8Array {
221
+ const skippedId = skippedKeyId(remoteDhPub, n);
222
+ const skipped = state.skippedKeys[skippedId];
223
+ if (skipped) {
224
+ const { [skippedId]: _discarded, ...rest } = state.skippedKeys;
225
+ state.skippedKeys = rest;
226
+ return XUtils.decodeHex(skipped);
227
+ }
228
+
229
+ if (!state.CKr) {
230
+ throw new Error("Missing receiving chain key.");
231
+ }
232
+
233
+ while (state.Nr < n) {
234
+ const { chainKey, messageKey } = kdfChain(state.CKr);
235
+ state.CKr = chainKey;
236
+ if (!state.DHr) {
237
+ throw new Error("Missing DHr when storing skipped key.");
238
+ }
239
+ state.skippedKeys[skippedKeyId(state.DHr, state.Nr)] =
240
+ XUtils.encodeHex(messageKey);
241
+ state.Nr += 1;
242
+ }
243
+
244
+ const { chainKey, messageKey } = kdfChain(state.CKr);
245
+ state.CKr = chainKey;
246
+ state.Nr += 1;
247
+ return messageKey;
248
+ }
249
+
250
+ export function takeSendMessageKey(state: {
251
+ CKs: null | Uint8Array;
252
+ Ns: number;
253
+ }): { messageKey: Uint8Array; n: number } {
254
+ if (!state.CKs) {
255
+ throw new Error("Missing sending chain key.");
256
+ }
257
+ const n = state.Ns;
258
+ const { chainKey, messageKey } = kdfChain(state.CKs);
259
+ state.CKs = chainKey;
260
+ state.Ns += 1;
261
+ return { messageKey, n };
262
+ }
263
+
264
+ function kdfChain(ck: Uint8Array): {
265
+ chainKey: Uint8Array;
266
+ messageKey: Uint8Array;
267
+ } {
268
+ return {
269
+ chainKey: xHMAC({ label: "ck-next", version: VERSION }, ck),
270
+ messageKey: xHMAC({ label: "msg-key", version: VERSION }, ck),
271
+ };
272
+ }
273
+
274
+ function kdfRoot(
275
+ rootKey: Uint8Array,
276
+ dhOut: Uint8Array,
277
+ ): { chainKey: Uint8Array; rootKey: Uint8Array } {
278
+ const material = xKDF(xConcat(rootKey, dhOut, encoder.encode("dr-v1")));
279
+ return {
280
+ chainKey: xHMAC({ label: "chain", version: VERSION }, material),
281
+ rootKey: xHMAC({ label: "root", version: VERSION }, material),
282
+ };
283
+ }
284
+
285
+ function skippedKeyId(dhPub: Uint8Array, n: number): string {
286
+ return `${XUtils.encodeHex(dhPub)}:${String(n)}`;
287
+ }
@@ -10,13 +10,43 @@ import type { SessionSQL } from "@vex-chat/types";
10
10
  import { XUtils } from "@vex-chat/crypto";
11
11
 
12
12
  export function sqlSessionToCrypto(session: SessionSQL): SessionCrypto {
13
+ const skippedKeys = parseSkippedKeys(session.skippedKeys);
13
14
  return {
15
+ CKr: session.CKr ? XUtils.decodeHex(session.CKr) : null,
16
+ CKs: session.CKs ? XUtils.decodeHex(session.CKs) : null,
17
+ DHr: session.DHr ? XUtils.decodeHex(session.DHr) : null,
18
+ DHsPrivate: XUtils.decodeHex(session.DHsPrivate),
19
+ DHsPublic: XUtils.decodeHex(session.DHsPublic),
14
20
  fingerprint: XUtils.decodeHex(session.fingerprint),
15
21
  lastUsed: session.lastUsed,
16
22
  mode: session.mode,
23
+ Nr: session.Nr,
24
+ Ns: session.Ns,
25
+ PN: session.PN,
17
26
  publicKey: XUtils.decodeHex(session.publicKey),
27
+ RK: XUtils.decodeHex(session.RK),
18
28
  sessionID: session.sessionID,
19
29
  SK: XUtils.decodeHex(session.SK),
30
+ skippedKeys,
20
31
  userID: session.userID,
32
+ verified: session.verified,
21
33
  };
22
34
  }
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
+ }