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