@vex-chat/libvex 1.0.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +103 -41
  2. package/dist/Client.d.ts +449 -554
  3. package/dist/Client.d.ts.map +1 -0
  4. package/dist/Client.js +1542 -1484
  5. package/dist/Client.js.map +1 -1
  6. package/dist/Storage.d.ts +111 -0
  7. package/dist/Storage.d.ts.map +1 -0
  8. package/dist/Storage.js +2 -0
  9. package/dist/Storage.js.map +1 -0
  10. package/dist/__tests__/harness/memory-storage.d.ts +29 -27
  11. package/dist/__tests__/harness/memory-storage.d.ts.map +1 -0
  12. package/dist/__tests__/harness/memory-storage.js +120 -109
  13. package/dist/__tests__/harness/memory-storage.js.map +1 -1
  14. package/dist/codec.d.ts +44 -0
  15. package/dist/codec.d.ts.map +1 -0
  16. package/dist/codec.js +51 -0
  17. package/dist/codec.js.map +1 -0
  18. package/dist/codecs.d.ts +201 -0
  19. package/dist/codecs.d.ts.map +1 -0
  20. package/dist/codecs.js +67 -0
  21. package/dist/codecs.js.map +1 -0
  22. package/dist/index.d.ts +7 -5
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/keystore/memory.d.ts +5 -4
  27. package/dist/keystore/memory.d.ts.map +1 -0
  28. package/dist/keystore/memory.js +9 -7
  29. package/dist/keystore/memory.js.map +1 -1
  30. package/dist/keystore/node.d.ts +6 -5
  31. package/dist/keystore/node.d.ts.map +1 -0
  32. package/dist/keystore/node.js +38 -19
  33. package/dist/keystore/node.js.map +1 -1
  34. package/dist/preset/common.d.ts +9 -0
  35. package/dist/preset/common.d.ts.map +1 -0
  36. package/dist/preset/common.js +2 -0
  37. package/dist/preset/common.js.map +1 -0
  38. package/dist/preset/node.d.ts +3 -5
  39. package/dist/preset/node.d.ts.map +1 -0
  40. package/dist/preset/node.js +5 -7
  41. package/dist/preset/node.js.map +1 -1
  42. package/dist/preset/test.d.ts +4 -4
  43. package/dist/preset/test.d.ts.map +1 -0
  44. package/dist/preset/test.js +8 -10
  45. package/dist/preset/test.js.map +1 -1
  46. package/dist/storage/node.d.ts +4 -3
  47. package/dist/storage/node.d.ts.map +1 -0
  48. package/dist/storage/node.js +4 -4
  49. package/dist/storage/node.js.map +1 -1
  50. package/dist/storage/schema.d.ts +55 -57
  51. package/dist/storage/schema.d.ts.map +1 -0
  52. package/dist/storage/sqlite.d.ts +33 -28
  53. package/dist/storage/sqlite.d.ts.map +1 -0
  54. package/dist/storage/sqlite.js +330 -290
  55. package/dist/storage/sqlite.js.map +1 -1
  56. package/dist/transport/types.d.ts +23 -16
  57. package/dist/transport/types.d.ts.map +1 -0
  58. package/dist/transport/websocket.d.ts +26 -0
  59. package/dist/transport/websocket.d.ts.map +1 -0
  60. package/dist/transport/websocket.js +83 -0
  61. package/dist/transport/websocket.js.map +1 -0
  62. package/dist/types/crypto.d.ts +35 -0
  63. package/dist/types/crypto.d.ts.map +1 -0
  64. package/dist/types/crypto.js +9 -0
  65. package/dist/types/crypto.js.map +1 -0
  66. package/dist/types/identity.d.ts +17 -0
  67. package/dist/types/identity.d.ts.map +1 -0
  68. package/dist/types/identity.js +6 -0
  69. package/dist/types/identity.js.map +1 -0
  70. package/dist/types/index.d.ts +3 -0
  71. package/dist/types/index.d.ts.map +1 -0
  72. package/dist/types/index.js +2 -0
  73. package/dist/types/index.js.map +1 -0
  74. package/dist/utils/capitalize.d.ts +1 -0
  75. package/dist/utils/capitalize.d.ts.map +1 -0
  76. package/dist/utils/createLogger.d.ts +1 -0
  77. package/dist/utils/createLogger.d.ts.map +1 -0
  78. package/dist/utils/createLogger.js +4 -11
  79. package/dist/utils/createLogger.js.map +1 -1
  80. package/dist/utils/formatBytes.d.ts +1 -0
  81. package/dist/utils/formatBytes.d.ts.map +1 -0
  82. package/dist/utils/formatBytes.js +3 -1
  83. package/dist/utils/formatBytes.js.map +1 -1
  84. package/dist/utils/sqlSessionToCrypto.d.ts +4 -2
  85. package/dist/utils/sqlSessionToCrypto.d.ts.map +1 -0
  86. package/dist/utils/sqlSessionToCrypto.js +5 -5
  87. package/dist/utils/sqlSessionToCrypto.js.map +1 -1
  88. package/dist/utils/uint8uuid.d.ts +1 -4
  89. package/dist/utils/uint8uuid.d.ts.map +1 -0
  90. package/dist/utils/uint8uuid.js +1 -7
  91. package/dist/utils/uint8uuid.js.map +1 -1
  92. package/package.json +58 -87
  93. package/src/Client.ts +3304 -0
  94. package/{dist/IStorage.d.ts → src/Storage.ts} +70 -62
  95. package/src/__tests__/codec.test.ts +251 -0
  96. package/src/__tests__/ghost.png +0 -0
  97. package/src/__tests__/harness/fixtures.ts +22 -0
  98. package/src/__tests__/harness/memory-storage.ts +254 -0
  99. package/src/__tests__/harness/platform-transports.ts +17 -0
  100. package/src/__tests__/harness/poison-node-imports.ts +108 -0
  101. package/src/__tests__/harness/shared-suite.ts +446 -0
  102. package/src/__tests__/platform-browser.test.ts +19 -0
  103. package/src/__tests__/platform-node.test.ts +10 -0
  104. package/src/__tests__/triggered.png +0 -0
  105. package/src/codec.ts +68 -0
  106. package/src/codecs.ts +101 -0
  107. package/src/index.ts +33 -0
  108. package/src/keystore/memory.ts +30 -0
  109. package/src/keystore/node.ts +91 -0
  110. package/src/preset/common.ts +13 -0
  111. package/src/preset/node.ts +34 -0
  112. package/src/preset/test.ts +37 -0
  113. package/src/storage/node.ts +33 -0
  114. package/src/storage/schema.ts +94 -0
  115. package/src/storage/sqlite.ts +676 -0
  116. package/src/transport/types.ts +29 -0
  117. package/src/transport/websocket.ts +106 -0
  118. package/src/types/crypto.ts +39 -0
  119. package/src/types/identity.ts +18 -0
  120. package/src/types/index.ts +9 -0
  121. package/src/utils/capitalize.ts +6 -0
  122. package/src/utils/createLogger.ts +37 -0
  123. package/src/utils/formatBytes.ts +15 -0
  124. package/src/utils/sqlSessionToCrypto.ts +16 -0
  125. package/src/utils/uint8uuid.ts +7 -0
  126. package/dist/IStorage.js +0 -2
  127. package/dist/IStorage.js.map +0 -1
  128. package/dist/keystore/types.d.ts +0 -4
  129. package/dist/keystore/types.js +0 -2
  130. package/dist/keystore/types.js.map +0 -1
  131. package/dist/preset/expo.d.ts +0 -2
  132. package/dist/preset/expo.js +0 -37
  133. package/dist/preset/expo.js.map +0 -1
  134. package/dist/preset/tauri.d.ts +0 -2
  135. package/dist/preset/tauri.js +0 -35
  136. package/dist/preset/tauri.js.map +0 -1
  137. package/dist/preset/types.d.ts +0 -13
  138. package/dist/preset/types.js +0 -2
  139. package/dist/preset/types.js.map +0 -1
  140. package/dist/storage/expo.d.ts +0 -3
  141. package/dist/storage/expo.js +0 -18
  142. package/dist/storage/expo.js.map +0 -1
  143. package/dist/storage/tauri.d.ts +0 -3
  144. package/dist/storage/tauri.js +0 -21
  145. package/dist/storage/tauri.js.map +0 -1
  146. package/dist/transport/browser.d.ts +0 -17
  147. package/dist/transport/browser.js +0 -56
  148. package/dist/transport/browser.js.map +0 -1
  149. package/dist/utils/constants.d.ts +0 -8
  150. package/dist/utils/constants.js +0 -9
  151. package/dist/utils/constants.js.map +0 -1
