@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
@@ -8,26 +8,207 @@
8
8
  * This replaces three separate storage classes (Storage.ts, TauriStorage,
9
9
  * ExpoStorage) with a single implementation.
10
10
  */
11
- import { XKeyConvert, XUtils } from "@vex-chat/crypto";
11
+ import { xBoxKeyPairFromSecret, XKeyConvert, xSecretbox, xSecretboxOpen, xSignKeyPairFromSecret, XUtils, } from "@vex-chat/crypto";
12
12
  import { EventEmitter } from "eventemitter3";
13
- import nacl from "tweetnacl";
14
13
  export class SqliteStorage extends EventEmitter {
15
14
  ready = false;
16
15
  closing = false;
17
16
  db;
18
- log;
19
17
  idKeys;
18
+ log;
20
19
  constructor(db, SK, logger) {
21
20
  super();
22
21
  this.db = db;
23
22
  this.log = logger;
24
- const idKeys = XKeyConvert.convertKeyPair(nacl.sign.keyPair.fromSecretKey(XUtils.decodeHex(SK)));
23
+ const idKeys = XKeyConvert.convertKeyPair(xSignKeyPairFromSecret(XUtils.decodeHex(SK)));
25
24
  if (!idKeys) {
26
25
  throw new Error("Can't convert SK!");
27
26
  }
28
27
  this.idKeys = idKeys;
29
28
  }
30
29
  // ── Lifecycle ────────────────────────────────────────────────────────────
30
+ async close() {
31
+ this.closing = true;
32
+ this.log.info("Closing database.");
33
+ await this.db.destroy();
34
+ }
35
+ async deleteHistory(channelOrUserID) {
36
+ await this.db
37
+ .deleteFrom("messages")
38
+ .where((eb) => eb.or([
39
+ eb("group", "=", channelOrUserID),
40
+ eb.and([
41
+ eb("group", "is", null),
42
+ eb("authorID", "=", channelOrUserID),
43
+ ]),
44
+ eb.and([
45
+ eb("group", "is", null),
46
+ eb("readerID", "=", channelOrUserID),
47
+ ]),
48
+ ]))
49
+ .execute();
50
+ }
51
+ // ── Messages ─────────────────────────────────────────────────────────────
52
+ async deleteMessage(mailID) {
53
+ if (this.closing) {
54
+ this.log.warn("Database is closing, deleteMessage() will not complete.");
55
+ return;
56
+ }
57
+ await this.db
58
+ .deleteFrom("messages")
59
+ .where("mailID", "=", mailID)
60
+ .execute();
61
+ }
62
+ async deleteOneTimeKey(index) {
63
+ if (this.closing) {
64
+ this.log.warn("Database is closing, deleteOneTimeKey() will not complete.");
65
+ return;
66
+ }
67
+ await this.db
68
+ .deleteFrom("oneTimeKeys")
69
+ .where("index", "=", index)
70
+ .execute();
71
+ }
72
+ async getAllSessions() {
73
+ if (this.closing) {
74
+ this.log.warn("Database is closing, getAllSessions() will not complete.");
75
+ return [];
76
+ }
77
+ const rows = await this.db
78
+ .selectFrom("sessions")
79
+ .selectAll()
80
+ .orderBy("lastUsed", "desc")
81
+ .execute();
82
+ return rows.map((s) => this.sessionRowToSQL(s));
83
+ }
84
+ async getDevice(deviceID) {
85
+ const rows = await this.db
86
+ .selectFrom("devices")
87
+ .selectAll()
88
+ .where("deviceID", "=", deviceID)
89
+ .execute();
90
+ const row = rows[0];
91
+ if (!row) {
92
+ return null;
93
+ }
94
+ return this.deviceRowToDevice(row);
95
+ }
96
+ async getGroupHistory(channelID) {
97
+ if (this.closing) {
98
+ this.log.warn("Database is closing, getGroupHistory() will not complete.");
99
+ return [];
100
+ }
101
+ const messages = await this.db
102
+ .selectFrom("messages")
103
+ .selectAll()
104
+ .where("group", "=", channelID)
105
+ .orderBy("timestamp", "asc")
106
+ .execute();
107
+ return this.decryptMessages(messages);
108
+ }
109
+ async getMessageHistory(userID) {
110
+ if (this.closing) {
111
+ this.log.warn("Database is closing, getMessageHistory() will not complete.");
112
+ return [];
113
+ }
114
+ const messages = await this.db
115
+ .selectFrom("messages")
116
+ .selectAll()
117
+ .where((eb) => eb.or([
118
+ eb.and([
119
+ eb("direction", "=", "incoming"),
120
+ eb("authorID", "=", userID),
121
+ eb("group", "is", null),
122
+ ]),
123
+ eb.and([
124
+ eb("direction", "=", "outgoing"),
125
+ eb("readerID", "=", userID),
126
+ eb("group", "is", null),
127
+ ]),
128
+ ]))
129
+ .orderBy("timestamp", "asc")
130
+ .execute();
131
+ return this.decryptMessages(messages);
132
+ }
133
+ // ── Sessions ─────────────────────────────────────────────────────────────
134
+ async getOneTimeKey(index) {
135
+ await this.untilReady();
136
+ if (this.closing) {
137
+ this.log.warn("Database is closing, getOneTimeKey() will not complete.");
138
+ return null;
139
+ }
140
+ const rows = await this.db
141
+ .selectFrom("oneTimeKeys")
142
+ .selectAll()
143
+ .where("index", "=", index)
144
+ .execute();
145
+ const otkInfo = rows[0];
146
+ if (!otkInfo) {
147
+ this.log.debug("getOneTimeKey() => " + JSON.stringify(null));
148
+ return null;
149
+ }
150
+ return {
151
+ index: otkInfo.index,
152
+ keyPair: xBoxKeyPairFromSecret(XUtils.decodeHex(otkInfo.privateKey)),
153
+ signature: XUtils.decodeHex(otkInfo.signature),
154
+ };
155
+ }
156
+ async getPreKeys() {
157
+ await this.untilReady();
158
+ if (this.closing) {
159
+ this.log.warn("Database is closing, getPreKeys() will not complete.");
160
+ return null;
161
+ }
162
+ const rows = await this.db.selectFrom("preKeys").selectAll().execute();
163
+ const preKeyInfo = rows[0];
164
+ if (!preKeyInfo) {
165
+ this.log.debug("getPreKeys() => " + JSON.stringify(null));
166
+ return null;
167
+ }
168
+ return {
169
+ index: preKeyInfo.index,
170
+ keyPair: xBoxKeyPairFromSecret(XUtils.decodeHex(preKeyInfo.privateKey)),
171
+ signature: XUtils.decodeHex(preKeyInfo.signature),
172
+ };
173
+ }
174
+ async getSessionByDeviceID(deviceID) {
175
+ if (this.closing) {
176
+ this.log.warn("Database is closing, getSessionByDeviceID() will not complete.");
177
+ return null;
178
+ }
179
+ const rows = await this.db
180
+ .selectFrom("sessions")
181
+ .selectAll()
182
+ .where("deviceID", "=", deviceID)
183
+ .orderBy("lastUsed", "desc")
184
+ .limit(1)
185
+ .execute();
186
+ const sessionRow = rows[0];
187
+ if (!sessionRow) {
188
+ this.log.debug("getSession() => " + JSON.stringify(null));
189
+ return null;
190
+ }
191
+ return this.sqlToCrypto(this.sessionRowToSQL(sessionRow));
192
+ }
193
+ async getSessionByPublicKey(publicKey) {
194
+ if (this.closing) {
195
+ this.log.warn("Database is closing, getSessionByPublicKey() will not complete.");
196
+ return null;
197
+ }
198
+ const hex = XUtils.encodeHex(publicKey);
199
+ const rows = await this.db
200
+ .selectFrom("sessions")
201
+ .selectAll()
202
+ .where("publicKey", "=", hex)
203
+ .limit(1)
204
+ .execute();
205
+ const sessionRow = rows[0];
206
+ if (!sessionRow) {
207
+ this.log.warn(`getSessionByPublicKey(${hex}) => ${JSON.stringify(null)}`);
208
+ return null;
209
+ }
210
+ return this.sqlToCrypto(this.sessionRowToSQL(sessionRow));
211
+ }
31
212
  async init() {
32
213
  this.log.info("Initializing database tables.");
33
214
  try {
@@ -96,120 +277,21 @@ export class SqliteStorage extends EventEmitter {
96
277
  this.emit("ready");
97
278
  }
98
279
  catch (err) {
99
- this.emit("error", err);
280
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
100
281
  }
101
282
  }
102
- async close() {
103
- this.closing = true;
104
- this.log.info("Closing database.");
105
- await this.db.destroy();
106
- }
107
- // ── Messages ─────────────────────────────────────────────────────────────
108
- async saveMessage(message) {
109
- if (this.closing) {
110
- this.log.warn("Database is closing, saveMessage() will not complete.");
111
- return;
112
- }
113
- // Encrypt plaintext with our idkey before saving to disk
114
- const encryptedMessage = XUtils.encodeHex(nacl.secretbox(XUtils.decodeUTF8(message.message), XUtils.decodeHex(message.nonce), this.idKeys.secretKey));
115
- try {
116
- await this.db
117
- .insertInto("messages")
118
- .values({
119
- nonce: message.nonce,
120
- sender: message.sender,
121
- recipient: message.recipient,
122
- group: message.group ?? null,
123
- mailID: message.mailID,
124
- message: encryptedMessage,
125
- direction: message.direction,
126
- timestamp: message.timestamp instanceof Date
127
- ? message.timestamp.toISOString()
128
- : String(message.timestamp),
129
- decrypted: message.decrypted ? 1 : 0,
130
- forward: message.forward ? 1 : 0,
131
- authorID: message.authorID,
132
- readerID: message.readerID,
133
- })
134
- .execute();
135
- }
136
- catch (err) {
137
- // SQLite UNIQUE constraint violation
138
- if (err?.errno === 19 || err?.message?.includes("UNIQUE")) {
139
- this.log.warn("Attempted to insert duplicate nonce into message table.");
140
- }
141
- else {
142
- throw err;
143
- }
144
- }
145
- }
146
- async deleteMessage(mailID) {
283
+ async markSessionUsed(sessionID) {
147
284
  if (this.closing) {
148
- this.log.warn("Database is closing, deleteMessage() will not complete.");
285
+ this.log.warn("Database is closing, markSessionUsed() will not complete.");
149
286
  return;
150
287
  }
151
288
  await this.db
152
- .deleteFrom("messages")
153
- .where("mailID", "=", mailID)
154
- .execute();
155
- }
156
- async getMessageHistory(userID) {
157
- if (this.closing) {
158
- this.log.warn("Database is closing, getMessageHistory() will not complete.");
159
- return [];
160
- }
161
- const messages = await this.db
162
- .selectFrom("messages")
163
- .selectAll()
164
- .where((eb) => eb.or([
165
- eb.and([
166
- eb("direction", "=", "incoming"),
167
- eb("authorID", "=", userID),
168
- eb("group", "is", null),
169
- ]),
170
- eb.and([
171
- eb("direction", "=", "outgoing"),
172
- eb("readerID", "=", userID),
173
- eb("group", "is", null),
174
- ]),
175
- ]))
176
- .orderBy("timestamp", "asc")
177
- .execute();
178
- return this.decryptMessages(messages);
179
- }
180
- async getGroupHistory(channelID) {
181
- if (this.closing) {
182
- this.log.warn("Database is closing, getGroupHistory() will not complete.");
183
- return [];
184
- }
185
- const messages = await this.db
186
- .selectFrom("messages")
187
- .selectAll()
188
- .where("group", "=", channelID)
189
- .orderBy("timestamp", "asc")
190
- .execute();
191
- return this.decryptMessages(messages);
192
- }
193
- async deleteHistory(channelOrUserID, _olderThan) {
194
- await this.db
195
- .deleteFrom("messages")
196
- .where((eb) => eb.or([
197
- eb("group", "=", channelOrUserID),
198
- eb.and([
199
- eb("group", "is", null),
200
- eb("authorID", "=", channelOrUserID),
201
- ]),
202
- eb.and([
203
- eb("group", "is", null),
204
- eb("readerID", "=", channelOrUserID),
205
- ]),
206
- ]))
289
+ .updateTable("sessions")
290
+ .set({ lastUsed: new Date(Date.now()).toISOString() })
291
+ .where("sessionID", "=", sessionID)
207
292
  .execute();
208
293
  }
209
- async purgeHistory() {
210
- await this.db.deleteFrom("messages").execute();
211
- }
212
- // ── Sessions ─────────────────────────────────────────────────────────────
294
+ // ── PreKeys / OneTimeKeys ────────────────────────────────────────────────
213
295
  async markSessionVerified(sessionID) {
214
296
  if (this.closing) {
215
297
  this.log.warn("Database is closing, markSessionVerified() will not complete.");
@@ -221,101 +303,78 @@ export class SqliteStorage extends EventEmitter {
221
303
  .where("sessionID", "=", sessionID)
222
304
  .execute();
223
305
  }
224
- async markSessionUsed(sessionID) {
225
- if (this.closing) {
226
- this.log.warn("Database is closing, markSessionUsed() will not complete.");
227
- return;
228
- }
229
- await this.db
230
- .updateTable("sessions")
231
- .set({ lastUsed: new Date(Date.now()).toISOString() })
232
- .where("sessionID", "=", sessionID)
233
- .execute();
306
+ async purgeHistory() {
307
+ await this.db.deleteFrom("messages").execute();
234
308
  }
235
- async getSessionByPublicKey(publicKey) {
236
- if (this.closing) {
237
- this.log.warn("Database is closing, getSessionByPublicKey() will not complete.");
238
- return null;
239
- }
240
- const hex = XUtils.encodeHex(publicKey);
241
- const rows = await this.db
242
- .selectFrom("sessions")
243
- .selectAll()
244
- .where("publicKey", "=", hex)
245
- .limit(1)
246
- .execute();
247
- if (rows.length === 0) {
248
- this.log.warn(`getSessionByPublicKey(${hex}) => ${JSON.stringify(null)}`);
249
- return null;
250
- }
251
- return this.sqlToCrypto(rows[0]);
309
+ async purgeKeyData() {
310
+ await this.db.deleteFrom("sessions").execute();
311
+ await this.db.deleteFrom("oneTimeKeys").execute();
312
+ await this.db.deleteFrom("preKeys").execute();
313
+ await this.db.deleteFrom("messages").execute();
252
314
  }
253
- async getAllSessions() {
315
+ async saveDevice(device) {
254
316
  if (this.closing) {
255
- this.log.warn("Database is closing, getAllSessions() will not complete.");
256
- return [];
317
+ this.log.warn("Database is closing, saveDevice() will not complete.");
318
+ return;
257
319
  }
258
- const rows = await this.db
259
- .selectFrom("sessions")
260
- .selectAll()
261
- .orderBy("lastUsed", "desc")
262
- .execute();
263
- return rows.map((s) => ({
264
- ...s,
265
- verified: Boolean(s.verified),
266
- }));
267
- }
268
- async getSessionByDeviceID(deviceID) {
269
- if (this.closing) {
270
- this.log.warn("Database is closing, getSessionByDeviceID() will not complete.");
271
- return null;
320
+ try {
321
+ await this.db
322
+ .insertInto("devices")
323
+ .values({
324
+ deleted: device.deleted ? 1 : 0,
325
+ deviceID: device.deviceID,
326
+ lastLogin: device.lastLogin,
327
+ name: device.name,
328
+ owner: device.owner,
329
+ signKey: device.signKey,
330
+ })
331
+ .execute();
272
332
  }
273
- const rows = await this.db
274
- .selectFrom("sessions")
275
- .selectAll()
276
- .where("deviceID", "=", deviceID)
277
- .orderBy("lastUsed", "desc")
278
- .limit(1)
279
- .execute();
280
- if (rows.length === 0) {
281
- this.log.debug("getSession() => " + JSON.stringify(null));
282
- return null;
333
+ catch (err) {
334
+ if (this.isDuplicateError(err)) {
335
+ this.log.warn("Attempted to insert duplicate deviceID");
336
+ }
337
+ else {
338
+ throw err;
339
+ }
283
340
  }
284
- return this.sqlToCrypto(rows[0]);
285
341
  }
286
- async saveSession(session) {
342
+ // ── Devices ──────────────────────────────────────────────────────────────
343
+ async saveMessage(message) {
287
344
  if (this.closing) {
288
- this.log.warn("Database is closing, saveSession() will not complete.");
345
+ this.log.warn("Database is closing, saveMessage() will not complete.");
289
346
  return;
290
347
  }
348
+ // Encrypt plaintext with our idkey before saving to disk
349
+ const encryptedMessage = XUtils.encodeHex(xSecretbox(XUtils.decodeUTF8(message.message), XUtils.decodeHex(message.nonce), this.idKeys.secretKey));
291
350
  try {
292
351
  await this.db
293
- .insertInto("sessions")
352
+ .insertInto("messages")
294
353
  .values({
295
- sessionID: session.sessionID,
296
- userID: session.userID,
297
- deviceID: session.deviceID,
298
- SK: session.SK,
299
- publicKey: session.publicKey,
300
- fingerprint: session.fingerprint,
301
- mode: session.mode,
302
- lastUsed: session.lastUsed instanceof Date
303
- ? session.lastUsed.toISOString()
304
- : String(session.lastUsed),
305
- verified: session.verified ? 1 : 0,
354
+ authorID: message.authorID,
355
+ decrypted: message.decrypted ? 1 : 0,
356
+ direction: message.direction,
357
+ forward: message.forward ? 1 : 0,
358
+ group: message.group ?? null,
359
+ mailID: message.mailID,
360
+ message: encryptedMessage,
361
+ nonce: message.nonce,
362
+ readerID: message.readerID,
363
+ recipient: message.recipient,
364
+ sender: message.sender,
365
+ timestamp: message.timestamp,
306
366
  })
307
367
  .execute();
308
368
  }
309
369
  catch (err) {
310
- if (err?.errno === 19 || err?.message?.includes("UNIQUE")) {
311
- this.log.warn("Attempted to insert duplicate SK");
370
+ if (this.isDuplicateError(err)) {
371
+ this.log.warn("Duplicate nonce in message table.");
312
372
  }
313
373
  else {
314
374
  throw err;
315
375
  }
316
376
  }
317
377
  }
318
- // ── PreKeys / OneTimeKeys ────────────────────────────────────────────────
319
378
  async savePreKeys(preKeys, oneTime) {
320
379
  await this.untilReady();
321
380
  if (this.closing) {
@@ -323,152 +382,131 @@ export class SqliteStorage extends EventEmitter {
323
382
  return [];
324
383
  }
325
384
  const table = oneTime ? "oneTimeKeys" : "preKeys";
326
- const addedIndexes = [];
385
+ const saved = [];
327
386
  for (const preKey of preKeys) {
328
- const result = await this.db
387
+ const row = await this.db
329
388
  .insertInto(table)
330
389
  .values({
331
390
  privateKey: XUtils.encodeHex(preKey.keyPair.secretKey),
332
391
  publicKey: XUtils.encodeHex(preKey.keyPair.publicKey),
333
392
  signature: XUtils.encodeHex(preKey.signature),
334
393
  })
335
- .executeTakeFirst();
336
- if (result.insertId !== undefined) {
337
- addedIndexes.push(Number(result.insertId));
338
- }
394
+ .returning([
395
+ "deviceID",
396
+ "index",
397
+ "keyID",
398
+ "publicKey",
399
+ "signature",
400
+ "userID",
401
+ ])
402
+ .executeTakeFirstOrThrow();
403
+ saved.push(row);
339
404
  }
340
- const rows = await this.db
341
- .selectFrom(table)
342
- .selectAll()
343
- .where("index", "in", addedIndexes)
344
- .execute();
345
- return rows.map((key) => {
346
- delete key.privateKey;
347
- return key;
348
- });
405
+ return saved;
349
406
  }
350
- async getPreKeys() {
351
- await this.untilReady();
352
- if (this.closing) {
353
- this.log.warn("Database is closing, getPreKeys() will not complete.");
354
- return null;
355
- }
356
- const rows = await this.db.selectFrom("preKeys").selectAll().execute();
357
- if (rows.length === 0) {
358
- this.log.debug("getPreKeys() => " + JSON.stringify(null));
359
- return null;
360
- }
361
- const preKeyInfo = rows[0];
362
- return {
363
- keyPair: nacl.box.keyPair.fromSecretKey(XUtils.decodeHex(preKeyInfo.privateKey)),
364
- signature: XUtils.decodeHex(preKeyInfo.signature),
365
- };
366
- }
367
- async getOneTimeKey(index) {
368
- await this.untilReady();
369
- if (this.closing) {
370
- this.log.warn("Database is closing, getOneTimeKey() will not complete.");
371
- return null;
372
- }
373
- const rows = await this.db
374
- .selectFrom("oneTimeKeys")
375
- .selectAll()
376
- .where("index", "=", index)
377
- .execute();
378
- if (rows.length === 0) {
379
- this.log.debug("getOneTimeKey() => " + JSON.stringify(null));
380
- return null;
381
- }
382
- const otkInfo = rows[0];
383
- return {
384
- keyPair: nacl.box.keyPair.fromSecretKey(XUtils.decodeHex(otkInfo.privateKey)),
385
- signature: XUtils.decodeHex(otkInfo.signature),
386
- index: otkInfo.index,
387
- };
388
- }
389
- async deleteOneTimeKey(index) {
390
- if (this.closing) {
391
- this.log.warn("Database is closing, deleteOneTimeKey() will not complete.");
392
- return;
393
- }
394
- await this.db
395
- .deleteFrom("oneTimeKeys")
396
- .where("index", "=", index)
397
- .execute();
398
- }
399
- // ── Devices ──────────────────────────────────────────────────────────────
400
- async getDevice(deviceID) {
401
- const rows = await this.db
402
- .selectFrom("devices")
403
- .selectAll()
404
- .where("deviceID", "=", deviceID)
405
- .execute();
406
- if (rows.length === 0) {
407
- return null;
408
- }
409
- return rows[0];
410
- }
411
- async saveDevice(device) {
407
+ // ── Purge ────────────────────────────────────────────────────────────────
408
+ async saveSession(session) {
412
409
  if (this.closing) {
413
- this.log.warn("Database is closing, saveDevice() will not complete.");
410
+ this.log.warn("Database is closing, saveSession() will not complete.");
414
411
  return;
415
412
  }
416
413
  try {
417
414
  await this.db
418
- .insertInto("devices")
415
+ .insertInto("sessions")
419
416
  .values({
420
- deviceID: device.deviceID,
421
- owner: device.owner,
422
- signKey: device.signKey,
423
- name: device.name,
424
- lastLogin: device.lastLogin,
425
- deleted: device.deleted ? 1 : 0,
417
+ deviceID: session.deviceID,
418
+ fingerprint: session.fingerprint,
419
+ lastUsed: session.lastUsed,
420
+ mode: session.mode,
421
+ publicKey: session.publicKey,
422
+ sessionID: session.sessionID,
423
+ SK: session.SK,
424
+ userID: session.userID,
425
+ verified: session.verified ? 1 : 0,
426
426
  })
427
427
  .execute();
428
428
  }
429
429
  catch (err) {
430
- if (err?.errno === 19 || err?.message?.includes("UNIQUE")) {
431
- this.log.warn("Attempted to insert duplicate deviceID");
430
+ if (this.isDuplicateError(err)) {
431
+ this.log.warn("Attempted to insert duplicate SK");
432
432
  }
433
433
  else {
434
434
  throw err;
435
435
  }
436
436
  }
437
437
  }
438
- // ── Purge ────────────────────────────────────────────────────────────────
439
- async purgeKeyData() {
440
- await this.db.deleteFrom("sessions").execute();
441
- await this.db.deleteFrom("oneTimeKeys").execute();
442
- await this.db.deleteFrom("preKeys").execute();
443
- await this.db.deleteFrom("messages").execute();
444
- }
445
438
  // ── Private helpers ──────────────────────────────────────────────────────
446
439
  decryptMessages(messages) {
447
440
  return messages.map((msg) => {
448
- msg.timestamp = new Date(msg.timestamp);
449
- msg.decrypted = Boolean(msg.decrypted);
450
- msg.forward = Boolean(msg.forward);
451
- if (msg.decrypted) {
452
- const decrypted = nacl.secretbox.open(XUtils.decodeHex(msg.message), XUtils.decodeHex(msg.nonce), this.idKeys.secretKey);
441
+ const decryptedFlag = msg.decrypted !== 0;
442
+ let plaintext = msg.message;
443
+ if (decryptedFlag) {
444
+ const decrypted = xSecretboxOpen(XUtils.decodeHex(msg.message), XUtils.decodeHex(msg.nonce), this.idKeys.secretKey);
453
445
  if (decrypted) {
454
- msg.message = XUtils.encodeUTF8(decrypted);
446
+ plaintext = XUtils.encodeUTF8(decrypted);
455
447
  }
456
448
  else {
457
449
  throw new Error("Couldn't decrypt messages on disk!");
458
450
  }
459
451
  }
460
- return msg;
452
+ const direction = msg.direction === "incoming" ? "incoming" : "outgoing";
453
+ return {
454
+ authorID: msg.authorID,
455
+ decrypted: decryptedFlag,
456
+ direction,
457
+ forward: msg.forward !== 0,
458
+ group: msg.group,
459
+ mailID: msg.mailID,
460
+ message: plaintext,
461
+ nonce: msg.nonce,
462
+ readerID: msg.readerID,
463
+ recipient: msg.recipient,
464
+ sender: msg.sender,
465
+ timestamp: msg.timestamp,
466
+ };
461
467
  });
462
468
  }
469
+ deviceRowToDevice(row) {
470
+ return {
471
+ deleted: row.deleted !== 0,
472
+ deviceID: row.deviceID,
473
+ lastLogin: row.lastLogin,
474
+ name: row.name,
475
+ owner: row.owner,
476
+ signKey: row.signKey,
477
+ };
478
+ }
479
+ isDuplicateError(err) {
480
+ if (err instanceof Error) {
481
+ return err.message.includes("UNIQUE");
482
+ }
483
+ if (typeof err === "object" && err !== null && "errno" in err) {
484
+ return err.errno === 19;
485
+ }
486
+ return false;
487
+ }
488
+ sessionRowToSQL(row) {
489
+ return {
490
+ deviceID: row.deviceID,
491
+ fingerprint: row.fingerprint,
492
+ lastUsed: row.lastUsed,
493
+ mode: row.mode === "initiator" ? "initiator" : "receiver",
494
+ publicKey: row.publicKey,
495
+ sessionID: row.sessionID,
496
+ SK: row.SK,
497
+ userID: row.userID,
498
+ verified: row.verified !== 0,
499
+ };
500
+ }
463
501
  sqlToCrypto(session) {
464
502
  return {
465
- sessionID: session.sessionID,
466
- userID: session.userID,
503
+ fingerprint: XUtils.decodeHex(session.fingerprint),
504
+ lastUsed: session.lastUsed,
467
505
  mode: session.mode,
468
- SK: XUtils.decodeHex(session.SK),
469
506
  publicKey: XUtils.decodeHex(session.publicKey),
470
- lastUsed: session.lastUsed,
471
- fingerprint: XUtils.decodeHex(session.fingerprint),
507
+ sessionID: session.sessionID,
508
+ SK: XUtils.decodeHex(session.SK),
509
+ userID: session.userID,
472
510
  };
473
511
  }
474
512
  async untilReady() {
@@ -476,8 +514,10 @@ export class SqliteStorage extends EventEmitter {
476
514
  return;
477
515
  return new Promise((resolve) => {
478
516
  const check = () => {
479
- if (this.ready)
480
- return resolve();
517
+ if (this.ready) {
518
+ resolve();
519
+ return;
520
+ }
481
521
  setTimeout(check, 10);
482
522
  };
483
523
  check();