@vex-chat/libvex 2.0.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 +3 -2
- package/dist/Client.d.ts +83 -59
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +143 -272
- 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/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 +206 -424
- 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 +0 -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/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,36 @@ 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 || typeof g.value !== "object" || g.value === null) {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
const env = Object.getOwnPropertyDescriptor(g.value, "env")?.value;
|
|
455
|
+
if (typeof env !== "object" || env === null) {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
const val = Object.getOwnPropertyDescriptor(env, "NODE_ENV")?.value;
|
|
459
|
+
return typeof val === "string" ? val : undefined;
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Closes the client — disconnects the WebSocket, shuts down storage,
|
|
467
|
+
* and emits `closed` unless `muteEvent` is `true`.
|
|
468
|
+
*
|
|
469
|
+
* @param muteEvent - When `true`, suppresses the `closed` event.
|
|
470
|
+
*/
|
|
471
471
|
async close(muteEvent = false) {
|
|
472
472
|
this.manuallyClosing = true;
|
|
473
|
-
this.log.info("Manually closing client.");
|
|
474
473
|
this.socket.close();
|
|
475
474
|
await this.database.close();
|
|
476
475
|
if (this.pingInterval) {
|
|
@@ -503,16 +502,12 @@ export class Client {
|
|
|
503
502
|
const res = await this.http.post(this.getHost() + "/device/" + this.device.deviceID + "/connect", msgpack.encode({ signed }), { headers: { "Content-Type": "application/msgpack" } });
|
|
504
503
|
const { deviceToken } = decodeAxios(ConnectResponseCodec, res.data);
|
|
505
504
|
this.http.defaults.headers.common["X-Device-Token"] = deviceToken;
|
|
506
|
-
this.log.info("Starting websocket.");
|
|
507
505
|
this.initSocket();
|
|
508
506
|
// Yield the event loop so the WS open callback fires and sends the
|
|
509
507
|
// auth message before OTK generation blocks for ~5s on mobile.
|
|
510
508
|
await new Promise((r) => setTimeout(r, 0));
|
|
511
509
|
await this.negotiateOTK();
|
|
512
510
|
}
|
|
513
|
-
/**
|
|
514
|
-
* Manually closes the client. Emits the closed event on successful shutdown.
|
|
515
|
-
*/
|
|
516
511
|
/**
|
|
517
512
|
* Delete all local data — message history, encryption sessions, and prekeys.
|
|
518
513
|
* Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
|
|
@@ -545,8 +540,8 @@ export class Client {
|
|
|
545
540
|
/**
|
|
546
541
|
* Authenticates with username/password and stores the Bearer auth token.
|
|
547
542
|
*
|
|
548
|
-
* @param username Account username.
|
|
549
|
-
* @param password Account password.
|
|
543
|
+
* @param username - Account username.
|
|
544
|
+
* @param password - Account password.
|
|
550
545
|
* @returns `{ ok: true }` on success, `{ ok: false, error }` on failure.
|
|
551
546
|
*
|
|
552
547
|
* @example
|
|
@@ -571,7 +566,6 @@ export class Client {
|
|
|
571
566
|
}
|
|
572
567
|
catch (err) {
|
|
573
568
|
const error = err instanceof Error ? err.message : String(err);
|
|
574
|
-
this.log.error("Login failed: " + error);
|
|
575
569
|
return { error, ok: false };
|
|
576
570
|
}
|
|
577
571
|
}
|
|
@@ -604,7 +598,6 @@ export class Client {
|
|
|
604
598
|
}
|
|
605
599
|
catch (err) {
|
|
606
600
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
607
|
-
this.log.error("Device-key auth failed: " + error.message);
|
|
608
601
|
return error;
|
|
609
602
|
}
|
|
610
603
|
return null;
|
|
@@ -615,17 +608,20 @@ export class Client {
|
|
|
615
608
|
async logout() {
|
|
616
609
|
await this.http.post(this.getHost() + "/goodbye");
|
|
617
610
|
}
|
|
611
|
+
/** Removes an event listener. See {@link ClientEvents} for available events. */
|
|
618
612
|
off(event, fn, context) {
|
|
619
613
|
this.emitter.off(event,
|
|
620
614
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ee3 requires generic listener type; E constraint guarantees safety
|
|
621
615
|
fn, context);
|
|
622
616
|
return this;
|
|
623
617
|
}
|
|
618
|
+
/** Subscribes to an event. See {@link ClientEvents} for available events. */
|
|
624
619
|
on(event, fn, context) {
|
|
625
620
|
// 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
621
|
this.emitter.on(event, fn, context);
|
|
627
622
|
return this;
|
|
628
623
|
}
|
|
624
|
+
/** Subscribes to an event for a single firing, then auto-removes. */
|
|
629
625
|
once(event, fn, context) {
|
|
630
626
|
// 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
627
|
this.emitter.once(event, fn, context);
|
|
@@ -633,11 +629,15 @@ export class Client {
|
|
|
633
629
|
}
|
|
634
630
|
/**
|
|
635
631
|
* Registers a new account on the server.
|
|
636
|
-
* @param username The username to register. Must be unique.
|
|
637
632
|
*
|
|
638
|
-
* @
|
|
633
|
+
* @param username - The username to register. Must be unique.
|
|
634
|
+
* @param password - Account password.
|
|
635
|
+
* @returns `[user, null]` on success, `[null, error]` on failure.
|
|
639
636
|
*
|
|
640
|
-
* @example
|
|
637
|
+
* @example
|
|
638
|
+
* ```ts
|
|
639
|
+
* const [user, err] = await client.register("MyUsername", "hunter2");
|
|
640
|
+
* ```
|
|
641
641
|
*/
|
|
642
642
|
async register(username, password) {
|
|
643
643
|
while (!this.xKeyRing) {
|
|
@@ -665,7 +665,11 @@ export class Client {
|
|
|
665
665
|
}
|
|
666
666
|
catch (err) {
|
|
667
667
|
if (isAxiosError(err) && err.response) {
|
|
668
|
-
|
|
668
|
+
const raw = err.response.data;
|
|
669
|
+
const msg = raw instanceof ArrayBuffer || raw instanceof Uint8Array
|
|
670
|
+
? new TextDecoder().decode(raw)
|
|
671
|
+
: String(raw);
|
|
672
|
+
return [null, new Error(msg)];
|
|
669
673
|
}
|
|
670
674
|
return [
|
|
671
675
|
null,
|
|
@@ -724,11 +728,9 @@ export class Client {
|
|
|
724
728
|
}
|
|
725
729
|
// returns the file details and the encryption key
|
|
726
730
|
async createFile(file) {
|
|
727
|
-
this.log.info("Creating file, size: " + formatBytes(file.byteLength));
|
|
728
731
|
const nonce = xMakeNonce();
|
|
729
732
|
const key = xBoxKeyPair();
|
|
730
733
|
const box = xSecretbox(Uint8Array.from(file), nonce, key.secretKey);
|
|
731
|
-
this.log.info("Encrypted size: " + formatBytes(box.byteLength));
|
|
732
734
|
if (typeof FormData !== "undefined") {
|
|
733
735
|
const fpayload = new FormData();
|
|
734
736
|
fpayload.set("owner", this.getDevice().deviceID);
|
|
@@ -767,7 +769,7 @@ export class Client {
|
|
|
767
769
|
duration,
|
|
768
770
|
serverID,
|
|
769
771
|
};
|
|
770
|
-
const res = await this.http.post(this.getHost() + "/server/" + serverID + "/invites", payload);
|
|
772
|
+
const res = await this.http.post(this.getHost() + "/server/" + serverID + "/invites", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
|
|
771
773
|
return decodeAxios(InviteCodec, res.data);
|
|
772
774
|
}
|
|
773
775
|
createPreKey() {
|
|
@@ -786,20 +788,12 @@ export class Client {
|
|
|
786
788
|
part of a group message */
|
|
787
789
|
mailID, forward) {
|
|
788
790
|
let keyBundle;
|
|
789
|
-
this.log.info("Requesting key bundle for device: " +
|
|
790
|
-
JSON.stringify(device, null, 4));
|
|
791
791
|
try {
|
|
792
792
|
keyBundle = await this.retrieveKeyBundle(device.deviceID);
|
|
793
793
|
}
|
|
794
|
-
catch
|
|
795
|
-
this.log.warn("Couldn't get key bundle:", err instanceof Error ? err.message : String(err));
|
|
794
|
+
catch {
|
|
796
795
|
return;
|
|
797
796
|
}
|
|
798
|
-
this.log.warn(this.toString() +
|
|
799
|
-
" retrieved keybundle #" +
|
|
800
|
-
String(keyBundle.otk?.index ?? "none") +
|
|
801
|
-
" for " +
|
|
802
|
-
device.deviceID);
|
|
803
797
|
if (!this.xKeyRing) {
|
|
804
798
|
throw new Error("Key ring not initialized.");
|
|
805
799
|
}
|
|
@@ -830,17 +824,10 @@ export class Client {
|
|
|
830
824
|
: XUtils.numberToUint8Arr(0);
|
|
831
825
|
// shared secret key
|
|
832
826
|
const SK = xKDF(IKM);
|
|
833
|
-
this.log.info("Obtained SK, " + XUtils.encodeHex(SK));
|
|
834
827
|
const PK = xBoxKeyPairFromSecret(SK).publicKey;
|
|
835
|
-
this.log.info(this.toString() +
|
|
836
|
-
" Obtained PK for " +
|
|
837
|
-
device.deviceID +
|
|
838
|
-
" " +
|
|
839
|
-
XUtils.encodeHex(PK));
|
|
840
828
|
const AD = xConcat(xEncode(xConstants.CURVE, IK_AP), xEncode(xConstants.CURVE, IK_B));
|
|
841
829
|
const nonce = xMakeNonce();
|
|
842
830
|
const cipher = xSecretbox(message, nonce, SK);
|
|
843
|
-
this.log.info("Encrypted ciphertext.");
|
|
844
831
|
/* 32 bytes for signkey, 32 bytes for ephemeral key,
|
|
845
832
|
68 bytes for AD, 6 bytes for otk index (empty for no otk) */
|
|
846
833
|
const extra = xConcat(this.signKeys.publicKey, this.xKeyRing.ephemeralKeys.publicKey, PK, AD, IDX);
|
|
@@ -858,8 +845,6 @@ export class Client {
|
|
|
858
845
|
sender: this.getDevice().deviceID,
|
|
859
846
|
};
|
|
860
847
|
const hmac = xHMAC(mail, SK);
|
|
861
|
-
this.log.info("Mail hash: " + JSON.stringify(mail));
|
|
862
|
-
this.log.info("Generated hmac: " + XUtils.encodeHex(hmac));
|
|
863
848
|
const msg = {
|
|
864
849
|
action: "CREATE",
|
|
865
850
|
data: mail,
|
|
@@ -869,8 +854,6 @@ export class Client {
|
|
|
869
854
|
};
|
|
870
855
|
// discard the ephemeral keys
|
|
871
856
|
this.newEphemeralKeys();
|
|
872
|
-
// save the encryption session
|
|
873
|
-
this.log.info("Saving new session.");
|
|
874
857
|
const sessionEntry = {
|
|
875
858
|
deviceID: device.deviceID,
|
|
876
859
|
fingerprint: XUtils.encodeHex(AD),
|
|
@@ -923,7 +906,6 @@ export class Client {
|
|
|
923
906
|
};
|
|
924
907
|
this.socket.on("message", callback);
|
|
925
908
|
void this.send(msg, hmac);
|
|
926
|
-
this.log.info("Mail sent.");
|
|
927
909
|
});
|
|
928
910
|
this.sending.delete(device.deviceID);
|
|
929
911
|
}
|
|
@@ -953,7 +935,7 @@ export class Client {
|
|
|
953
935
|
/**
|
|
954
936
|
* Gets a list of permissions for a server.
|
|
955
937
|
*
|
|
956
|
-
* @returns
|
|
938
|
+
* @returns The list of Permission objects.
|
|
957
939
|
*/
|
|
958
940
|
async fetchPermissionList(serverID) {
|
|
959
941
|
const res = await this.http.get(this.prefixes.HTTP +
|
|
@@ -1004,8 +986,6 @@ export class Client {
|
|
|
1004
986
|
}
|
|
1005
987
|
const msgBytes = Uint8Array.from(msgpack.encode(copy));
|
|
1006
988
|
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
989
|
if (!devices) {
|
|
1010
990
|
throw new Error("Couldn't get own devices.");
|
|
1011
991
|
}
|
|
@@ -1019,8 +999,6 @@ export class Client {
|
|
|
1019
999
|
for (const result of results) {
|
|
1020
1000
|
const { status } = result;
|
|
1021
1001
|
if (status === "rejected") {
|
|
1022
|
-
this.log.warn("Message failed.");
|
|
1023
|
-
this.log.warn(JSON.stringify(result));
|
|
1024
1002
|
}
|
|
1025
1003
|
}
|
|
1026
1004
|
});
|
|
@@ -1046,18 +1024,15 @@ export class Client {
|
|
|
1046
1024
|
}
|
|
1047
1025
|
async getDeviceByID(deviceID) {
|
|
1048
1026
|
if (deviceID in this.deviceRecords) {
|
|
1049
|
-
this.log.info("Found device in local cache.");
|
|
1050
1027
|
return this.deviceRecords[deviceID] ?? null;
|
|
1051
1028
|
}
|
|
1052
1029
|
const device = await this.database.getDevice(deviceID);
|
|
1053
1030
|
if (device) {
|
|
1054
|
-
this.log.info("Found device in local db.");
|
|
1055
1031
|
this.deviceRecords[deviceID] = device;
|
|
1056
1032
|
return device;
|
|
1057
1033
|
}
|
|
1058
1034
|
try {
|
|
1059
1035
|
const res = await this.http.get(this.getHost() + "/device/" + deviceID);
|
|
1060
|
-
this.log.info("Retrieved device from server.");
|
|
1061
1036
|
const fetchedDevice = decodeAxios(DeviceCodec, res.data);
|
|
1062
1037
|
this.deviceRecords[deviceID] = fetchedDevice;
|
|
1063
1038
|
await this.database.saveDevice(fetchedDevice);
|
|
@@ -1096,7 +1071,6 @@ export class Client {
|
|
|
1096
1071
|
if (firstFetch) {
|
|
1097
1072
|
this.emitter.emit("decryptingMail");
|
|
1098
1073
|
}
|
|
1099
|
-
this.log.info("fetching mail for device " + this.getDevice().deviceID);
|
|
1100
1074
|
try {
|
|
1101
1075
|
const res = await this.http.post(this.getHost() +
|
|
1102
1076
|
"/device/" +
|
|
@@ -1112,13 +1086,13 @@ export class Client {
|
|
|
1112
1086
|
try {
|
|
1113
1087
|
await this.readMail(mailHeader, mailBody, timestamp);
|
|
1114
1088
|
}
|
|
1115
|
-
catch (
|
|
1116
|
-
|
|
1089
|
+
catch (_readMailErr) {
|
|
1090
|
+
// non-fatal — inspect _readMailErr in a debugger
|
|
1117
1091
|
}
|
|
1118
1092
|
}
|
|
1119
1093
|
}
|
|
1120
|
-
catch (
|
|
1121
|
-
|
|
1094
|
+
catch (_fetchErr) {
|
|
1095
|
+
// non-fatal — inspect _fetchErr in a debugger
|
|
1122
1096
|
}
|
|
1123
1097
|
this.fetchingMail = false;
|
|
1124
1098
|
}
|
|
@@ -1149,7 +1123,7 @@ export class Client {
|
|
|
1149
1123
|
/**
|
|
1150
1124
|
* Gets all permissions for the logged in user.
|
|
1151
1125
|
*
|
|
1152
|
-
* @returns
|
|
1126
|
+
* @returns The list of Permission objects.
|
|
1153
1127
|
*/
|
|
1154
1128
|
async getPermissions() {
|
|
1155
1129
|
const res = await this.http.get(this.getHost() + "/user/" + this.getUser().userID + "/permissions");
|
|
@@ -1189,8 +1163,7 @@ export class Client {
|
|
|
1189
1163
|
});
|
|
1190
1164
|
return decodeAxios(ActionTokenCodec, res.data);
|
|
1191
1165
|
}
|
|
1192
|
-
catch
|
|
1193
|
-
this.log.warn(String(err));
|
|
1166
|
+
catch {
|
|
1194
1167
|
return null;
|
|
1195
1168
|
}
|
|
1196
1169
|
}
|
|
@@ -1222,7 +1195,6 @@ export class Client {
|
|
|
1222
1195
|
async handleNotify(msg) {
|
|
1223
1196
|
switch (msg.event) {
|
|
1224
1197
|
case "mail":
|
|
1225
|
-
this.log.info("Server has informed us of new mail.");
|
|
1226
1198
|
await this.getMail();
|
|
1227
1199
|
this.fetchingMail = false;
|
|
1228
1200
|
break;
|
|
@@ -1233,7 +1205,6 @@ export class Client {
|
|
|
1233
1205
|
// msg.data is the messageID for retry
|
|
1234
1206
|
break;
|
|
1235
1207
|
default:
|
|
1236
|
-
this.log.info("Unsupported notification event " + msg.event);
|
|
1237
1208
|
break;
|
|
1238
1209
|
}
|
|
1239
1210
|
}
|
|
@@ -1267,8 +1238,6 @@ export class Client {
|
|
|
1267
1238
|
// Auth sent as first message after open
|
|
1268
1239
|
this.socket = new WebSocketAdapter(wsUrl);
|
|
1269
1240
|
this.socket.on("open", () => {
|
|
1270
|
-
this.log.info("Connection opened.");
|
|
1271
|
-
// Send auth as first message (encoded to bytes — protocol is binary).
|
|
1272
1241
|
const authMsg = JSON.stringify({
|
|
1273
1242
|
token: this.token,
|
|
1274
1243
|
type: "auth",
|
|
@@ -1277,7 +1246,6 @@ export class Client {
|
|
|
1277
1246
|
this.pingInterval = setInterval(this.ping.bind(this), 15000);
|
|
1278
1247
|
});
|
|
1279
1248
|
this.socket.on("close", () => {
|
|
1280
|
-
this.log.info("Connection closed.");
|
|
1281
1249
|
if (this.pingInterval) {
|
|
1282
1250
|
clearInterval(this.pingInterval);
|
|
1283
1251
|
this.pingInterval = null;
|
|
@@ -1286,26 +1254,23 @@ export class Client {
|
|
|
1286
1254
|
this.emitter.emit("disconnect");
|
|
1287
1255
|
}
|
|
1288
1256
|
});
|
|
1289
|
-
this.socket.on("error", (
|
|
1290
|
-
|
|
1257
|
+
this.socket.on("error", (_error) => {
|
|
1258
|
+
if (!this.manuallyClosing) {
|
|
1259
|
+
this.emitter.emit("disconnect");
|
|
1260
|
+
}
|
|
1291
1261
|
});
|
|
1292
1262
|
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));
|
|
1263
|
+
const [_header, raw] = XUtils.unpackMessage(message);
|
|
1296
1264
|
const parseResult = WSMessageSchema.safeParse(raw);
|
|
1297
1265
|
if (!parseResult.success) {
|
|
1298
|
-
this.log.warn("Unknown WS message: " + JSON.stringify(raw));
|
|
1299
1266
|
return;
|
|
1300
1267
|
}
|
|
1301
1268
|
const msg = parseResult.data;
|
|
1302
1269
|
switch (msg.type) {
|
|
1303
1270
|
case "challenge":
|
|
1304
|
-
this.log.info("Received challenge from server.");
|
|
1305
1271
|
this.respond(msg);
|
|
1306
1272
|
break;
|
|
1307
1273
|
case "error":
|
|
1308
|
-
this.log.warn(JSON.stringify(msg));
|
|
1309
1274
|
break;
|
|
1310
1275
|
case "notify":
|
|
1311
1276
|
void this.handleNotify(msg);
|
|
@@ -1321,13 +1286,10 @@ export class Client {
|
|
|
1321
1286
|
case "unauthorized":
|
|
1322
1287
|
throw new Error("Received unauthorized message from server.");
|
|
1323
1288
|
case "authorized":
|
|
1324
|
-
this.log.info("Authenticated with userID " +
|
|
1325
|
-
(this.user?.userID ?? "unknown"));
|
|
1326
1289
|
this.emitter.emit("connected");
|
|
1327
1290
|
void this.postAuth();
|
|
1328
1291
|
break;
|
|
1329
1292
|
default:
|
|
1330
|
-
this.log.info("Unsupported message " + msg.type);
|
|
1331
1293
|
break;
|
|
1332
1294
|
}
|
|
1333
1295
|
});
|
|
@@ -1359,10 +1321,8 @@ export class Client {
|
|
|
1359
1321
|
}
|
|
1360
1322
|
async negotiateOTK() {
|
|
1361
1323
|
const otkCount = await this.getOTKCount();
|
|
1362
|
-
this.log.info("Server reported OTK: " + otkCount.toString());
|
|
1363
1324
|
const needs = xConstants.MIN_OTK_SUPPLY - otkCount;
|
|
1364
1325
|
if (needs === 0) {
|
|
1365
|
-
this.log.info("Server otk supply full.");
|
|
1366
1326
|
return;
|
|
1367
1327
|
}
|
|
1368
1328
|
await this.submitOTK(needs);
|
|
@@ -1375,7 +1335,6 @@ export class Client {
|
|
|
1375
1335
|
}
|
|
1376
1336
|
ping() {
|
|
1377
1337
|
if (!this.isAlive) {
|
|
1378
|
-
this.log.warn("Ping failed.");
|
|
1379
1338
|
}
|
|
1380
1339
|
this.setAlive(false);
|
|
1381
1340
|
void this.send({ transmissionID: uuid.v4(), type: "ping" });
|
|
@@ -1392,7 +1351,6 @@ export class Client {
|
|
|
1392
1351
|
const existingPreKeys = await this.database.getPreKeys();
|
|
1393
1352
|
const preKeys = existingPreKeys ??
|
|
1394
1353
|
(await (async () => {
|
|
1395
|
-
this.log.warn("No prekeys found in database, creating a new one.");
|
|
1396
1354
|
const unsaved = this.createPreKey();
|
|
1397
1355
|
const [saved] = await this.database.savePreKeys([unsaved], false);
|
|
1398
1356
|
if (!saved || saved.index == null)
|
|
@@ -1410,12 +1368,6 @@ export class Client {
|
|
|
1410
1368
|
identityKeys,
|
|
1411
1369
|
preKeys,
|
|
1412
1370
|
};
|
|
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
1371
|
}
|
|
1420
1372
|
async postAuth() {
|
|
1421
1373
|
let count = 0;
|
|
@@ -1429,9 +1381,7 @@ export class Client {
|
|
|
1429
1381
|
count = 0;
|
|
1430
1382
|
}
|
|
1431
1383
|
}
|
|
1432
|
-
catch
|
|
1433
|
-
this.log.warn("Problem fetching mail" + String(err));
|
|
1434
|
-
}
|
|
1384
|
+
catch { }
|
|
1435
1385
|
await sleep(1000 * 60);
|
|
1436
1386
|
}
|
|
1437
1387
|
}
|
|
@@ -1439,6 +1389,10 @@ export class Client {
|
|
|
1439
1389
|
await this.database.purgeHistory();
|
|
1440
1390
|
}
|
|
1441
1391
|
async readMail(header, mail, timestamp) {
|
|
1392
|
+
if (this.seenMailIDs.has(mail.mailID)) {
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
this.seenMailIDs.add(mail.mailID);
|
|
1442
1396
|
this.sendReceipt(new Uint8Array(mail.nonce));
|
|
1443
1397
|
let timeout = 1;
|
|
1444
1398
|
while (this.reading) {
|
|
@@ -1448,17 +1402,14 @@ export class Client {
|
|
|
1448
1402
|
this.reading = true;
|
|
1449
1403
|
try {
|
|
1450
1404
|
const healSession = async () => {
|
|
1451
|
-
this.log.info("Requesting retry of " + mail.mailID);
|
|
1452
1405
|
const deviceEntry = await this.getDeviceByID(mail.sender);
|
|
1453
1406
|
const [user, _err] = await this.fetchUser(mail.authorID);
|
|
1454
1407
|
if (deviceEntry && user) {
|
|
1455
1408
|
void this.createSession(deviceEntry, user, XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`), mail.group, uuid.v4(), false);
|
|
1456
1409
|
}
|
|
1457
1410
|
};
|
|
1458
|
-
this.log.info("Received mail from " + mail.sender);
|
|
1459
1411
|
switch (mail.mailType) {
|
|
1460
1412
|
case MailType.initial:
|
|
1461
|
-
this.log.info("Initiating new session.");
|
|
1462
1413
|
const extraParts = Client.deserializeExtra(MailType.initial, new Uint8Array(mail.extra));
|
|
1463
1414
|
const signKey = extraParts[0];
|
|
1464
1415
|
const ephKey = extraParts[1];
|
|
@@ -1467,34 +1418,15 @@ export class Client {
|
|
|
1467
1418
|
throw new Error("Malformed initial mail extra: missing signKey, ephKey, or indexBytes");
|
|
1468
1419
|
}
|
|
1469
1420
|
const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
|
|
1470
|
-
this.log.info(this.toString() +
|
|
1471
|
-
" otk #" +
|
|
1472
|
-
String(preKeyIndex) +
|
|
1473
|
-
" indicated");
|
|
1474
1421
|
const otk = preKeyIndex === 0
|
|
1475
1422
|
? null
|
|
1476
1423
|
: 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
1424
|
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
1425
|
return;
|
|
1493
1426
|
}
|
|
1494
1427
|
// their public keys
|
|
1495
1428
|
const IK_A_raw = XKeyConvert.convertPublicKey(signKey);
|
|
1496
1429
|
if (!IK_A_raw) {
|
|
1497
|
-
this.log.warn("Could not convert sign key to X25519.");
|
|
1498
1430
|
return;
|
|
1499
1431
|
}
|
|
1500
1432
|
const IK_A = IK_A_raw;
|
|
@@ -1518,31 +1450,15 @@ export class Client {
|
|
|
1518
1450
|
: xConcat(DH1, DH2, DH3);
|
|
1519
1451
|
// shared secret key
|
|
1520
1452
|
const SK = xKDF(IKM);
|
|
1521
|
-
this.log.info("Obtained SK for " +
|
|
1522
|
-
mail.sender +
|
|
1523
|
-
", " +
|
|
1524
|
-
XUtils.encodeHex(SK));
|
|
1525
|
-
// shared public key
|
|
1526
1453
|
const PK = xBoxKeyPairFromSecret(SK).publicKey;
|
|
1527
|
-
this.log.info(this.toString() +
|
|
1528
|
-
"Obtained PK for " +
|
|
1529
|
-
mail.sender +
|
|
1530
|
-
" " +
|
|
1531
|
-
XUtils.encodeHex(PK));
|
|
1532
1454
|
const hmac = xHMAC(mail, SK);
|
|
1533
|
-
this.log.info("Mail hash: " + JSON.stringify(mail));
|
|
1534
|
-
this.log.info("Calculated hmac: " + XUtils.encodeHex(hmac));
|
|
1535
1455
|
// associated data
|
|
1536
1456
|
const AD = xConcat(xEncode(xConstants.CURVE, IK_A), xEncode(xConstants.CURVE, IK_BP));
|
|
1537
1457
|
if (!XUtils.bytesEqual(hmac, header)) {
|
|
1538
|
-
console.warn("Mail authentication failed (HMAC did not match).");
|
|
1539
|
-
console.warn(mail);
|
|
1540
1458
|
return;
|
|
1541
1459
|
}
|
|
1542
|
-
this.log.info("Mail authenticated successfully.");
|
|
1543
1460
|
const unsealed = xSecretboxOpen(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), SK);
|
|
1544
1461
|
if (unsealed) {
|
|
1545
|
-
this.log.info("Decryption successful.");
|
|
1546
1462
|
let plaintext = "";
|
|
1547
1463
|
if (!mail.forward) {
|
|
1548
1464
|
plaintext = XUtils.encodeUTF8(unsealed);
|
|
@@ -1600,11 +1516,9 @@ export class Client {
|
|
|
1600
1516
|
this.emitter.emit("session", newSession, user);
|
|
1601
1517
|
}
|
|
1602
1518
|
else {
|
|
1603
|
-
this.log.warn("Couldn't retrieve user " + newSession.userID);
|
|
1604
1519
|
}
|
|
1605
1520
|
}
|
|
1606
1521
|
else {
|
|
1607
|
-
this.log.warn("Mail decryption failed.");
|
|
1608
1522
|
}
|
|
1609
1523
|
break;
|
|
1610
1524
|
case MailType.subsequent:
|
|
@@ -1615,34 +1529,24 @@ export class Client {
|
|
|
1615
1529
|
let session = await this.getSessionByPubkey(publicKey);
|
|
1616
1530
|
let retries = 0;
|
|
1617
1531
|
while (!session) {
|
|
1618
|
-
if (retries
|
|
1532
|
+
if (retries >= 3) {
|
|
1619
1533
|
break;
|
|
1620
1534
|
}
|
|
1621
|
-
|
|
1535
|
+
await sleep(100 * 2 ** retries);
|
|
1622
1536
|
retries++;
|
|
1623
|
-
|
|
1537
|
+
session = await this.getSessionByPubkey(publicKey);
|
|
1624
1538
|
}
|
|
1625
1539
|
if (!session) {
|
|
1626
|
-
this.log.warn("Couldn't find session public key " +
|
|
1627
|
-
XUtils.encodeHex(publicKey));
|
|
1628
1540
|
void healSession();
|
|
1629
1541
|
return;
|
|
1630
1542
|
}
|
|
1631
|
-
this.log.info("Session found for " + mail.sender);
|
|
1632
|
-
this.log.info("Mail nonce " +
|
|
1633
|
-
XUtils.encodeHex(new Uint8Array(mail.nonce)));
|
|
1634
1543
|
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
1544
|
if (!XUtils.bytesEqual(HMAC, header)) {
|
|
1638
|
-
this.log.warn("Message authentication failed (HMAC does not match).");
|
|
1639
1545
|
void healSession();
|
|
1640
1546
|
return;
|
|
1641
1547
|
}
|
|
1642
1548
|
const decrypted = xSecretboxOpen(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), session.SK);
|
|
1643
1549
|
if (decrypted) {
|
|
1644
|
-
this.log.info("Decryption successful.");
|
|
1645
|
-
// emit the message
|
|
1646
1550
|
const fwdMsg2 = mail.forward
|
|
1647
1551
|
? messageSchema.parse(msgpack.decode(decrypted))
|
|
1648
1552
|
: null;
|
|
@@ -1671,7 +1575,6 @@ export class Client {
|
|
|
1671
1575
|
void this.database.markSessionUsed(session.sessionID);
|
|
1672
1576
|
}
|
|
1673
1577
|
else {
|
|
1674
|
-
this.log.info("Decryption failed.");
|
|
1675
1578
|
void healSession();
|
|
1676
1579
|
// emit the message
|
|
1677
1580
|
const message = {
|
|
@@ -1694,7 +1597,6 @@ export class Client {
|
|
|
1694
1597
|
}
|
|
1695
1598
|
break;
|
|
1696
1599
|
default:
|
|
1697
|
-
this.log.warn("Unsupported MailType:", mail.mailType);
|
|
1698
1600
|
break;
|
|
1699
1601
|
}
|
|
1700
1602
|
}
|
|
@@ -1809,12 +1711,9 @@ export class Client {
|
|
|
1809
1711
|
device = decodeAxios(DeviceCodec, res.data);
|
|
1810
1712
|
}
|
|
1811
1713
|
catch (err) {
|
|
1812
|
-
this.log.error(err instanceof Error ? err.message : String(err));
|
|
1813
1714
|
if (isAxiosError(err) && err.response?.status === 404) {
|
|
1814
|
-
// just in case
|
|
1815
1715
|
await this.database.purgeKeyData();
|
|
1816
1716
|
await this.populateKeyRing();
|
|
1817
|
-
this.log.info("Attempting to register device.");
|
|
1818
1717
|
const newDevice = await this.registerDevice();
|
|
1819
1718
|
if (newDevice) {
|
|
1820
1719
|
device = newDevice;
|
|
@@ -1827,20 +1726,23 @@ export class Client {
|
|
|
1827
1726
|
throw err;
|
|
1828
1727
|
}
|
|
1829
1728
|
}
|
|
1830
|
-
this.log.info("Got device " + JSON.stringify(device, null, 4));
|
|
1831
1729
|
return device;
|
|
1832
1730
|
}
|
|
1833
1731
|
/* header is 32 bytes and is either empty
|
|
1834
1732
|
or contains an HMAC of the message with
|
|
1835
1733
|
a derived SK */
|
|
1836
1734
|
async send(msg, header) {
|
|
1837
|
-
|
|
1735
|
+
const maxWaitMs = 30_000;
|
|
1736
|
+
let elapsed = 0;
|
|
1737
|
+
let backoff = 50;
|
|
1838
1738
|
while (this.socket.readyState !== 1) {
|
|
1839
|
-
|
|
1840
|
-
|
|
1739
|
+
if (elapsed >= maxWaitMs) {
|
|
1740
|
+
throw new Error("WebSocket did not reach OPEN state within 30 seconds.");
|
|
1741
|
+
}
|
|
1742
|
+
await sleep(backoff);
|
|
1743
|
+
elapsed += backoff;
|
|
1744
|
+
backoff = Math.min(backoff * 2, 4_000);
|
|
1841
1745
|
}
|
|
1842
|
-
this.log.debug("OUTH " + XUtils.encodeHex(header || XUtils.emptyHeader()));
|
|
1843
|
-
this.log.debug("OUT " + JSON.stringify(msg, null, 4));
|
|
1844
1746
|
this.socket.send(XUtils.packMessage(msg, header));
|
|
1845
1747
|
}
|
|
1846
1748
|
async sendGroupMessage(channelID, message) {
|
|
@@ -1848,19 +1750,13 @@ export class Client {
|
|
|
1848
1750
|
for (const user of userList) {
|
|
1849
1751
|
this.userRecords[user.userID] = user;
|
|
1850
1752
|
}
|
|
1851
|
-
this.log.info("Sending to userlist:\n" + JSON.stringify(userList, null, 4));
|
|
1852
1753
|
const mailID = uuid.v4();
|
|
1853
1754
|
const promises = [];
|
|
1854
1755
|
const userIDs = [...new Set(userList.map((user) => user.userID))];
|
|
1855
1756
|
const devices = await this.getMultiUserDeviceList(userIDs);
|
|
1856
|
-
this.log.info("Retrieved devicelist:\n" + JSON.stringify(devices, null, 4));
|
|
1857
1757
|
for (const device of devices) {
|
|
1858
1758
|
const ownerRecord = this.userRecords[device.owner];
|
|
1859
1759
|
if (!ownerRecord) {
|
|
1860
|
-
this.log.warn("Skipping device " +
|
|
1861
|
-
device.deviceID +
|
|
1862
|
-
": no user record for owner " +
|
|
1863
|
-
device.owner);
|
|
1864
1760
|
continue;
|
|
1865
1761
|
}
|
|
1866
1762
|
promises.push(this.sendMail(device, ownerRecord, XUtils.decodeUTF8(message), uuidToUint8(channelID), mailID, false));
|
|
@@ -1869,8 +1765,6 @@ export class Client {
|
|
|
1869
1765
|
for (const result of results) {
|
|
1870
1766
|
const { status } = result;
|
|
1871
1767
|
if (status === "rejected") {
|
|
1872
|
-
this.log.warn("Message failed.");
|
|
1873
|
-
this.log.warn(JSON.stringify(result));
|
|
1874
1768
|
}
|
|
1875
1769
|
}
|
|
1876
1770
|
});
|
|
@@ -1878,24 +1772,14 @@ export class Client {
|
|
|
1878
1772
|
/* Sends encrypted mail to a user. */
|
|
1879
1773
|
async sendMail(device, user, msg, group, mailID, forward, retry = false) {
|
|
1880
1774
|
while (this.sending.has(device.deviceID)) {
|
|
1881
|
-
this.log.warn("Sending in progress to device ID " +
|
|
1882
|
-
device.deviceID +
|
|
1883
|
-
", waiting.");
|
|
1884
1775
|
await sleep(100);
|
|
1885
1776
|
}
|
|
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
1777
|
this.sending.set(device.deviceID, device);
|
|
1890
1778
|
const session = await this.database.getSessionByDeviceID(device.deviceID);
|
|
1891
1779
|
if (!session || retry) {
|
|
1892
|
-
this.log.info("Creating new session for " + device.deviceID);
|
|
1893
1780
|
await this.createSession(device, user, msg, group, mailID, forward);
|
|
1894
1781
|
return;
|
|
1895
1782
|
}
|
|
1896
|
-
else {
|
|
1897
|
-
this.log.info("Found existing session for " + device.deviceID);
|
|
1898
|
-
}
|
|
1899
1783
|
const nonce = xMakeNonce();
|
|
1900
1784
|
const cipher = xSecretbox(msg, nonce, session.SK);
|
|
1901
1785
|
const extra = session.publicKey;
|
|
@@ -1920,8 +1804,6 @@ export class Client {
|
|
|
1920
1804
|
type: "resource",
|
|
1921
1805
|
};
|
|
1922
1806
|
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
1807
|
const fwdOut = forward
|
|
1926
1808
|
? messageSchema.parse(msgpack.decode(msg))
|
|
1927
1809
|
: null;
|
|
@@ -1991,15 +1873,11 @@ export class Client {
|
|
|
1991
1873
|
for (const result of results) {
|
|
1992
1874
|
const { status } = result;
|
|
1993
1875
|
if (status === "rejected") {
|
|
1994
|
-
this.log.warn("Message failed.");
|
|
1995
|
-
this.log.warn(JSON.stringify(result));
|
|
1996
1876
|
}
|
|
1997
1877
|
}
|
|
1998
1878
|
});
|
|
1999
1879
|
}
|
|
2000
1880
|
catch (err) {
|
|
2001
|
-
this.log.error("Message threw exception.");
|
|
2002
|
-
this.log.error(err instanceof Error ? err.message : String(err));
|
|
2003
1881
|
throw err;
|
|
2004
1882
|
}
|
|
2005
1883
|
}
|
|
@@ -2019,16 +1897,9 @@ export class Client {
|
|
|
2019
1897
|
}
|
|
2020
1898
|
async submitOTK(amount) {
|
|
2021
1899
|
const otks = [];
|
|
2022
|
-
const t0 = performance.now();
|
|
2023
1900
|
for (let i = 0; i < amount; i++) {
|
|
2024
1901
|
otks[i] = this.createPreKey();
|
|
2025
1902
|
}
|
|
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
1903
|
const savedKeys = await this.database.savePreKeys(otks, true);
|
|
2033
1904
|
await this.http.post(this.getHost() + "/device/" + this.getDevice().deviceID + "/otk", msgpack.encode(savedKeys.map((key) => this.censorPreKey(key))), {
|
|
2034
1905
|
headers: { "Content-Type": "application/msgpack" },
|