@@ -0,0 +1,676 @@
1
+ import type { Message } from "../index.js";
2
+ import type { Storage } from "../Storage.js";
3
+ import type { Logger } from "../transport/types.js";
4
+ import type {
5
+ PreKeysCrypto,
6
+ SessionCrypto,
7
+ UnsavedPreKey,
8
+ } from "../types/index.js";
9
+ import type {
10
+ ClientDatabase,
11
+ DeviceRow,
12
+ MessageRow,
13
+ SessionRow,
14
+ } from "./schema.js";
15
+ import type { Device, PreKeysSQL, SessionSQL } from "@vex-chat/types";
16
+ import type { Kysely } from "kysely";
17
+
18
+ /**
19
+ * Unified Kysely-based SQLite storage implementation.
20
+ *
21
+ * Accepts any `Kysely<ClientDatabase>` instance — the caller picks the
22
+ * dialect (better-sqlite3, Tauri plugin-sql, expo-sqlite, etc.) and
23
+ * passes the configured Kysely handle here.
24
+ *
25
+ * This replaces three separate storage classes (Storage.ts, TauriStorage,
26
+ * ExpoStorage) with a single implementation.
27
+ */
28
+ import {
29
+ type KeyPair,
30
+ xBoxKeyPairFromSecret,
31
+ XKeyConvert,
32
+ xSecretbox,
33
+ xSecretboxOpen,
34
+ xSignKeyPairFromSecret,
35
+ XUtils,
36
+ } from "@vex-chat/crypto";
37
+
38
+ import { EventEmitter } from "eventemitter3";
39
+
40
+ export class SqliteStorage extends EventEmitter implements Storage {
41
+ public ready = false;
42
+ private closing = false;
43
+ private readonly db: Kysely<ClientDatabase>;
44
+ private readonly idKeys: KeyPair;
45
+ private readonly log: Logger;
46
+
47
+ constructor(db: Kysely<ClientDatabase>, SK: string, logger: Logger) {
48
+ super();
49
+ this.db = db;
50
+ this.log = logger;
51
+
52
+ const idKeys = XKeyConvert.convertKeyPair(
53
+ xSignKeyPairFromSecret(XUtils.decodeHex(SK)),
54
+ );
55
+ if (!idKeys) {
56
+ throw new Error("Can't convert SK!");
57
+ }
58
+ this.idKeys = idKeys;
59
+ }
60
+
61
+ // ── Lifecycle ────────────────────────────────────────────────────────────
62
+
63
+ async close(): Promise<void> {
64
+ this.closing = true;
65
+ this.log.info("Closing database.");
66
+ await this.db.destroy();
67
+ }
68
+
69
+ async deleteHistory(channelOrUserID: string): Promise<void> {
70
+ await this.db
71
+ .deleteFrom("messages")
72
+ .where((eb) =>
73
+ eb.or([
74
+ eb("group", "=", channelOrUserID),
75
+ eb.and([
76
+ eb("group", "is", null),
77
+ eb("authorID", "=", channelOrUserID),
78
+ ]),
79
+ eb.and([
80
+ eb("group", "is", null),
81
+ eb("readerID", "=", channelOrUserID),
82
+ ]),
83
+ ]),
84
+ )
85
+ .execute();
86
+ }
87
+
88
+ // ── Messages ─────────────────────────────────────────────────────────────
89
+
90
+ async deleteMessage(mailID: string): Promise<void> {
91
+ if (this.closing) {
92
+ this.log.warn(
93
+ "Database is closing, deleteMessage() will not complete.",
94
+ );
95
+ return;
96
+ }
97
+ await this.db
98
+ .deleteFrom("messages")
99
+ .where("mailID", "=", mailID)
100
+ .execute();
101
+ }
102
+
103
+ async deleteOneTimeKey(index: number): Promise<void> {
104
+ if (this.closing) {
105
+ this.log.warn(
106
+ "Database is closing, deleteOneTimeKey() will not complete.",
107
+ );
108
+ return;
109
+ }
110
+ await this.db
111
+ .deleteFrom("oneTimeKeys")
112
+ .where("index", "=", index)
113
+ .execute();
114
+ }
115
+
116
+ async getAllSessions(): Promise<SessionSQL[]> {
117
+ if (this.closing) {
118
+ this.log.warn(
119
+ "Database is closing, getAllSessions() will not complete.",
120
+ );
121
+ return [];
122
+ }
123
+ const rows = await this.db
124
+ .selectFrom("sessions")
125
+ .selectAll()
126
+ .orderBy("lastUsed", "desc")
127
+ .execute();
128
+
129
+ return rows.map((s) => this.sessionRowToSQL(s));
130
+ }
131
+
132
+ async getDevice(deviceID: string): Promise<Device | null> {
133
+ const rows = await this.db
134
+ .selectFrom("devices")
135
+ .selectAll()
136
+ .where("deviceID", "=", deviceID)
137
+ .execute();
138
+
139
+ const row = rows[0];
140
+ if (!row) {
141
+ return null;
142
+ }
143
+ return this.deviceRowToDevice(row);
144
+ }
145
+
146
+ async getGroupHistory(channelID: string): Promise<Message[]> {
147
+ if (this.closing) {
148
+ this.log.warn(
149
+ "Database is closing, getGroupHistory() will not complete.",
150
+ );
151
+ return [];
152
+ }
153
+
154
+ const messages = await this.db
155
+ .selectFrom("messages")
156
+ .selectAll()
157
+ .where("group", "=", channelID)
158
+ .orderBy("timestamp", "asc")
159
+ .execute();
160
+
161
+ return this.decryptMessages(messages);
162
+ }
163
+
164
+ async getMessageHistory(userID: string): Promise<Message[]> {
165
+ if (this.closing) {
166
+ this.log.warn(
167
+ "Database is closing, getMessageHistory() will not complete.",
168
+ );
169
+ return [];
170
+ }
171
+
172
+ const messages = await this.db
173
+ .selectFrom("messages")
174
+ .selectAll()
175
+ .where((eb) =>
176
+ eb.or([
177
+ eb.and([
178
+ eb("direction", "=", "incoming"),
179
+ eb("authorID", "=", userID),
180
+ eb("group", "is", null),
181
+ ]),
182
+ eb.and([
183
+ eb("direction", "=", "outgoing"),
184
+ eb("readerID", "=", userID),
185
+ eb("group", "is", null),
186
+ ]),
187
+ ]),
188
+ )
189
+ .orderBy("timestamp", "asc")
190
+ .execute();
191
+
192
+ return this.decryptMessages(messages);
193
+ }
194
+
195
+ // ── Sessions ─────────────────────────────────────────────────────────────
196
+
197
+ async getOneTimeKey(index: number): Promise<null | PreKeysCrypto> {
198
+ await this.untilReady();
199
+ if (this.closing) {
200
+ this.log.warn(
201
+ "Database is closing, getOneTimeKey() will not complete.",
202
+ );
203
+ return null;
204
+ }
205
+
206
+ const rows = await this.db
207
+ .selectFrom("oneTimeKeys")
208
+ .selectAll()
209
+ .where("index", "=", index)
210
+ .execute();
211
+
212
+ const otkInfo = rows[0];
213
+ if (!otkInfo) {
214
+ this.log.debug("getOneTimeKey() => " + JSON.stringify(null));
215
+ return null;
216
+ }
217
+ return {
218
+ index: otkInfo.index,
219
+ keyPair: xBoxKeyPairFromSecret(
220
+ XUtils.decodeHex(otkInfo.privateKey),
221
+ ),
222
+ signature: XUtils.decodeHex(otkInfo.signature),
223
+ };
224
+ }
225
+
226
+ async getPreKeys(): Promise<null | PreKeysCrypto> {
227
+ await this.untilReady();
228
+ if (this.closing) {
229
+ this.log.warn(
230
+ "Database is closing, getPreKeys() will not complete.",
231
+ );
232
+ return null;
233
+ }
234
+
235
+ const rows = await this.db.selectFrom("preKeys").selectAll().execute();
236
+
237
+ const preKeyInfo = rows[0];
238
+ if (!preKeyInfo) {
239
+ this.log.debug("getPreKeys() => " + JSON.stringify(null));
240
+ return null;
241
+ }
242
+ return {
243
+ index: preKeyInfo.index,
244
+ keyPair: xBoxKeyPairFromSecret(
245
+ XUtils.decodeHex(preKeyInfo.privateKey),
246
+ ),
247
+ signature: XUtils.decodeHex(preKeyInfo.signature),
248
+ };
249
+ }
250
+
251
+ async getSessionByDeviceID(
252
+ deviceID: string,
253
+ ): Promise<null | SessionCrypto> {
254
+ if (this.closing) {
255
+ this.log.warn(
256
+ "Database is closing, getSessionByDeviceID() will not complete.",
257
+ );
258
+ return null;
259
+ }
260
+ const rows = await this.db
261
+ .selectFrom("sessions")
262
+ .selectAll()
263
+ .where("deviceID", "=", deviceID)
264
+ .orderBy("lastUsed", "desc")
265
+ .limit(1)
266
+ .execute();
267
+
268
+ const sessionRow = rows[0];
269
+ if (!sessionRow) {
270
+ this.log.debug("getSession() => " + JSON.stringify(null));
271
+ return null;
272
+ }
273
+
274
+ return this.sqlToCrypto(this.sessionRowToSQL(sessionRow));
275
+ }
276
+
277
+ async getSessionByPublicKey(
278
+ publicKey: Uint8Array,
279
+ ): Promise<null | SessionCrypto> {
280
+ if (this.closing) {
281
+ this.log.warn(
282
+ "Database is closing, getSessionByPublicKey() will not complete.",
283
+ );
284
+ return null;
285
+ }
286
+ const hex = XUtils.encodeHex(publicKey);
287
+
288
+ const rows = await this.db
289
+ .selectFrom("sessions")
290
+ .selectAll()
291
+ .where("publicKey", "=", hex)
292
+ .limit(1)
293
+ .execute();
294
+
295
+ const sessionRow = rows[0];
296
+ if (!sessionRow) {
297
+ this.log.warn(
298
+ `getSessionByPublicKey(${hex}) => ${JSON.stringify(null)}`,
299
+ );
300
+ return null;
301
+ }
302
+
303
+ return this.sqlToCrypto(this.sessionRowToSQL(sessionRow));
304
+ }
305
+
306
+ async init(): Promise<void> {
307
+ this.log.info("Initializing database tables.");
308
+ try {
309
+ await this.db.schema
310
+ .createTable("messages")
311
+ .ifNotExists()
312
+ .addColumn("nonce", "text", (col) => col.primaryKey())
313
+ .addColumn("sender", "text")
314
+ .addColumn("recipient", "text")
315
+ .addColumn("group", "text")
316
+ .addColumn("mailID", "text")
317
+ .addColumn("message", "text")
318
+ .addColumn("direction", "text")
319
+ .addColumn("timestamp", "text")
320
+ .addColumn("decrypted", "integer")
321
+ .addColumn("forward", "integer")
322
+ .addColumn("authorID", "text")
323
+ .addColumn("readerID", "text")
324
+ .execute();
325
+
326
+ await this.db.schema
327
+ .createTable("devices")
328
+ .ifNotExists()
329
+ .addColumn("deviceID", "text", (col) => col.primaryKey())
330
+ .addColumn("owner", "text")
331
+ .addColumn("signKey", "text")
332
+ .addColumn("name", "text")
333
+ .addColumn("lastLogin", "text")
334
+ .addColumn("deleted", "integer")
335
+ .execute();
336
+
337
+ await this.db.schema
338
+ .createTable("sessions")
339
+ .ifNotExists()
340
+ .addColumn("sessionID", "text", (col) => col.primaryKey())
341
+ .addColumn("userID", "text")
342
+ .addColumn("deviceID", "text")
343
+ .addColumn("SK", "text", (col) => col.unique())
344
+ .addColumn("publicKey", "text")
345
+ .addColumn("fingerprint", "text")
346
+ .addColumn("mode", "text")
347
+ .addColumn("lastUsed", "text")
348
+ .addColumn("verified", "integer")
349
+ .execute();
350
+
351
+ await this.db.schema
352
+ .createTable("preKeys")
353
+ .ifNotExists()
354
+ .addColumn("index", "integer", (col) =>
355
+ col.primaryKey().autoIncrement(),
356
+ )
357
+ .addColumn("keyID", "text", (col) => col.unique())
358
+ .addColumn("userID", "text")
359
+ .addColumn("deviceID", "text")
360
+ .addColumn("privateKey", "text")
361
+ .addColumn("publicKey", "text")
362
+ .addColumn("signature", "text")
363
+ .execute();
364
+
365
+ await this.db.schema
366
+ .createTable("oneTimeKeys")
367
+ .ifNotExists()
368
+ .addColumn("index", "integer", (col) =>
369
+ col.primaryKey().autoIncrement(),
370
+ )
371
+ .addColumn("keyID", "text", (col) => col.unique())
372
+ .addColumn("userID", "text")
373
+ .addColumn("deviceID", "text")
374
+ .addColumn("privateKey", "text")
375
+ .addColumn("publicKey", "text")
376
+ .addColumn("signature", "text")
377
+ .execute();
378
+
379
+ this.ready = true;
380
+ this.emit("ready");
381
+ } catch (err: unknown) {
382
+ this.emit(
383
+ "error",
384
+ err instanceof Error ? err : new Error(String(err)),
385
+ );
386
+ }
387
+ }
388
+
389
+ async markSessionUsed(sessionID: string): Promise<void> {
390
+ if (this.closing) {
391
+ this.log.warn(
392
+ "Database is closing, markSessionUsed() will not complete.",
393
+ );
394
+ return;
395
+ }
396
+ await this.db
397
+ .updateTable("sessions")
398
+ .set({ lastUsed: new Date(Date.now()).toISOString() })
399
+ .where("sessionID", "=", sessionID)
400
+ .execute();
401
+ }
402
+
403
+ // ── PreKeys / OneTimeKeys ────────────────────────────────────────────────
404
+
405
+ async markSessionVerified(sessionID: string): Promise<void> {
406
+ if (this.closing) {
407
+ this.log.warn(
408
+ "Database is closing, markSessionVerified() will not complete.",
409
+ );
410
+ return;
411
+ }
412
+ await this.db
413
+ .updateTable("sessions")
414
+ .set({ verified: 1 })
415
+ .where("sessionID", "=", sessionID)
416
+ .execute();
417
+ }
418
+
419
+ async purgeHistory(): Promise<void> {
420
+ await this.db.deleteFrom("messages").execute();
421
+ }
422
+
423
+ async purgeKeyData(): Promise<void> {
424
+ await this.db.deleteFrom("sessions").execute();
425
+ await this.db.deleteFrom("oneTimeKeys").execute();
426
+ await this.db.deleteFrom("preKeys").execute();
427
+ await this.db.deleteFrom("messages").execute();
428
+ }
429
+
430
+ async saveDevice(device: Device): Promise<void> {
431
+ if (this.closing) {
432
+ this.log.warn(
433
+ "Database is closing, saveDevice() will not complete.",
434
+ );
435
+ return;
436
+ }
437
+ try {
438
+ await this.db
439
+ .insertInto("devices")
440
+ .values({
441
+ deleted: device.deleted ? 1 : 0,
442
+ deviceID: device.deviceID,
443
+ lastLogin: device.lastLogin,
444
+ name: device.name,
445
+ owner: device.owner,
446
+ signKey: device.signKey,
447
+ })
448
+ .execute();
449
+ } catch (err: unknown) {
450
+ if (this.isDuplicateError(err)) {
451
+ this.log.warn("Attempted to insert duplicate deviceID");
452
+ } else {
453
+ throw err;
454
+ }
455
+ }
456
+ }
457
+
458
+ // ── Devices ──────────────────────────────────────────────────────────────
459
+
460
+ async saveMessage(message: Message): Promise<void> {
461
+ if (this.closing) {
462
+ this.log.warn(
463
+ "Database is closing, saveMessage() will not complete.",
464
+ );
465
+ return;
466
+ }
467
+
468
+ // Encrypt plaintext with our idkey before saving to disk
469
+ const encryptedMessage = XUtils.encodeHex(
470
+ xSecretbox(
471
+ XUtils.decodeUTF8(message.message),
472
+ XUtils.decodeHex(message.nonce),
473
+ this.idKeys.secretKey,
474
+ ),
475
+ );
476
+
477
+ try {
478
+ await this.db
479
+ .insertInto("messages")
480
+ .values({
481
+ authorID: message.authorID,
482
+ decrypted: message.decrypted ? 1 : 0,
483
+ direction: message.direction,
484
+ forward: message.forward ? 1 : 0,
485
+ group: message.group ?? null,
486
+ mailID: message.mailID,
487
+ message: encryptedMessage,
488
+ nonce: message.nonce,
489
+ readerID: message.readerID,
490
+ recipient: message.recipient,
491
+ sender: message.sender,
492
+ timestamp: message.timestamp,
493
+ })
494
+ .execute();
495
+ } catch (err: unknown) {
496
+ if (this.isDuplicateError(err)) {
497
+ this.log.warn("Duplicate nonce in message table.");
498
+ } else {
499
+ throw err;
500
+ }
501
+ }
502
+ }
503
+
504
+ async savePreKeys(
505
+ preKeys: UnsavedPreKey[],
506
+ oneTime: boolean,
507
+ ): Promise<PreKeysSQL[]> {
508
+ await this.untilReady();
509
+ if (this.closing) {
510
+ this.log.warn(
511
+ "Database is closing, savePreKeys() will not complete.",
512
+ );
513
+ return [];
514
+ }
515
+
516
+ const table = oneTime ? ("oneTimeKeys" as const) : ("preKeys" as const);
517
+ const saved: PreKeysSQL[] = [];
518
+
519
+ for (const preKey of preKeys) {
520
+ const row = await this.db
521
+ .insertInto(table)
522
+ .values({
523
+ privateKey: XUtils.encodeHex(preKey.keyPair.secretKey),
524
+ publicKey: XUtils.encodeHex(preKey.keyPair.publicKey),
525
+ signature: XUtils.encodeHex(preKey.signature),
526
+ })
527
+ .returning([
528
+ "deviceID",
529
+ "index",
530
+ "keyID",
531
+ "publicKey",
532
+ "signature",
533
+ "userID",
534
+ ])
535
+ .executeTakeFirstOrThrow();
536
+
537
+ saved.push(row);
538
+ }
539
+
540
+ return saved;
541
+ }
542
+
543
+ // ── Purge ────────────────────────────────────────────────────────────────
544
+
545
+ async saveSession(session: SessionSQL): Promise<void> {
546
+ if (this.closing) {
547
+ this.log.warn(
548
+ "Database is closing, saveSession() will not complete.",
549
+ );
550
+ return;
551
+ }
552
+ try {
553
+ await this.db
554
+ .insertInto("sessions")
555
+ .values({
556
+ deviceID: session.deviceID,
557
+ fingerprint: session.fingerprint,
558
+ lastUsed: session.lastUsed,
559
+ mode: session.mode,
560
+ publicKey: session.publicKey,
561
+ sessionID: session.sessionID,
562
+ SK: session.SK,
563
+ userID: session.userID,
564
+ verified: session.verified ? 1 : 0,
565
+ })
566
+ .execute();
567
+ } catch (err: unknown) {
568
+ if (this.isDuplicateError(err)) {
569
+ this.log.warn("Attempted to insert duplicate SK");
570
+ } else {
571
+ throw err;
572
+ }
573
+ }
574
+ }
575
+
576
+ // ── Private helpers ──────────────────────────────────────────────────────
577
+
578
+ private decryptMessages(messages: MessageRow[]): Message[] {
579
+ return messages.map((msg): Message => {
580
+ const decryptedFlag = msg.decrypted !== 0;
581
+ let plaintext = msg.message;
582
+
583
+ if (decryptedFlag) {
584
+ const decrypted = xSecretboxOpen(
585
+ XUtils.decodeHex(msg.message),
586
+ XUtils.decodeHex(msg.nonce),
587
+ this.idKeys.secretKey,
588
+ );
589
+ if (decrypted) {
590
+ plaintext = XUtils.encodeUTF8(decrypted);
591
+ } else {
592
+ throw new Error("Couldn't decrypt messages on disk!");
593
+ }
594
+ }
595
+
596
+ const direction =
597
+ msg.direction === "incoming" ? "incoming" : "outgoing";
598
+
599
+ return {
600
+ authorID: msg.authorID,
601
+ decrypted: decryptedFlag,
602
+ direction,
603
+ forward: msg.forward !== 0,
604
+ group: msg.group,
605
+ mailID: msg.mailID,
606
+ message: plaintext,
607
+ nonce: msg.nonce,
608
+ readerID: msg.readerID,
609
+ recipient: msg.recipient,
610
+ sender: msg.sender,
611
+ timestamp: msg.timestamp,
612
+ };
613
+ });
614
+ }
615
+
616
+ private deviceRowToDevice(row: DeviceRow): Device {
617
+ return {
618
+ deleted: row.deleted !== 0,
619
+ deviceID: row.deviceID,
620
+ lastLogin: row.lastLogin,
621
+ name: row.name,
622
+ owner: row.owner,
623
+ signKey: row.signKey,
624
+ };
625
+ }
626
+
627
+ private isDuplicateError(err: unknown): boolean {
628
+ if (err instanceof Error) {
629
+ return err.message.includes("UNIQUE");
630
+ }
631
+ if (typeof err === "object" && err !== null && "errno" in err) {
632
+ return err.errno === 19;
633
+ }
634
+ return false;
635
+ }
636
+
637
+ private sessionRowToSQL(row: SessionRow): SessionSQL {
638
+ return {
639
+ deviceID: row.deviceID,
640
+ fingerprint: row.fingerprint,
641
+ lastUsed: row.lastUsed,
642
+ mode: row.mode === "initiator" ? "initiator" : "receiver",
643
+ publicKey: row.publicKey,
644
+ sessionID: row.sessionID,
645
+ SK: row.SK,
646
+ userID: row.userID,
647
+ verified: row.verified !== 0,
648
+ };
649
+ }
650
+
651
+ private sqlToCrypto(session: SessionSQL): SessionCrypto {
652
+ return {
653
+ fingerprint: XUtils.decodeHex(session.fingerprint),
654
+ lastUsed: session.lastUsed,
655
+ mode: session.mode,
656
+ publicKey: XUtils.decodeHex(session.publicKey),
657
+ sessionID: session.sessionID,
658
+ SK: XUtils.decodeHex(session.SK),
659
+ userID: session.userID,
660
+ };
661
+ }
662
+
663
+ private async untilReady(): Promise<void> {
664
+ if (this.ready) return;
665
+ return new Promise((resolve) => {
666
+ const check = () => {
667
+ if (this.ready) {
668
+ resolve();
669
+ return;
670
+ }
671
+ setTimeout(check, 10);
672
+ };
673
+ check();
674
+ });
675
+ }
676
+ }
@@ -0,0 +1,29 @@
1
+ export interface Logger {
2
+ debug(message: string, ...args: unknown[]): void;
3
+ error(message: string, ...args: unknown[]): void;
4
+ info(message: string, ...args: unknown[]): void;
5
+ warn(message: string, ...args: unknown[]): void;
6
+ }
7
+
8
+ export type WebSocketEvent = keyof WebSocketEventMap;
9
+
10
+ export interface WebSocketEventMap {
11
+ close: [];
12
+ error: [error: Error];
13
+ message: [data: Uint8Array];
14
+ open: [];
15
+ }
16
+
17
+ export interface WebSocketLike {
18
+ close(): void;
19
+ off(event: "close" | "open", listener: () => void): void;
20
+ off(event: "error", listener: (error: Error) => void): void;
21
+ off(event: "message", listener: (data: Uint8Array) => void): void;
22
+ on(event: "close" | "open", listener: () => void): void;
23
+ on(event: "error", listener: (error: Error) => void): void;
24
+ on(event: "message", listener: (data: Uint8Array) => void): void;
25
+ onerror: ((err: Error | Event) => void) | null;
26
+ readyState: number;
27
+ send(data: Uint8Array): void;
28
+ terminate?(): void;
29
+ }