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