@vex-chat/libvex 1.1.0 → 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 +440 -551
  3. package/dist/Client.d.ts.map +1 -0
  4. package/dist/Client.js +1566 -1502
  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 -8
  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 -11
  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 -54
  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 -291
  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 +3 -3
  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 -83
  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 -39
  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 -36
  136. package/dist/preset/tauri.js.map +0 -1
  137. package/dist/preset/types.d.ts +0 -14
  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,121 +277,21 @@ export class SqliteStorage extends EventEmitter {
96
277
  this.emit("ready");
97
278
  }
98
279
  catch (err) {
99
- this.emit("error", err);
100
- }
101
- }
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
- if (this.closing)
138
- return;
139
- if (err?.errno === 19 || err?.message?.includes("UNIQUE")) {
140
- this.log.warn("Duplicate nonce in message table.");
141
- }
142
- else {
143
- throw err;
144
- }
280
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
145
281
  }
146
282
  }
147
- async deleteMessage(mailID) {
283
+ async markSessionUsed(sessionID) {
148
284
  if (this.closing) {
149
- this.log.warn("Database is closing, deleteMessage() will not complete.");
285
+ this.log.warn("Database is closing, markSessionUsed() will not complete.");
150
286
  return;
151
287
  }
152
288
  await this.db
153
- .deleteFrom("messages")
154
- .where("mailID", "=", mailID)
155
- .execute();
156
- }
157
- async getMessageHistory(userID) {
158
- if (this.closing) {
159
- this.log.warn("Database is closing, getMessageHistory() will not complete.");
160
- return [];
161
- }
162
- const messages = await this.db
163
- .selectFrom("messages")
164
- .selectAll()
165
- .where((eb) => eb.or([
166
- eb.and([
167
- eb("direction", "=", "incoming"),
168
- eb("authorID", "=", userID),
169
- eb("group", "is", null),
170
- ]),
171
- eb.and([
172
- eb("direction", "=", "outgoing"),
173
- eb("readerID", "=", userID),
174
- eb("group", "is", null),
175
- ]),
176
- ]))
177
- .orderBy("timestamp", "asc")
178
- .execute();
179
- return this.decryptMessages(messages);
180
- }
181
- async getGroupHistory(channelID) {
182
- if (this.closing) {
183
- this.log.warn("Database is closing, getGroupHistory() will not complete.");
184
- return [];
185
- }
186
- const messages = await this.db
187
- .selectFrom("messages")
188
- .selectAll()
189
- .where("group", "=", channelID)
190
- .orderBy("timestamp", "asc")
191
- .execute();
192
- return this.decryptMessages(messages);
193
- }
194
- async deleteHistory(channelOrUserID, _olderThan) {
195
- await this.db
196
- .deleteFrom("messages")
197
- .where((eb) => eb.or([
198
- eb("group", "=", channelOrUserID),
199
- eb.and([
200
- eb("group", "is", null),
201
- eb("authorID", "=", channelOrUserID),
202
- ]),
203
- eb.and([
204
- eb("group", "is", null),
205
- eb("readerID", "=", channelOrUserID),
206
- ]),
207
- ]))
289
+ .updateTable("sessions")
290
+ .set({ lastUsed: new Date(Date.now()).toISOString() })
291
+ .where("sessionID", "=", sessionID)
208
292
  .execute();
209
293
  }
210
- async purgeHistory() {
211
- await this.db.deleteFrom("messages").execute();
212
- }
213
- // ── Sessions ─────────────────────────────────────────────────────────────
294
+ // ── PreKeys / OneTimeKeys ────────────────────────────────────────────────
214
295
  async markSessionVerified(sessionID) {
215
296
  if (this.closing) {
216
297
  this.log.warn("Database is closing, markSessionVerified() will not complete.");
@@ -222,101 +303,78 @@ export class SqliteStorage extends EventEmitter {
222
303
  .where("sessionID", "=", sessionID)
223
304
  .execute();
224
305
  }
225
- async markSessionUsed(sessionID) {
226
- if (this.closing) {
227
- this.log.warn("Database is closing, markSessionUsed() will not complete.");
228
- return;
229
- }
230
- await this.db
231
- .updateTable("sessions")
232
- .set({ lastUsed: new Date(Date.now()).toISOString() })
233
- .where("sessionID", "=", sessionID)
234
- .execute();
306
+ async purgeHistory() {
307
+ await this.db.deleteFrom("messages").execute();
235
308
  }
236
- async getSessionByPublicKey(publicKey) {
237
- if (this.closing) {
238
- this.log.warn("Database is closing, getSessionByPublicKey() will not complete.");
239
- return null;
240
- }
241
- const hex = XUtils.encodeHex(publicKey);
242
- const rows = await this.db
243
- .selectFrom("sessions")
244
- .selectAll()
245
- .where("publicKey", "=", hex)
246
- .limit(1)
247
- .execute();
248
- if (rows.length === 0) {
249
- this.log.warn(`getSessionByPublicKey(${hex}) => ${JSON.stringify(null)}`);
250
- return null;
251
- }
252
- 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();
253
314
  }
254
- async getAllSessions() {
315
+ async saveDevice(device) {
255
316
  if (this.closing) {
256
- this.log.warn("Database is closing, getAllSessions() will not complete.");
257
- return [];
317
+ this.log.warn("Database is closing, saveDevice() will not complete.");
318
+ return;
258
319
  }
259
- const rows = await this.db
260
- .selectFrom("sessions")
261
- .selectAll()
262
- .orderBy("lastUsed", "desc")
263
- .execute();
264
- return rows.map((s) => ({
265
- ...s,
266
- verified: Boolean(s.verified),
267
- }));
268
- }
269
- async getSessionByDeviceID(deviceID) {
270
- if (this.closing) {
271
- this.log.warn("Database is closing, getSessionByDeviceID() will not complete.");
272
- 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();
273
332
  }
274
- const rows = await this.db
275
- .selectFrom("sessions")
276
- .selectAll()
277
- .where("deviceID", "=", deviceID)
278
- .orderBy("lastUsed", "desc")
279
- .limit(1)
280
- .execute();
281
- if (rows.length === 0) {
282
- this.log.debug("getSession() => " + JSON.stringify(null));
283
- 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
+ }
284
340
  }
285
- return this.sqlToCrypto(rows[0]);
286
341
  }
287
- async saveSession(session) {
342
+ // ── Devices ──────────────────────────────────────────────────────────────
343
+ async saveMessage(message) {
288
344
  if (this.closing) {
289
- this.log.warn("Database is closing, saveSession() will not complete.");
345
+ this.log.warn("Database is closing, saveMessage() will not complete.");
290
346
  return;
291
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));
292
350
  try {
293
351
  await this.db
294
- .insertInto("sessions")
352
+ .insertInto("messages")
295
353
  .values({
296
- sessionID: session.sessionID,
297
- userID: session.userID,
298
- deviceID: session.deviceID,
299
- SK: session.SK,
300
- publicKey: session.publicKey,
301
- fingerprint: session.fingerprint,
302
- mode: session.mode,
303
- lastUsed: session.lastUsed instanceof Date
304
- ? session.lastUsed.toISOString()
305
- : String(session.lastUsed),
306
- 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,
307
366
  })
308
367
  .execute();
309
368
  }
310
369
  catch (err) {
311
- if (err?.errno === 19 || err?.message?.includes("UNIQUE")) {
312
- this.log.warn("Attempted to insert duplicate SK");
370
+ if (this.isDuplicateError(err)) {
371
+ this.log.warn("Duplicate nonce in message table.");
313
372
  }
314
373
  else {
315
374
  throw err;
316
375
  }
317
376
  }
318
377
  }
319
- // ── PreKeys / OneTimeKeys ────────────────────────────────────────────────
320
378
  async savePreKeys(preKeys, oneTime) {
321
379
  await this.untilReady();
322
380
  if (this.closing) {
@@ -324,152 +382,131 @@ export class SqliteStorage extends EventEmitter {
324
382
  return [];
325
383
  }
326
384
  const table = oneTime ? "oneTimeKeys" : "preKeys";
327
- const addedIndexes = [];
385
+ const saved = [];
328
386
  for (const preKey of preKeys) {
329
- const result = await this.db
387
+ const row = await this.db
330
388
  .insertInto(table)
331
389
  .values({
332
390
  privateKey: XUtils.encodeHex(preKey.keyPair.secretKey),
333
391
  publicKey: XUtils.encodeHex(preKey.keyPair.publicKey),
334
392
  signature: XUtils.encodeHex(preKey.signature),
335
393
  })
336
- .executeTakeFirst();
337
- if (result.insertId !== undefined) {
338
- addedIndexes.push(Number(result.insertId));
339
- }
340
- }
341
- const rows = await this.db
342
- .selectFrom(table)
343
- .selectAll()
344
- .where("index", "in", addedIndexes)
345
- .execute();
346
- return rows.map((key) => {
347
- delete key.privateKey;
348
- return key;
349
- });
350
- }
351
- async getPreKeys() {
352
- await this.untilReady();
353
- if (this.closing) {
354
- this.log.warn("Database is closing, getPreKeys() will not complete.");
355
- return null;
356
- }
357
- const rows = await this.db.selectFrom("preKeys").selectAll().execute();
358
- if (rows.length === 0) {
359
- this.log.debug("getPreKeys() => " + JSON.stringify(null));
360
- return null;
361
- }
362
- const preKeyInfo = rows[0];
363
- return {
364
- keyPair: nacl.box.keyPair.fromSecretKey(XUtils.decodeHex(preKeyInfo.privateKey)),
365
- signature: XUtils.decodeHex(preKeyInfo.signature),
366
- };
367
- }
368
- async getOneTimeKey(index) {
369
- await this.untilReady();
370
- if (this.closing) {
371
- this.log.warn("Database is closing, getOneTimeKey() will not complete.");
372
- return null;
373
- }
374
- const rows = await this.db
375
- .selectFrom("oneTimeKeys")
376
- .selectAll()
377
- .where("index", "=", index)
378
- .execute();
379
- if (rows.length === 0) {
380
- this.log.debug("getOneTimeKey() => " + JSON.stringify(null));
381
- return null;
382
- }
383
- const otkInfo = rows[0];
384
- return {
385
- keyPair: nacl.box.keyPair.fromSecretKey(XUtils.decodeHex(otkInfo.privateKey)),
386
- signature: XUtils.decodeHex(otkInfo.signature),
387
- index: otkInfo.index,
388
- };
389
- }
390
- async deleteOneTimeKey(index) {
391
- if (this.closing) {
392
- this.log.warn("Database is closing, deleteOneTimeKey() will not complete.");
393
- return;
394
- }
395
- await this.db
396
- .deleteFrom("oneTimeKeys")
397
- .where("index", "=", index)
398
- .execute();
399
- }
400
- // ── Devices ──────────────────────────────────────────────────────────────
401
- async getDevice(deviceID) {
402
- const rows = await this.db
403
- .selectFrom("devices")
404
- .selectAll()
405
- .where("deviceID", "=", deviceID)
406
- .execute();
407
- if (rows.length === 0) {
408
- return null;
394
+ .returning([
395
+ "deviceID",
396
+ "index",
397
+ "keyID",
398
+ "publicKey",
399
+ "signature",
400
+ "userID",
401
+ ])
402
+ .executeTakeFirstOrThrow();
403
+ saved.push(row);
409
404
  }
410
- return rows[0];
405
+ return saved;
411
406
  }
412
- async saveDevice(device) {
407
+ // ── Purge ────────────────────────────────────────────────────────────────
408
+ async saveSession(session) {
413
409
  if (this.closing) {
414
- this.log.warn("Database is closing, saveDevice() will not complete.");
410
+ this.log.warn("Database is closing, saveSession() will not complete.");
415
411
  return;
416
412
  }
417
413
  try {
418
414
  await this.db
419
- .insertInto("devices")
415
+ .insertInto("sessions")
420
416
  .values({
421
- deviceID: device.deviceID,
422
- owner: device.owner,
423
- signKey: device.signKey,
424
- name: device.name,
425
- lastLogin: device.lastLogin,
426
- 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,
427
426
  })
428
427
  .execute();
429
428
  }
430
429
  catch (err) {
431
- if (err?.errno === 19 || err?.message?.includes("UNIQUE")) {
432
- this.log.warn("Attempted to insert duplicate deviceID");
430
+ if (this.isDuplicateError(err)) {
431
+ this.log.warn("Attempted to insert duplicate SK");
433
432
  }
434
433
  else {
435
434
  throw err;
436
435
  }
437
436
  }
438
437
  }
439
- // ── Purge ────────────────────────────────────────────────────────────────
440
- async purgeKeyData() {
441
- await this.db.deleteFrom("sessions").execute();
442
- await this.db.deleteFrom("oneTimeKeys").execute();
443
- await this.db.deleteFrom("preKeys").execute();
444
- await this.db.deleteFrom("messages").execute();
445
- }
446
438
  // ── Private helpers ──────────────────────────────────────────────────────
447
439
  decryptMessages(messages) {
448
440
  return messages.map((msg) => {
449
- msg.timestamp = new Date(msg.timestamp);
450
- msg.decrypted = Boolean(msg.decrypted);
451
- msg.forward = Boolean(msg.forward);
452
- if (msg.decrypted) {
453
- 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);
454
445
  if (decrypted) {
455
- msg.message = XUtils.encodeUTF8(decrypted);
446
+ plaintext = XUtils.encodeUTF8(decrypted);
456
447
  }
457
448
  else {
458
449
  throw new Error("Couldn't decrypt messages on disk!");
459
450
  }
460
451
  }
461
- 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
+ };
462
467
  });
463
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
+ }
464
501
  sqlToCrypto(session) {
465
502
  return {
466
- sessionID: session.sessionID,
467
- userID: session.userID,
503
+ fingerprint: XUtils.decodeHex(session.fingerprint),
504
+ lastUsed: session.lastUsed,
468
505
  mode: session.mode,
469
- SK: XUtils.decodeHex(session.SK),
470
506
  publicKey: XUtils.decodeHex(session.publicKey),
471
- lastUsed: session.lastUsed,
472
- fingerprint: XUtils.decodeHex(session.fingerprint),
507
+ sessionID: session.sessionID,
508
+ SK: XUtils.decodeHex(session.SK),
509
+ userID: session.userID,
473
510
  };
474
511
  }
475
512
  async untilReady() {
@@ -477,8 +514,10 @@ export class SqliteStorage extends EventEmitter {
477
514
  return;
478
515
  return new Promise((resolve) => {
479
516
  const check = () => {
480
- if (this.ready)
481
- return resolve();
517
+ if (this.ready) {
518
+ resolve();
519
+ return;
520
+ }
482
521
  setTimeout(check, 10);
483
522
  };
484
523
  check();