@vex-chat/libvex 2.0.0 → 5.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 +3 -2
- package/dist/Client.d.ts +83 -60
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +161 -275
- package/dist/Client.js.map +1 -1
- package/dist/Storage.d.ts +3 -3
- package/dist/codec.d.ts +4 -4
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +4 -4
- package/dist/codec.js.map +1 -1
- package/dist/codecs.d.ts +0 -4
- package/dist/codecs.d.ts.map +1 -1
- package/dist/codecs.js +0 -1
- package/dist/codecs.js.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keystore/node.d.ts +2 -1
- package/dist/keystore/node.d.ts.map +1 -1
- package/dist/keystore/node.js +9 -3
- package/dist/keystore/node.js.map +1 -1
- package/dist/preset/common.d.ts +1 -3
- package/dist/preset/common.d.ts.map +1 -1
- package/dist/preset/node.d.ts +1 -2
- package/dist/preset/node.d.ts.map +1 -1
- package/dist/preset/node.js +3 -7
- package/dist/preset/node.js.map +1 -1
- package/dist/preset/test.d.ts +0 -1
- package/dist/preset/test.d.ts.map +1 -1
- package/dist/preset/test.js +1 -15
- package/dist/preset/test.js.map +1 -1
- package/dist/storage/node.d.ts +1 -2
- package/dist/storage/node.d.ts.map +1 -1
- package/dist/storage/node.js +2 -8
- package/dist/storage/node.js.map +1 -1
- package/dist/storage/sqlite.d.ts +11 -3
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +36 -33
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/transport/types.d.ts +0 -6
- package/dist/transport/types.d.ts.map +1 -1
- package/dist/types/crypto.d.ts +5 -2
- package/dist/types/crypto.d.ts.map +1 -1
- package/dist/types/crypto.js +2 -2
- package/dist/types/identity.d.ts +6 -1
- package/dist/types/identity.d.ts.map +1 -1
- package/dist/types/identity.js +1 -1
- package/package.json +20 -12
- package/src/Client.ts +220 -428
- package/src/Storage.ts +3 -3
- package/src/__tests__/codec.test.ts +26 -21
- package/src/__tests__/harness/platform-transports.ts +2 -15
- package/src/__tests__/harness/poison-node-imports.ts +0 -1
- package/src/__tests__/harness/shared-suite.ts +1 -20
- package/src/__tests__/platform-browser.test.ts +5 -10
- package/src/__tests__/platform-node.test.ts +1 -2
- package/src/codec.ts +4 -4
- package/src/codecs.ts +0 -1
- package/src/index.ts +9 -2
- package/src/keystore/node.ts +14 -3
- package/src/preset/common.ts +1 -7
- package/src/preset/node.ts +3 -19
- package/src/preset/test.ts +1 -18
- package/src/storage/node.ts +2 -13
- package/src/storage/sqlite.ts +44 -65
- package/src/transport/types.ts +0 -7
- package/src/types/crypto.ts +5 -2
- package/src/types/identity.ts +6 -1
- package/dist/utils/createLogger.d.ts +0 -6
- package/dist/utils/createLogger.d.ts.map +0 -1
- package/dist/utils/createLogger.js +0 -27
- package/dist/utils/createLogger.js.map +0 -1
- package/src/utils/createLogger.ts +0 -37
package/dist/Client.js
CHANGED
|
@@ -11,7 +11,6 @@ function sleep(ms) {
|
|
|
11
11
|
import { msgpack } from "./codec.js";
|
|
12
12
|
import { ActionTokenCodec, AuthResponseCodec, ChannelArrayCodec, ChannelCodec, ConnectResponseCodec, decodeAxios, DeviceArrayCodec, DeviceChallengeCodec, DeviceCodec, EmojiArrayCodec, EmojiCodec, FileSQLCodec, InviteArrayCodec, InviteCodec, KeyBundleCodec, OtkCountCodec, PermissionArrayCodec, PermissionCodec, ServerArrayCodec, ServerCodec, UserArrayCodec, UserCodec, WhoamiCodec, } from "./codecs.js";
|
|
13
13
|
import { capitalize } from "./utils/capitalize.js";
|
|
14
|
-
import { formatBytes } from "./utils/formatBytes.js";
|
|
15
14
|
import { sqlSessionToCrypto } from "./utils/sqlSessionToCrypto.js";
|
|
16
15
|
import { uuidToUint8 } from "./utils/uint8uuid.js";
|
|
17
16
|
const _protocolMsgRegex = /��\w+:\w+��/g;
|
|
@@ -50,38 +49,43 @@ export class Client {
|
|
|
50
49
|
*/
|
|
51
50
|
static encryptKeyData = XUtils.encryptKeyData;
|
|
52
51
|
static NOT_FOUND_TTL = 30 * 60 * 1000;
|
|
52
|
+
/**
|
|
53
|
+
* Browser-safe NODE_ENV accessor.
|
|
54
|
+
* Uses indirect lookup so the bare `process` global never appears in
|
|
55
|
+
* source that the platform-guard plugin scans.
|
|
56
|
+
*/
|
|
53
57
|
/**
|
|
54
58
|
* Channel operations.
|
|
55
59
|
*/
|
|
56
60
|
channels = {
|
|
57
61
|
/**
|
|
58
62
|
* Creates a new channel in a server.
|
|
59
|
-
* @param name
|
|
60
|
-
* @param serverID
|
|
63
|
+
* @param name - The channel name.
|
|
64
|
+
* @param serverID - The server to create the channel in.
|
|
61
65
|
*
|
|
62
|
-
* @returns
|
|
66
|
+
* @returns The created Channel object.
|
|
63
67
|
*/
|
|
64
68
|
create: this.createChannel.bind(this),
|
|
65
69
|
/**
|
|
66
70
|
* Deletes a channel.
|
|
67
|
-
* @param channelID
|
|
71
|
+
* @param channelID - The channel to delete.
|
|
68
72
|
*/
|
|
69
73
|
delete: this.deleteChannel.bind(this),
|
|
70
74
|
/**
|
|
71
75
|
* Retrieves all channels in a server.
|
|
72
76
|
*
|
|
73
|
-
* @returns
|
|
77
|
+
* @returns The list of Channel objects.
|
|
74
78
|
*/
|
|
75
79
|
retrieve: this.getChannelList.bind(this),
|
|
76
80
|
/**
|
|
77
81
|
* Retrieves channel details by its unique channelID.
|
|
78
82
|
*
|
|
79
|
-
* @returns
|
|
83
|
+
* @returns The Channel object, or null.
|
|
80
84
|
*/
|
|
81
85
|
retrieveByID: this.getChannelByID.bind(this),
|
|
82
86
|
/**
|
|
83
87
|
* Retrieves a channel's userlist.
|
|
84
|
-
* @param channelID
|
|
88
|
+
* @param channelID - The channel to retrieve the userlist for.
|
|
85
89
|
*/
|
|
86
90
|
userList: this.getUserList.bind(this),
|
|
87
91
|
};
|
|
@@ -111,9 +115,9 @@ export class Client {
|
|
|
111
115
|
files = {
|
|
112
116
|
/**
|
|
113
117
|
* Uploads an encrypted file and returns the details and the secret key.
|
|
114
|
-
* @param file
|
|
118
|
+
* @param file - The file bytes.
|
|
115
119
|
*
|
|
116
|
-
* @returns
|
|
120
|
+
* @returns `[details, key]` — file metadata and the encryption key.
|
|
117
121
|
*/
|
|
118
122
|
create: this.createFile.bind(this),
|
|
119
123
|
retrieve: this.retrieveFile.bind(this),
|
|
@@ -140,19 +144,17 @@ export class Client {
|
|
|
140
144
|
*/
|
|
141
145
|
me = {
|
|
142
146
|
/**
|
|
143
|
-
* Retrieves current device details
|
|
147
|
+
* Retrieves current device details.
|
|
144
148
|
*
|
|
145
|
-
* @returns
|
|
149
|
+
* @returns The logged in device's Device object.
|
|
146
150
|
*/
|
|
147
151
|
device: this.getDevice.bind(this),
|
|
148
|
-
/**
|
|
149
|
-
* Changes your avatar.
|
|
150
|
-
*/
|
|
152
|
+
/** Changes your avatar. */
|
|
151
153
|
setAvatar: this.uploadAvatar.bind(this),
|
|
152
154
|
/**
|
|
153
|
-
* Retrieves your user information
|
|
155
|
+
* Retrieves your user information.
|
|
154
156
|
*
|
|
155
|
-
* @returns
|
|
157
|
+
* @returns The logged in user's User object.
|
|
156
158
|
*/
|
|
157
159
|
user: this.getUser.bind(this),
|
|
158
160
|
};
|
|
@@ -170,29 +172,29 @@ export class Client {
|
|
|
170
172
|
delete: this.deleteHistory.bind(this),
|
|
171
173
|
/**
|
|
172
174
|
* Send a group message to a channel.
|
|
173
|
-
* @param channelID
|
|
174
|
-
* @param message
|
|
175
|
+
* @param channelID - The channel to send a message to.
|
|
176
|
+
* @param message - The message to send.
|
|
175
177
|
*/
|
|
176
178
|
group: this.sendGroupMessage.bind(this),
|
|
177
179
|
purge: this.purgeHistory.bind(this),
|
|
178
180
|
/**
|
|
179
181
|
* Gets the message history with a specific userID.
|
|
180
|
-
* @param userID
|
|
182
|
+
* @param userID - The user to retrieve message history for.
|
|
181
183
|
*
|
|
182
|
-
* @returns
|
|
184
|
+
* @returns The list of Message objects.
|
|
183
185
|
*/
|
|
184
186
|
retrieve: this.getMessageHistory.bind(this),
|
|
185
187
|
/**
|
|
186
|
-
* Gets the group message history
|
|
187
|
-
* @param
|
|
188
|
+
* Gets the group message history for a channel.
|
|
189
|
+
* @param channelID - The channel to retrieve message history for.
|
|
188
190
|
*
|
|
189
|
-
* @returns
|
|
191
|
+
* @returns The list of Message objects.
|
|
190
192
|
*/
|
|
191
193
|
retrieveGroup: this.getGroupHistory.bind(this),
|
|
192
194
|
/**
|
|
193
195
|
* Send a direct message.
|
|
194
|
-
* @param userID
|
|
195
|
-
* @param message
|
|
196
|
+
* @param userID - The user to send a message to.
|
|
197
|
+
* @param message - The message to send.
|
|
196
198
|
*/
|
|
197
199
|
send: this.sendMessage.bind(this),
|
|
198
200
|
};
|
|
@@ -223,27 +225,27 @@ export class Client {
|
|
|
223
225
|
servers = {
|
|
224
226
|
/**
|
|
225
227
|
* Creates a new server.
|
|
226
|
-
* @param name
|
|
228
|
+
* @param name - The server name.
|
|
227
229
|
*
|
|
228
|
-
* @returns
|
|
230
|
+
* @returns The created Server object.
|
|
229
231
|
*/
|
|
230
232
|
create: this.createServer.bind(this),
|
|
231
233
|
/**
|
|
232
234
|
* Deletes a server.
|
|
233
|
-
* @param serverID
|
|
235
|
+
* @param serverID - The server to delete.
|
|
234
236
|
*/
|
|
235
237
|
delete: this.deleteServer.bind(this),
|
|
236
238
|
leave: this.leaveServer.bind(this),
|
|
237
239
|
/**
|
|
238
240
|
* Retrieves all servers the logged in user has access to.
|
|
239
241
|
*
|
|
240
|
-
* @returns
|
|
242
|
+
* @returns The list of Server objects.
|
|
241
243
|
*/
|
|
242
244
|
retrieve: this.getServerList.bind(this),
|
|
243
245
|
/**
|
|
244
246
|
* Retrieves server details by its unique serverID.
|
|
245
247
|
*
|
|
246
|
-
* @returns
|
|
248
|
+
* @returns The requested Server object, or null if the id does not exist.
|
|
247
249
|
*/
|
|
248
250
|
retrieveByID: this.getServerByID.bind(this),
|
|
249
251
|
};
|
|
@@ -252,23 +254,22 @@ export class Client {
|
|
|
252
254
|
*/
|
|
253
255
|
sessions = {
|
|
254
256
|
/**
|
|
255
|
-
* Marks a
|
|
257
|
+
* Marks a session as verified, implying that the user has confirmed
|
|
256
258
|
* that the session mnemonic matches with the other user.
|
|
257
|
-
* @param sessionID
|
|
258
|
-
* @param status Optionally, what to mark it as. Defaults to true.
|
|
259
|
+
* @param sessionID - The session to mark.
|
|
259
260
|
*/
|
|
260
261
|
markVerified: this.markSessionVerified.bind(this),
|
|
261
262
|
/**
|
|
262
263
|
* Gets all encryption sessions.
|
|
263
264
|
*
|
|
264
|
-
* @returns
|
|
265
|
+
* @returns The list of Session encryption sessions.
|
|
265
266
|
*/
|
|
266
267
|
retrieve: this.getSessionList.bind(this),
|
|
267
268
|
/**
|
|
268
269
|
* Returns a mnemonic for the session, to verify with the other user.
|
|
269
|
-
* @param session
|
|
270
|
+
* @param session - The session to get the mnemonic for.
|
|
270
271
|
*
|
|
271
|
-
* @returns
|
|
272
|
+
* @returns The mnemonic representation of the session.
|
|
272
273
|
*/
|
|
273
274
|
verify: (session) => Client.getMnemonic(session),
|
|
274
275
|
};
|
|
@@ -285,14 +286,14 @@ export class Client {
|
|
|
285
286
|
/**
|
|
286
287
|
* Retrieves the list of users you can currently access, or are already familiar with.
|
|
287
288
|
*
|
|
288
|
-
* @returns
|
|
289
|
+
* @returns The list of User objects.
|
|
289
290
|
*/
|
|
290
291
|
familiars: this.getFamiliars.bind(this),
|
|
291
292
|
/**
|
|
292
293
|
* Retrieves a user's information by a string identifier.
|
|
293
|
-
* @param identifier
|
|
294
|
+
* @param identifier - A userID, hex string public key, or a username.
|
|
294
295
|
*
|
|
295
|
-
* @returns
|
|
296
|
+
* @returns The user's User object, or null if the user does not exist.
|
|
296
297
|
*/
|
|
297
298
|
retrieve: this.fetchUser.bind(this),
|
|
298
299
|
};
|
|
@@ -309,7 +310,6 @@ export class Client {
|
|
|
309
310
|
http;
|
|
310
311
|
idKeys;
|
|
311
312
|
isAlive = true;
|
|
312
|
-
log;
|
|
313
313
|
mailInterval;
|
|
314
314
|
manuallyClosing = false;
|
|
315
315
|
/* Retrieves the userID with the user identifier.
|
|
@@ -321,6 +321,7 @@ export class Client {
|
|
|
321
321
|
pingInterval = null;
|
|
322
322
|
prefixes;
|
|
323
323
|
reading = false;
|
|
324
|
+
seenMailIDs = new Set();
|
|
324
325
|
sessionRecords = {};
|
|
325
326
|
// these are created from one set of sign keys
|
|
326
327
|
signKeys;
|
|
@@ -332,15 +333,17 @@ export class Client {
|
|
|
332
333
|
constructor(privateKey, options, storage) {
|
|
333
334
|
// (no super — composition, not inheritance)
|
|
334
335
|
this.options = options;
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
336
|
+
if (options?.unsafeHttp) {
|
|
337
|
+
const env = Client.getNodeEnv();
|
|
338
|
+
if (env !== "development" && env !== "test") {
|
|
339
|
+
throw new Error("unsafeHttp is only allowed when NODE_ENV is 'development' or 'test'. " +
|
|
340
|
+
"Set NODE_ENV=development to use unencrypted transport.");
|
|
341
|
+
}
|
|
342
|
+
this.prefixes = { HTTP: "http://", WS: "ws://" };
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
this.prefixes = { HTTP: "https://", WS: "wss://" };
|
|
346
|
+
}
|
|
344
347
|
this.signKeys = privateKey
|
|
345
348
|
? xSignKeyPairFromSecret(XUtils.decodeHex(privateKey))
|
|
346
349
|
: xSignKeyPair();
|
|
@@ -359,37 +362,19 @@ export class Client {
|
|
|
359
362
|
throw new Error("No storage provided. Use Client.create() which resolves storage automatically.");
|
|
360
363
|
}
|
|
361
364
|
this.database = storage;
|
|
362
|
-
this.database.on("error", (
|
|
363
|
-
this.log.error(error.toString());
|
|
365
|
+
this.database.on("error", (_error) => {
|
|
364
366
|
void this.close(true);
|
|
365
367
|
});
|
|
366
368
|
this.http = axios.create({ responseType: "arraybuffer" });
|
|
367
|
-
|
|
368
|
-
this.socket = new WebSocketAdapter("ws://localhost:1234");
|
|
369
|
+
this.socket = new WebSocketAdapter(this.prefixes.WS + this.host);
|
|
369
370
|
this.socket.onerror = () => { };
|
|
370
|
-
// Strip the `logger` field before stringifying — when a consumer
|
|
371
|
-
// passes a Winston logger instance (which has a circular
|
|
372
|
-
// `_readableState.pipes[0].parent` back-reference from the
|
|
373
|
-
// underlying file transport), JSON.stringify throws
|
|
374
|
-
// `TypeError: Converting circular structure to JSON`.
|
|
375
|
-
const { logger: _logger, ...safeOptions } = options ?? {};
|
|
376
|
-
this.log.info("Client debug information: " +
|
|
377
|
-
JSON.stringify({
|
|
378
|
-
dbPath: this.dbPath,
|
|
379
|
-
environment: {
|
|
380
|
-
platform: this.options?.deviceName ?? "unknown",
|
|
381
|
-
},
|
|
382
|
-
host: this.getHost(),
|
|
383
|
-
options: safeOptions,
|
|
384
|
-
publicKey: this.getKeys().public,
|
|
385
|
-
}, null, 4));
|
|
386
371
|
}
|
|
387
372
|
/**
|
|
388
373
|
* Creates and initializes a client in one step.
|
|
389
374
|
*
|
|
390
|
-
* @param privateKey
|
|
391
|
-
* @param options Runtime options.
|
|
392
|
-
* @param storage
|
|
375
|
+
* @param privateKey - Hex secret key. When omitted, a fresh key is generated.
|
|
376
|
+
* @param options - Runtime options.
|
|
377
|
+
* @param storage - Custom storage backend implementing {@link Storage}.
|
|
393
378
|
*
|
|
394
379
|
* @example
|
|
395
380
|
* ```ts
|
|
@@ -397,40 +382,27 @@ export class Client {
|
|
|
397
382
|
* ```
|
|
398
383
|
*/
|
|
399
384
|
static create = async (privateKey, options, storage) => {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const { createLogger: makeLog } = await import("./utils/createLogger.js");
|
|
403
|
-
opts = {
|
|
404
|
-
...opts,
|
|
405
|
-
logger: makeLog("libvex", opts?.logLevel),
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
// Lazily create Node Storage only on the Node path (no logger override).
|
|
409
|
-
// When a logger is provided (browser/RN), the caller must supply storage
|
|
410
|
-
// via BootstrapConfig.createStorage() — there is no Node fallback.
|
|
385
|
+
const opts = options;
|
|
386
|
+
const sk = privateKey ?? XUtils.encodeHex(xSignKeyPair().secretKey);
|
|
411
387
|
let resolvedStorage = storage;
|
|
412
388
|
if (!resolvedStorage) {
|
|
413
|
-
if (opts.logger) {
|
|
414
|
-
throw new Error("No storage provided. When using a custom logger (browser/RN), pass storage from your BootstrapConfig.");
|
|
415
|
-
}
|
|
416
389
|
const { createNodeStorage } = await import("./storage/node.js");
|
|
417
|
-
const dbFileName = opts
|
|
390
|
+
const dbFileName = opts?.inMemoryDb
|
|
418
391
|
? ":memory:"
|
|
419
|
-
: XUtils.encodeHex(xSignKeyPairFromSecret(XUtils.decodeHex(
|
|
420
|
-
|
|
421
|
-
const dbPath = opts.dbFolder
|
|
392
|
+
: XUtils.encodeHex(xSignKeyPairFromSecret(XUtils.decodeHex(sk)).publicKey) + ".sqlite";
|
|
393
|
+
const dbPath = opts?.dbFolder
|
|
422
394
|
? opts.dbFolder + "/" + dbFileName
|
|
423
395
|
: dbFileName;
|
|
424
|
-
resolvedStorage = createNodeStorage(dbPath,
|
|
396
|
+
resolvedStorage = createNodeStorage(dbPath, sk);
|
|
425
397
|
}
|
|
426
|
-
const client = new Client(
|
|
398
|
+
const client = new Client(sk, opts, resolvedStorage);
|
|
427
399
|
await client.init();
|
|
428
400
|
return client;
|
|
429
401
|
};
|
|
430
402
|
/**
|
|
431
403
|
* Generates an ed25519 secret key as a hex string.
|
|
432
404
|
*
|
|
433
|
-
* @returns
|
|
405
|
+
* @returns A secret key to use for the client. Save it permanently somewhere safe.
|
|
434
406
|
*/
|
|
435
407
|
static generateSecretKey() {
|
|
436
408
|
return XUtils.encodeHex(xSignKeyPair().secretKey);
|
|
@@ -438,7 +410,7 @@ export class Client {
|
|
|
438
410
|
/**
|
|
439
411
|
* Generates a random username using bip39.
|
|
440
412
|
*
|
|
441
|
-
* @returns
|
|
413
|
+
* @returns The username.
|
|
442
414
|
*/
|
|
443
415
|
static randomUsername() {
|
|
444
416
|
const IKM = XUtils.decodeHex(XUtils.encodeHex(xRandomBytes(16)));
|
|
@@ -468,9 +440,50 @@ export class Client {
|
|
|
468
440
|
static getMnemonic(session) {
|
|
469
441
|
return xMnemonic(xKDF(XUtils.decodeHex(session.fingerprint)));
|
|
470
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* Browser-safe NODE_ENV accessor.
|
|
445
|
+
* Uses indirect lookup so the bare `process` global never appears in
|
|
446
|
+
* source that the platform-guard plugin scans.
|
|
447
|
+
*/
|
|
448
|
+
static getNodeEnv() {
|
|
449
|
+
try {
|
|
450
|
+
const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
|
|
451
|
+
if (!g)
|
|
452
|
+
return undefined;
|
|
453
|
+
// Node 24+ exposes `process` as an accessor (get/set), not a value.
|
|
454
|
+
const proc = typeof g.get === "function" ? g.get() : g.value;
|
|
455
|
+
if (typeof proc !== "object" || proc === null) {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
|
|
459
|
+
if (!envDesc)
|
|
460
|
+
return undefined;
|
|
461
|
+
const env = typeof envDesc.get === "function"
|
|
462
|
+
? envDesc.get()
|
|
463
|
+
: envDesc.value;
|
|
464
|
+
if (typeof env !== "object" || env === null) {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
const valDesc = Object.getOwnPropertyDescriptor(env, "NODE_ENV");
|
|
468
|
+
if (!valDesc)
|
|
469
|
+
return undefined;
|
|
470
|
+
const val = typeof valDesc.get === "function"
|
|
471
|
+
? valDesc.get()
|
|
472
|
+
: valDesc.value;
|
|
473
|
+
return typeof val === "string" ? val : undefined;
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Closes the client — disconnects the WebSocket, shuts down storage,
|
|
481
|
+
* and emits `closed` unless `muteEvent` is `true`.
|
|
482
|
+
*
|
|
483
|
+
* @param muteEvent - When `true`, suppresses the `closed` event.
|
|
484
|
+
*/
|
|
471
485
|
async close(muteEvent = false) {
|
|
472
486
|
this.manuallyClosing = true;
|
|
473
|
-
this.log.info("Manually closing client.");
|
|
474
487
|
this.socket.close();
|
|
475
488
|
await this.database.close();
|
|
476
489
|
if (this.pingInterval) {
|
|
@@ -490,9 +503,10 @@ export class Client {
|
|
|
490
503
|
* You can check whoami() to see before calling connect().
|
|
491
504
|
*/
|
|
492
505
|
async connect() {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
506
|
+
if (!this.token) {
|
|
507
|
+
throw new Error("No token — call login() or loginWithDeviceKey() first.");
|
|
508
|
+
}
|
|
509
|
+
const { user } = await this.whoami();
|
|
496
510
|
this.setUser(user);
|
|
497
511
|
this.device = await this.retrieveOrCreateDevice();
|
|
498
512
|
const connectToken = await this.getToken("connect");
|
|
@@ -503,16 +517,12 @@ export class Client {
|
|
|
503
517
|
const res = await this.http.post(this.getHost() + "/device/" + this.device.deviceID + "/connect", msgpack.encode({ signed }), { headers: { "Content-Type": "application/msgpack" } });
|
|
504
518
|
const { deviceToken } = decodeAxios(ConnectResponseCodec, res.data);
|
|
505
519
|
this.http.defaults.headers.common["X-Device-Token"] = deviceToken;
|
|
506
|
-
this.log.info("Starting websocket.");
|
|
507
520
|
this.initSocket();
|
|
508
521
|
// Yield the event loop so the WS open callback fires and sends the
|
|
509
522
|
// auth message before OTK generation blocks for ~5s on mobile.
|
|
510
523
|
await new Promise((r) => setTimeout(r, 0));
|
|
511
524
|
await this.negotiateOTK();
|
|
512
525
|
}
|
|
513
|
-
/**
|
|
514
|
-
* Manually closes the client. Emits the closed event on successful shutdown.
|
|
515
|
-
*/
|
|
516
526
|
/**
|
|
517
527
|
* Delete all local data — message history, encryption sessions, and prekeys.
|
|
518
528
|
* Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
|
|
@@ -545,8 +555,8 @@ export class Client {
|
|
|
545
555
|
/**
|
|
546
556
|
* Authenticates with username/password and stores the Bearer auth token.
|
|
547
557
|
*
|
|
548
|
-
* @param username Account username.
|
|
549
|
-
* @param password Account password.
|
|
558
|
+
* @param username - Account username.
|
|
559
|
+
* @param password - Account password.
|
|
550
560
|
* @returns `{ ok: true }` on success, `{ ok: false, error }` on failure.
|
|
551
561
|
*
|
|
552
562
|
* @example
|
|
@@ -571,7 +581,6 @@ export class Client {
|
|
|
571
581
|
}
|
|
572
582
|
catch (err) {
|
|
573
583
|
const error = err instanceof Error ? err.message : String(err);
|
|
574
|
-
this.log.error("Login failed: " + error);
|
|
575
584
|
return { error, ok: false };
|
|
576
585
|
}
|
|
577
586
|
}
|
|
@@ -604,7 +613,6 @@ export class Client {
|
|
|
604
613
|
}
|
|
605
614
|
catch (err) {
|
|
606
615
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
607
|
-
this.log.error("Device-key auth failed: " + error.message);
|
|
608
616
|
return error;
|
|
609
617
|
}
|
|
610
618
|
return null;
|
|
@@ -615,17 +623,20 @@ export class Client {
|
|
|
615
623
|
async logout() {
|
|
616
624
|
await this.http.post(this.getHost() + "/goodbye");
|
|
617
625
|
}
|
|
626
|
+
/** Removes an event listener. See {@link ClientEvents} for available events. */
|
|
618
627
|
off(event, fn, context) {
|
|
619
628
|
this.emitter.off(event,
|
|
620
629
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ee3 requires generic listener type; E constraint guarantees safety
|
|
621
630
|
fn, context);
|
|
622
631
|
return this;
|
|
623
632
|
}
|
|
633
|
+
/** Subscribes to an event. See {@link ClientEvents} for available events. */
|
|
624
634
|
on(event, fn, context) {
|
|
625
635
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- EventEmitter requires a generic listener type; the generic constraint on E guarantees type safety
|
|
626
636
|
this.emitter.on(event, fn, context);
|
|
627
637
|
return this;
|
|
628
638
|
}
|
|
639
|
+
/** Subscribes to an event for a single firing, then auto-removes. */
|
|
629
640
|
once(event, fn, context) {
|
|
630
641
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- EventEmitter requires a generic listener type; the generic constraint on E guarantees type safety
|
|
631
642
|
this.emitter.once(event, fn, context);
|
|
@@ -633,11 +644,15 @@ export class Client {
|
|
|
633
644
|
}
|
|
634
645
|
/**
|
|
635
646
|
* Registers a new account on the server.
|
|
636
|
-
* @param username The username to register. Must be unique.
|
|
637
647
|
*
|
|
638
|
-
* @
|
|
648
|
+
* @param username - The username to register. Must be unique.
|
|
649
|
+
* @param password - Account password.
|
|
650
|
+
* @returns `[user, null]` on success, `[null, error]` on failure.
|
|
639
651
|
*
|
|
640
|
-
* @example
|
|
652
|
+
* @example
|
|
653
|
+
* ```ts
|
|
654
|
+
* const [user, err] = await client.register("MyUsername", "hunter2");
|
|
655
|
+
* ```
|
|
641
656
|
*/
|
|
642
657
|
async register(username, password) {
|
|
643
658
|
while (!this.xKeyRing) {
|
|
@@ -665,7 +680,11 @@ export class Client {
|
|
|
665
680
|
}
|
|
666
681
|
catch (err) {
|
|
667
682
|
if (isAxiosError(err) && err.response) {
|
|
668
|
-
|
|
683
|
+
const raw = err.response.data;
|
|
684
|
+
const msg = raw instanceof ArrayBuffer || raw instanceof Uint8Array
|
|
685
|
+
? new TextDecoder().decode(raw)
|
|
686
|
+
: String(raw);
|
|
687
|
+
return [null, new Error(msg)];
|
|
669
688
|
}
|
|
670
689
|
return [
|
|
671
690
|
null,
|
|
@@ -724,11 +743,9 @@ export class Client {
|
|
|
724
743
|
}
|
|
725
744
|
// returns the file details and the encryption key
|
|
726
745
|
async createFile(file) {
|
|
727
|
-
this.log.info("Creating file, size: " + formatBytes(file.byteLength));
|
|
728
746
|
const nonce = xMakeNonce();
|
|
729
747
|
const key = xBoxKeyPair();
|
|
730
748
|
const box = xSecretbox(Uint8Array.from(file), nonce, key.secretKey);
|
|
731
|
-
this.log.info("Encrypted size: " + formatBytes(box.byteLength));
|
|
732
749
|
if (typeof FormData !== "undefined") {
|
|
733
750
|
const fpayload = new FormData();
|
|
734
751
|
fpayload.set("owner", this.getDevice().deviceID);
|
|
@@ -767,7 +784,7 @@ export class Client {
|
|
|
767
784
|
duration,
|
|
768
785
|
serverID,
|
|
769
786
|
};
|
|
770
|
-
const res = await this.http.post(this.getHost() + "/server/" + serverID + "/invites", payload);
|
|
787
|
+
const res = await this.http.post(this.getHost() + "/server/" + serverID + "/invites", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
|
|
771
788
|
return decodeAxios(InviteCodec, res.data);
|
|
772
789
|
}
|
|
773
790
|
createPreKey() {
|
|
@@ -786,20 +803,12 @@ export class Client {
|
|
|
786
803
|
part of a group message */
|
|
787
804
|
mailID, forward) {
|
|
788
805
|
let keyBundle;
|
|
789
|
-
this.log.info("Requesting key bundle for device: " +
|
|
790
|
-
JSON.stringify(device, null, 4));
|
|
791
806
|
try {
|
|
792
807
|
keyBundle = await this.retrieveKeyBundle(device.deviceID);
|
|
793
808
|
}
|
|
794
|
-
catch
|
|
795
|
-
this.log.warn("Couldn't get key bundle:", err instanceof Error ? err.message : String(err));
|
|
809
|
+
catch {
|
|
796
810
|
return;
|
|
797
811
|
}
|
|
798
|
-
this.log.warn(this.toString() +
|
|
799
|
-
" retrieved keybundle #" +
|
|
800
|
-
String(keyBundle.otk?.index ?? "none") +
|
|
801
|
-
" for " +
|
|
802
|
-
device.deviceID);
|
|
803
812
|
if (!this.xKeyRing) {
|
|
804
813
|
throw new Error("Key ring not initialized.");
|
|
805
814
|
}
|
|
@@ -830,17 +839,10 @@ export class Client {
|
|
|
830
839
|
: XUtils.numberToUint8Arr(0);
|
|
831
840
|
// shared secret key
|
|
832
841
|
const SK = xKDF(IKM);
|
|
833
|
-
this.log.info("Obtained SK, " + XUtils.encodeHex(SK));
|
|
834
842
|
const PK = xBoxKeyPairFromSecret(SK).publicKey;
|
|
835
|
-
this.log.info(this.toString() +
|
|
836
|
-
" Obtained PK for " +
|
|
837
|
-
device.deviceID +
|
|
838
|
-
" " +
|
|
839
|
-
XUtils.encodeHex(PK));
|
|
840
843
|
const AD = xConcat(xEncode(xConstants.CURVE, IK_AP), xEncode(xConstants.CURVE, IK_B));
|
|
841
844
|
const nonce = xMakeNonce();
|
|
842
845
|
const cipher = xSecretbox(message, nonce, SK);
|
|
843
|
-
this.log.info("Encrypted ciphertext.");
|
|
844
846
|
/* 32 bytes for signkey, 32 bytes for ephemeral key,
|
|
845
847
|
68 bytes for AD, 6 bytes for otk index (empty for no otk) */
|
|
846
848
|
const extra = xConcat(this.signKeys.publicKey, this.xKeyRing.ephemeralKeys.publicKey, PK, AD, IDX);
|
|
@@ -858,8 +860,6 @@ export class Client {
|
|
|
858
860
|
sender: this.getDevice().deviceID,
|
|
859
861
|
};
|
|
860
862
|
const hmac = xHMAC(mail, SK);
|
|
861
|
-
this.log.info("Mail hash: " + JSON.stringify(mail));
|
|
862
|
-
this.log.info("Generated hmac: " + XUtils.encodeHex(hmac));
|
|
863
863
|
const msg = {
|
|
864
864
|
action: "CREATE",
|
|
865
865
|
data: mail,
|
|
@@ -869,8 +869,6 @@ export class Client {
|
|
|
869
869
|
};
|
|
870
870
|
// discard the ephemeral keys
|
|
871
871
|
this.newEphemeralKeys();
|
|
872
|
-
// save the encryption session
|
|
873
|
-
this.log.info("Saving new session.");
|
|
874
872
|
const sessionEntry = {
|
|
875
873
|
deviceID: device.deviceID,
|
|
876
874
|
fingerprint: XUtils.encodeHex(AD),
|
|
@@ -923,7 +921,6 @@ export class Client {
|
|
|
923
921
|
};
|
|
924
922
|
this.socket.on("message", callback);
|
|
925
923
|
void this.send(msg, hmac);
|
|
926
|
-
this.log.info("Mail sent.");
|
|
927
924
|
});
|
|
928
925
|
this.sending.delete(device.deviceID);
|
|
929
926
|
}
|
|
@@ -953,7 +950,7 @@ export class Client {
|
|
|
953
950
|
/**
|
|
954
951
|
* Gets a list of permissions for a server.
|
|
955
952
|
*
|
|
956
|
-
* @returns
|
|
953
|
+
* @returns The list of Permission objects.
|
|
957
954
|
*/
|
|
958
955
|
async fetchPermissionList(serverID) {
|
|
959
956
|
const res = await this.http.get(this.prefixes.HTTP +
|
|
@@ -1004,8 +1001,6 @@ export class Client {
|
|
|
1004
1001
|
}
|
|
1005
1002
|
const msgBytes = Uint8Array.from(msgpack.encode(copy));
|
|
1006
1003
|
const devices = await this.getUserDeviceList(this.getUser().userID);
|
|
1007
|
-
this.log.info("Forwarding to my other devices, deviceList length is " +
|
|
1008
|
-
String(devices?.length ?? 0));
|
|
1009
1004
|
if (!devices) {
|
|
1010
1005
|
throw new Error("Couldn't get own devices.");
|
|
1011
1006
|
}
|
|
@@ -1019,8 +1014,6 @@ export class Client {
|
|
|
1019
1014
|
for (const result of results) {
|
|
1020
1015
|
const { status } = result;
|
|
1021
1016
|
if (status === "rejected") {
|
|
1022
|
-
this.log.warn("Message failed.");
|
|
1023
|
-
this.log.warn(JSON.stringify(result));
|
|
1024
1017
|
}
|
|
1025
1018
|
}
|
|
1026
1019
|
});
|
|
@@ -1046,18 +1039,15 @@ export class Client {
|
|
|
1046
1039
|
}
|
|
1047
1040
|
async getDeviceByID(deviceID) {
|
|
1048
1041
|
if (deviceID in this.deviceRecords) {
|
|
1049
|
-
this.log.info("Found device in local cache.");
|
|
1050
1042
|
return this.deviceRecords[deviceID] ?? null;
|
|
1051
1043
|
}
|
|
1052
1044
|
const device = await this.database.getDevice(deviceID);
|
|
1053
1045
|
if (device) {
|
|
1054
|
-
this.log.info("Found device in local db.");
|
|
1055
1046
|
this.deviceRecords[deviceID] = device;
|
|
1056
1047
|
return device;
|
|
1057
1048
|
}
|
|
1058
1049
|
try {
|
|
1059
1050
|
const res = await this.http.get(this.getHost() + "/device/" + deviceID);
|
|
1060
|
-
this.log.info("Retrieved device from server.");
|
|
1061
1051
|
const fetchedDevice = decodeAxios(DeviceCodec, res.data);
|
|
1062
1052
|
this.deviceRecords[deviceID] = fetchedDevice;
|
|
1063
1053
|
await this.database.saveDevice(fetchedDevice);
|
|
@@ -1096,7 +1086,6 @@ export class Client {
|
|
|
1096
1086
|
if (firstFetch) {
|
|
1097
1087
|
this.emitter.emit("decryptingMail");
|
|
1098
1088
|
}
|
|
1099
|
-
this.log.info("fetching mail for device " + this.getDevice().deviceID);
|
|
1100
1089
|
try {
|
|
1101
1090
|
const res = await this.http.post(this.getHost() +
|
|
1102
1091
|
"/device/" +
|
|
@@ -1112,13 +1101,13 @@ export class Client {
|
|
|
1112
1101
|
try {
|
|
1113
1102
|
await this.readMail(mailHeader, mailBody, timestamp);
|
|
1114
1103
|
}
|
|
1115
|
-
catch (
|
|
1116
|
-
|
|
1104
|
+
catch (_readMailErr) {
|
|
1105
|
+
// non-fatal — inspect _readMailErr in a debugger
|
|
1117
1106
|
}
|
|
1118
1107
|
}
|
|
1119
1108
|
}
|
|
1120
|
-
catch (
|
|
1121
|
-
|
|
1109
|
+
catch (_fetchErr) {
|
|
1110
|
+
// non-fatal — inspect _fetchErr in a debugger
|
|
1122
1111
|
}
|
|
1123
1112
|
this.fetchingMail = false;
|
|
1124
1113
|
}
|
|
@@ -1149,7 +1138,7 @@ export class Client {
|
|
|
1149
1138
|
/**
|
|
1150
1139
|
* Gets all permissions for the logged in user.
|
|
1151
1140
|
*
|
|
1152
|
-
* @returns
|
|
1141
|
+
* @returns The list of Permission objects.
|
|
1153
1142
|
*/
|
|
1154
1143
|
async getPermissions() {
|
|
1155
1144
|
const res = await this.http.get(this.getHost() + "/user/" + this.getUser().userID + "/permissions");
|
|
@@ -1189,8 +1178,7 @@ export class Client {
|
|
|
1189
1178
|
});
|
|
1190
1179
|
return decodeAxios(ActionTokenCodec, res.data);
|
|
1191
1180
|
}
|
|
1192
|
-
catch
|
|
1193
|
-
this.log.warn(String(err));
|
|
1181
|
+
catch {
|
|
1194
1182
|
return null;
|
|
1195
1183
|
}
|
|
1196
1184
|
}
|
|
@@ -1222,7 +1210,6 @@ export class Client {
|
|
|
1222
1210
|
async handleNotify(msg) {
|
|
1223
1211
|
switch (msg.event) {
|
|
1224
1212
|
case "mail":
|
|
1225
|
-
this.log.info("Server has informed us of new mail.");
|
|
1226
1213
|
await this.getMail();
|
|
1227
1214
|
this.fetchingMail = false;
|
|
1228
1215
|
break;
|
|
@@ -1233,7 +1220,6 @@ export class Client {
|
|
|
1233
1220
|
// msg.data is the messageID for retry
|
|
1234
1221
|
break;
|
|
1235
1222
|
default:
|
|
1236
|
-
this.log.info("Unsupported notification event " + msg.event);
|
|
1237
1223
|
break;
|
|
1238
1224
|
}
|
|
1239
1225
|
}
|
|
@@ -1267,8 +1253,6 @@ export class Client {
|
|
|
1267
1253
|
// Auth sent as first message after open
|
|
1268
1254
|
this.socket = new WebSocketAdapter(wsUrl);
|
|
1269
1255
|
this.socket.on("open", () => {
|
|
1270
|
-
this.log.info("Connection opened.");
|
|
1271
|
-
// Send auth as first message (encoded to bytes — protocol is binary).
|
|
1272
1256
|
const authMsg = JSON.stringify({
|
|
1273
1257
|
token: this.token,
|
|
1274
1258
|
type: "auth",
|
|
@@ -1277,7 +1261,6 @@ export class Client {
|
|
|
1277
1261
|
this.pingInterval = setInterval(this.ping.bind(this), 15000);
|
|
1278
1262
|
});
|
|
1279
1263
|
this.socket.on("close", () => {
|
|
1280
|
-
this.log.info("Connection closed.");
|
|
1281
1264
|
if (this.pingInterval) {
|
|
1282
1265
|
clearInterval(this.pingInterval);
|
|
1283
1266
|
this.pingInterval = null;
|
|
@@ -1286,26 +1269,23 @@ export class Client {
|
|
|
1286
1269
|
this.emitter.emit("disconnect");
|
|
1287
1270
|
}
|
|
1288
1271
|
});
|
|
1289
|
-
this.socket.on("error", (
|
|
1290
|
-
|
|
1272
|
+
this.socket.on("error", (_error) => {
|
|
1273
|
+
if (!this.manuallyClosing) {
|
|
1274
|
+
this.emitter.emit("disconnect");
|
|
1275
|
+
}
|
|
1291
1276
|
});
|
|
1292
1277
|
this.socket.on("message", (message) => {
|
|
1293
|
-
const [
|
|
1294
|
-
this.log.debug("INH " + XUtils.encodeHex(header));
|
|
1295
|
-
this.log.debug("IN " + JSON.stringify(raw, null, 4));
|
|
1278
|
+
const [_header, raw] = XUtils.unpackMessage(message);
|
|
1296
1279
|
const parseResult = WSMessageSchema.safeParse(raw);
|
|
1297
1280
|
if (!parseResult.success) {
|
|
1298
|
-
this.log.warn("Unknown WS message: " + JSON.stringify(raw));
|
|
1299
1281
|
return;
|
|
1300
1282
|
}
|
|
1301
1283
|
const msg = parseResult.data;
|
|
1302
1284
|
switch (msg.type) {
|
|
1303
1285
|
case "challenge":
|
|
1304
|
-
this.log.info("Received challenge from server.");
|
|
1305
1286
|
this.respond(msg);
|
|
1306
1287
|
break;
|
|
1307
1288
|
case "error":
|
|
1308
|
-
this.log.warn(JSON.stringify(msg));
|
|
1309
1289
|
break;
|
|
1310
1290
|
case "notify":
|
|
1311
1291
|
void this.handleNotify(msg);
|
|
@@ -1321,13 +1301,10 @@ export class Client {
|
|
|
1321
1301
|
case "unauthorized":
|
|
1322
1302
|
throw new Error("Received unauthorized message from server.");
|
|
1323
1303
|
case "authorized":
|
|
1324
|
-
this.log.info("Authenticated with userID " +
|
|
1325
|
-
(this.user?.userID ?? "unknown"));
|
|
1326
1304
|
this.emitter.emit("connected");
|
|
1327
1305
|
void this.postAuth();
|
|
1328
1306
|
break;
|
|
1329
1307
|
default:
|
|
1330
|
-
this.log.info("Unsupported message " + msg.type);
|
|
1331
1308
|
break;
|
|
1332
1309
|
}
|
|
1333
1310
|
});
|
|
@@ -1359,10 +1336,8 @@ export class Client {
|
|
|
1359
1336
|
}
|
|
1360
1337
|
async negotiateOTK() {
|
|
1361
1338
|
const otkCount = await this.getOTKCount();
|
|
1362
|
-
this.log.info("Server reported OTK: " + otkCount.toString());
|
|
1363
1339
|
const needs = xConstants.MIN_OTK_SUPPLY - otkCount;
|
|
1364
1340
|
if (needs === 0) {
|
|
1365
|
-
this.log.info("Server otk supply full.");
|
|
1366
1341
|
return;
|
|
1367
1342
|
}
|
|
1368
1343
|
await this.submitOTK(needs);
|
|
@@ -1375,7 +1350,6 @@ export class Client {
|
|
|
1375
1350
|
}
|
|
1376
1351
|
ping() {
|
|
1377
1352
|
if (!this.isAlive) {
|
|
1378
|
-
this.log.warn("Ping failed.");
|
|
1379
1353
|
}
|
|
1380
1354
|
this.setAlive(false);
|
|
1381
1355
|
void this.send({ transmissionID: uuid.v4(), type: "ping" });
|
|
@@ -1392,7 +1366,6 @@ export class Client {
|
|
|
1392
1366
|
const existingPreKeys = await this.database.getPreKeys();
|
|
1393
1367
|
const preKeys = existingPreKeys ??
|
|
1394
1368
|
(await (async () => {
|
|
1395
|
-
this.log.warn("No prekeys found in database, creating a new one.");
|
|
1396
1369
|
const unsaved = this.createPreKey();
|
|
1397
1370
|
const [saved] = await this.database.savePreKeys([unsaved], false);
|
|
1398
1371
|
if (!saved || saved.index == null)
|
|
@@ -1410,12 +1383,6 @@ export class Client {
|
|
|
1410
1383
|
identityKeys,
|
|
1411
1384
|
preKeys,
|
|
1412
1385
|
};
|
|
1413
|
-
this.log.info("Keyring populated:\n" +
|
|
1414
|
-
JSON.stringify({
|
|
1415
|
-
ephemeralKey: XUtils.encodeHex(ephemeralKeys.publicKey),
|
|
1416
|
-
preKey: XUtils.encodeHex(preKeys.keyPair.publicKey),
|
|
1417
|
-
signKey: XUtils.encodeHex(this.signKeys.publicKey),
|
|
1418
|
-
}, null, 4));
|
|
1419
1386
|
}
|
|
1420
1387
|
async postAuth() {
|
|
1421
1388
|
let count = 0;
|
|
@@ -1429,9 +1396,7 @@ export class Client {
|
|
|
1429
1396
|
count = 0;
|
|
1430
1397
|
}
|
|
1431
1398
|
}
|
|
1432
|
-
catch
|
|
1433
|
-
this.log.warn("Problem fetching mail" + String(err));
|
|
1434
|
-
}
|
|
1399
|
+
catch { }
|
|
1435
1400
|
await sleep(1000 * 60);
|
|
1436
1401
|
}
|
|
1437
1402
|
}
|
|
@@ -1439,6 +1404,10 @@ export class Client {
|
|
|
1439
1404
|
await this.database.purgeHistory();
|
|
1440
1405
|
}
|
|
1441
1406
|
async readMail(header, mail, timestamp) {
|
|
1407
|
+
if (this.seenMailIDs.has(mail.mailID)) {
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
this.seenMailIDs.add(mail.mailID);
|
|
1442
1411
|
this.sendReceipt(new Uint8Array(mail.nonce));
|
|
1443
1412
|
let timeout = 1;
|
|
1444
1413
|
while (this.reading) {
|
|
@@ -1448,17 +1417,14 @@ export class Client {
|
|
|
1448
1417
|
this.reading = true;
|
|
1449
1418
|
try {
|
|
1450
1419
|
const healSession = async () => {
|
|
1451
|
-
this.log.info("Requesting retry of " + mail.mailID);
|
|
1452
1420
|
const deviceEntry = await this.getDeviceByID(mail.sender);
|
|
1453
1421
|
const [user, _err] = await this.fetchUser(mail.authorID);
|
|
1454
1422
|
if (deviceEntry && user) {
|
|
1455
1423
|
void this.createSession(deviceEntry, user, XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`), mail.group, uuid.v4(), false);
|
|
1456
1424
|
}
|
|
1457
1425
|
};
|
|
1458
|
-
this.log.info("Received mail from " + mail.sender);
|
|
1459
1426
|
switch (mail.mailType) {
|
|
1460
1427
|
case MailType.initial:
|
|
1461
|
-
this.log.info("Initiating new session.");
|
|
1462
1428
|
const extraParts = Client.deserializeExtra(MailType.initial, new Uint8Array(mail.extra));
|
|
1463
1429
|
const signKey = extraParts[0];
|
|
1464
1430
|
const ephKey = extraParts[1];
|
|
@@ -1467,34 +1433,15 @@ export class Client {
|
|
|
1467
1433
|
throw new Error("Malformed initial mail extra: missing signKey, ephKey, or indexBytes");
|
|
1468
1434
|
}
|
|
1469
1435
|
const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
|
|
1470
|
-
this.log.info(this.toString() +
|
|
1471
|
-
" otk #" +
|
|
1472
|
-
String(preKeyIndex) +
|
|
1473
|
-
" indicated");
|
|
1474
1436
|
const otk = preKeyIndex === 0
|
|
1475
1437
|
? null
|
|
1476
1438
|
: await this.database.getOneTimeKey(preKeyIndex);
|
|
1477
|
-
if (otk) {
|
|
1478
|
-
this.log.info("otk #" +
|
|
1479
|
-
JSON.stringify(otk.index) +
|
|
1480
|
-
" retrieved from database.");
|
|
1481
|
-
}
|
|
1482
|
-
this.log.info("signKey: " + XUtils.encodeHex(signKey));
|
|
1483
|
-
this.log.info("preKey: " + XUtils.encodeHex(ephKey));
|
|
1484
|
-
if (otk) {
|
|
1485
|
-
this.log.info("OTK: " + XUtils.encodeHex(otk.keyPair.publicKey));
|
|
1486
|
-
}
|
|
1487
1439
|
if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
|
|
1488
|
-
this.log.warn("OTK index mismatch, received " +
|
|
1489
|
-
JSON.stringify(otk?.index) +
|
|
1490
|
-
", expected " +
|
|
1491
|
-
preKeyIndex.toString());
|
|
1492
1440
|
return;
|
|
1493
1441
|
}
|
|
1494
1442
|
// their public keys
|
|
1495
1443
|
const IK_A_raw = XKeyConvert.convertPublicKey(signKey);
|
|
1496
1444
|
if (!IK_A_raw) {
|
|
1497
|
-
this.log.warn("Could not convert sign key to X25519.");
|
|
1498
1445
|
return;
|
|
1499
1446
|
}
|
|
1500
1447
|
const IK_A = IK_A_raw;
|
|
@@ -1518,31 +1465,15 @@ export class Client {
|
|
|
1518
1465
|
: xConcat(DH1, DH2, DH3);
|
|
1519
1466
|
// shared secret key
|
|
1520
1467
|
const SK = xKDF(IKM);
|
|
1521
|
-
this.log.info("Obtained SK for " +
|
|
1522
|
-
mail.sender +
|
|
1523
|
-
", " +
|
|
1524
|
-
XUtils.encodeHex(SK));
|
|
1525
|
-
// shared public key
|
|
1526
1468
|
const PK = xBoxKeyPairFromSecret(SK).publicKey;
|
|
1527
|
-
this.log.info(this.toString() +
|
|
1528
|
-
"Obtained PK for " +
|
|
1529
|
-
mail.sender +
|
|
1530
|
-
" " +
|
|
1531
|
-
XUtils.encodeHex(PK));
|
|
1532
1469
|
const hmac = xHMAC(mail, SK);
|
|
1533
|
-
this.log.info("Mail hash: " + JSON.stringify(mail));
|
|
1534
|
-
this.log.info("Calculated hmac: " + XUtils.encodeHex(hmac));
|
|
1535
1470
|
// associated data
|
|
1536
1471
|
const AD = xConcat(xEncode(xConstants.CURVE, IK_A), xEncode(xConstants.CURVE, IK_BP));
|
|
1537
1472
|
if (!XUtils.bytesEqual(hmac, header)) {
|
|
1538
|
-
console.warn("Mail authentication failed (HMAC did not match).");
|
|
1539
|
-
console.warn(mail);
|
|
1540
1473
|
return;
|
|
1541
1474
|
}
|
|
1542
|
-
this.log.info("Mail authenticated successfully.");
|
|
1543
1475
|
const unsealed = xSecretboxOpen(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), SK);
|
|
1544
1476
|
if (unsealed) {
|
|
1545
|
-
this.log.info("Decryption successful.");
|
|
1546
1477
|
let plaintext = "";
|
|
1547
1478
|
if (!mail.forward) {
|
|
1548
1479
|
plaintext = XUtils.encodeUTF8(unsealed);
|
|
@@ -1600,11 +1531,9 @@ export class Client {
|
|
|
1600
1531
|
this.emitter.emit("session", newSession, user);
|
|
1601
1532
|
}
|
|
1602
1533
|
else {
|
|
1603
|
-
this.log.warn("Couldn't retrieve user " + newSession.userID);
|
|
1604
1534
|
}
|
|
1605
1535
|
}
|
|
1606
1536
|
else {
|
|
1607
|
-
this.log.warn("Mail decryption failed.");
|
|
1608
1537
|
}
|
|
1609
1538
|
break;
|
|
1610
1539
|
case MailType.subsequent:
|
|
@@ -1615,34 +1544,24 @@ export class Client {
|
|
|
1615
1544
|
let session = await this.getSessionByPubkey(publicKey);
|
|
1616
1545
|
let retries = 0;
|
|
1617
1546
|
while (!session) {
|
|
1618
|
-
if (retries
|
|
1547
|
+
if (retries >= 3) {
|
|
1619
1548
|
break;
|
|
1620
1549
|
}
|
|
1621
|
-
|
|
1550
|
+
await sleep(100 * 2 ** retries);
|
|
1622
1551
|
retries++;
|
|
1623
|
-
|
|
1552
|
+
session = await this.getSessionByPubkey(publicKey);
|
|
1624
1553
|
}
|
|
1625
1554
|
if (!session) {
|
|
1626
|
-
this.log.warn("Couldn't find session public key " +
|
|
1627
|
-
XUtils.encodeHex(publicKey));
|
|
1628
1555
|
void healSession();
|
|
1629
1556
|
return;
|
|
1630
1557
|
}
|
|
1631
|
-
this.log.info("Session found for " + mail.sender);
|
|
1632
|
-
this.log.info("Mail nonce " +
|
|
1633
|
-
XUtils.encodeHex(new Uint8Array(mail.nonce)));
|
|
1634
1558
|
const HMAC = xHMAC(mail, session.SK);
|
|
1635
|
-
this.log.info("Mail hash: " + JSON.stringify(mail));
|
|
1636
|
-
this.log.info("Calculated hmac: " + XUtils.encodeHex(HMAC));
|
|
1637
1559
|
if (!XUtils.bytesEqual(HMAC, header)) {
|
|
1638
|
-
this.log.warn("Message authentication failed (HMAC does not match).");
|
|
1639
1560
|
void healSession();
|
|
1640
1561
|
return;
|
|
1641
1562
|
}
|
|
1642
1563
|
const decrypted = xSecretboxOpen(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), session.SK);
|
|
1643
1564
|
if (decrypted) {
|
|
1644
|
-
this.log.info("Decryption successful.");
|
|
1645
|
-
// emit the message
|
|
1646
1565
|
const fwdMsg2 = mail.forward
|
|
1647
1566
|
? messageSchema.parse(msgpack.decode(decrypted))
|
|
1648
1567
|
: null;
|
|
@@ -1671,7 +1590,6 @@ export class Client {
|
|
|
1671
1590
|
void this.database.markSessionUsed(session.sessionID);
|
|
1672
1591
|
}
|
|
1673
1592
|
else {
|
|
1674
|
-
this.log.info("Decryption failed.");
|
|
1675
1593
|
void healSession();
|
|
1676
1594
|
// emit the message
|
|
1677
1595
|
const message = {
|
|
@@ -1694,7 +1612,6 @@ export class Client {
|
|
|
1694
1612
|
}
|
|
1695
1613
|
break;
|
|
1696
1614
|
default:
|
|
1697
|
-
this.log.warn("Unsupported MailType:", mail.mailType);
|
|
1698
1615
|
break;
|
|
1699
1616
|
}
|
|
1700
1617
|
}
|
|
@@ -1809,12 +1726,9 @@ export class Client {
|
|
|
1809
1726
|
device = decodeAxios(DeviceCodec, res.data);
|
|
1810
1727
|
}
|
|
1811
1728
|
catch (err) {
|
|
1812
|
-
this.log.error(err instanceof Error ? err.message : String(err));
|
|
1813
1729
|
if (isAxiosError(err) && err.response?.status === 404) {
|
|
1814
|
-
// just in case
|
|
1815
1730
|
await this.database.purgeKeyData();
|
|
1816
1731
|
await this.populateKeyRing();
|
|
1817
|
-
this.log.info("Attempting to register device.");
|
|
1818
1732
|
const newDevice = await this.registerDevice();
|
|
1819
1733
|
if (newDevice) {
|
|
1820
1734
|
device = newDevice;
|
|
@@ -1827,20 +1741,23 @@ export class Client {
|
|
|
1827
1741
|
throw err;
|
|
1828
1742
|
}
|
|
1829
1743
|
}
|
|
1830
|
-
this.log.info("Got device " + JSON.stringify(device, null, 4));
|
|
1831
1744
|
return device;
|
|
1832
1745
|
}
|
|
1833
1746
|
/* header is 32 bytes and is either empty
|
|
1834
1747
|
or contains an HMAC of the message with
|
|
1835
1748
|
a derived SK */
|
|
1836
1749
|
async send(msg, header) {
|
|
1837
|
-
|
|
1750
|
+
const maxWaitMs = 30_000;
|
|
1751
|
+
let elapsed = 0;
|
|
1752
|
+
let backoff = 50;
|
|
1838
1753
|
while (this.socket.readyState !== 1) {
|
|
1839
|
-
|
|
1840
|
-
|
|
1754
|
+
if (elapsed >= maxWaitMs) {
|
|
1755
|
+
throw new Error("WebSocket did not reach OPEN state within 30 seconds.");
|
|
1756
|
+
}
|
|
1757
|
+
await sleep(backoff);
|
|
1758
|
+
elapsed += backoff;
|
|
1759
|
+
backoff = Math.min(backoff * 2, 4_000);
|
|
1841
1760
|
}
|
|
1842
|
-
this.log.debug("OUTH " + XUtils.encodeHex(header || XUtils.emptyHeader()));
|
|
1843
|
-
this.log.debug("OUT " + JSON.stringify(msg, null, 4));
|
|
1844
1761
|
this.socket.send(XUtils.packMessage(msg, header));
|
|
1845
1762
|
}
|
|
1846
1763
|
async sendGroupMessage(channelID, message) {
|
|
@@ -1848,19 +1765,13 @@ export class Client {
|
|
|
1848
1765
|
for (const user of userList) {
|
|
1849
1766
|
this.userRecords[user.userID] = user;
|
|
1850
1767
|
}
|
|
1851
|
-
this.log.info("Sending to userlist:\n" + JSON.stringify(userList, null, 4));
|
|
1852
1768
|
const mailID = uuid.v4();
|
|
1853
1769
|
const promises = [];
|
|
1854
1770
|
const userIDs = [...new Set(userList.map((user) => user.userID))];
|
|
1855
1771
|
const devices = await this.getMultiUserDeviceList(userIDs);
|
|
1856
|
-
this.log.info("Retrieved devicelist:\n" + JSON.stringify(devices, null, 4));
|
|
1857
1772
|
for (const device of devices) {
|
|
1858
1773
|
const ownerRecord = this.userRecords[device.owner];
|
|
1859
1774
|
if (!ownerRecord) {
|
|
1860
|
-
this.log.warn("Skipping device " +
|
|
1861
|
-
device.deviceID +
|
|
1862
|
-
": no user record for owner " +
|
|
1863
|
-
device.owner);
|
|
1864
1775
|
continue;
|
|
1865
1776
|
}
|
|
1866
1777
|
promises.push(this.sendMail(device, ownerRecord, XUtils.decodeUTF8(message), uuidToUint8(channelID), mailID, false));
|
|
@@ -1869,8 +1780,6 @@ export class Client {
|
|
|
1869
1780
|
for (const result of results) {
|
|
1870
1781
|
const { status } = result;
|
|
1871
1782
|
if (status === "rejected") {
|
|
1872
|
-
this.log.warn("Message failed.");
|
|
1873
|
-
this.log.warn(JSON.stringify(result));
|
|
1874
1783
|
}
|
|
1875
1784
|
}
|
|
1876
1785
|
});
|
|
@@ -1878,24 +1787,14 @@ export class Client {
|
|
|
1878
1787
|
/* Sends encrypted mail to a user. */
|
|
1879
1788
|
async sendMail(device, user, msg, group, mailID, forward, retry = false) {
|
|
1880
1789
|
while (this.sending.has(device.deviceID)) {
|
|
1881
|
-
this.log.warn("Sending in progress to device ID " +
|
|
1882
|
-
device.deviceID +
|
|
1883
|
-
", waiting.");
|
|
1884
1790
|
await sleep(100);
|
|
1885
1791
|
}
|
|
1886
|
-
this.log.info("Sending mail to user: \n" + JSON.stringify(user, null, 4));
|
|
1887
|
-
this.log.info("Sending mail to device:\n " +
|
|
1888
|
-
JSON.stringify(device.deviceID, null, 4));
|
|
1889
1792
|
this.sending.set(device.deviceID, device);
|
|
1890
1793
|
const session = await this.database.getSessionByDeviceID(device.deviceID);
|
|
1891
1794
|
if (!session || retry) {
|
|
1892
|
-
this.log.info("Creating new session for " + device.deviceID);
|
|
1893
1795
|
await this.createSession(device, user, msg, group, mailID, forward);
|
|
1894
1796
|
return;
|
|
1895
1797
|
}
|
|
1896
|
-
else {
|
|
1897
|
-
this.log.info("Found existing session for " + device.deviceID);
|
|
1898
|
-
}
|
|
1899
1798
|
const nonce = xMakeNonce();
|
|
1900
1799
|
const cipher = xSecretbox(msg, nonce, session.SK);
|
|
1901
1800
|
const extra = session.publicKey;
|
|
@@ -1920,8 +1819,6 @@ export class Client {
|
|
|
1920
1819
|
type: "resource",
|
|
1921
1820
|
};
|
|
1922
1821
|
const hmac = xHMAC(mail, session.SK);
|
|
1923
|
-
this.log.info("Mail hash: " + JSON.stringify(mail));
|
|
1924
|
-
this.log.info("Calculated hmac: " + XUtils.encodeHex(hmac));
|
|
1925
1822
|
const fwdOut = forward
|
|
1926
1823
|
? messageSchema.parse(msgpack.decode(msg))
|
|
1927
1824
|
: null;
|
|
@@ -1991,15 +1888,11 @@ export class Client {
|
|
|
1991
1888
|
for (const result of results) {
|
|
1992
1889
|
const { status } = result;
|
|
1993
1890
|
if (status === "rejected") {
|
|
1994
|
-
this.log.warn("Message failed.");
|
|
1995
|
-
this.log.warn(JSON.stringify(result));
|
|
1996
1891
|
}
|
|
1997
1892
|
}
|
|
1998
1893
|
});
|
|
1999
1894
|
}
|
|
2000
1895
|
catch (err) {
|
|
2001
|
-
this.log.error("Message threw exception.");
|
|
2002
|
-
this.log.error(err instanceof Error ? err.message : String(err));
|
|
2003
1896
|
throw err;
|
|
2004
1897
|
}
|
|
2005
1898
|
}
|
|
@@ -2019,16 +1912,9 @@ export class Client {
|
|
|
2019
1912
|
}
|
|
2020
1913
|
async submitOTK(amount) {
|
|
2021
1914
|
const otks = [];
|
|
2022
|
-
const t0 = performance.now();
|
|
2023
1915
|
for (let i = 0; i < amount; i++) {
|
|
2024
1916
|
otks[i] = this.createPreKey();
|
|
2025
1917
|
}
|
|
2026
|
-
const t1 = performance.now();
|
|
2027
|
-
this.log.info("Generated " +
|
|
2028
|
-
String(amount) +
|
|
2029
|
-
" one time keys in " +
|
|
2030
|
-
String(t1 - t0) +
|
|
2031
|
-
" ms.");
|
|
2032
1918
|
const savedKeys = await this.database.savePreKeys(otks, true);
|
|
2033
1919
|
await this.http.post(this.getHost() + "/device/" + this.getDevice().deviceID + "/otk", msgpack.encode(savedKeys.map((key) => this.censorPreKey(key))), {
|
|
2034
1920
|
headers: { "Content-Type": "application/msgpack" },
|