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