@vex-chat/libvex 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -41
- package/dist/Client.d.ts +440 -551
- package/dist/Client.d.ts.map +1 -0
- package/dist/Client.js +1566 -1502
- package/dist/Client.js.map +1 -1
- package/dist/Storage.d.ts +111 -0
- package/dist/Storage.d.ts.map +1 -0
- package/dist/Storage.js +2 -0
- package/dist/Storage.js.map +1 -0
- package/dist/__tests__/harness/memory-storage.d.ts +29 -27
- package/dist/__tests__/harness/memory-storage.d.ts.map +1 -0
- package/dist/__tests__/harness/memory-storage.js +120 -109
- package/dist/__tests__/harness/memory-storage.js.map +1 -1
- package/dist/codec.d.ts +44 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +51 -0
- package/dist/codec.js.map +1 -0
- package/dist/codecs.d.ts +201 -0
- package/dist/codecs.d.ts.map +1 -0
- package/dist/codecs.js +67 -0
- package/dist/codecs.js.map +1 -0
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/keystore/memory.d.ts +5 -4
- package/dist/keystore/memory.d.ts.map +1 -0
- package/dist/keystore/memory.js +9 -7
- package/dist/keystore/memory.js.map +1 -1
- package/dist/keystore/node.d.ts +6 -5
- package/dist/keystore/node.d.ts.map +1 -0
- package/dist/keystore/node.js +38 -19
- package/dist/keystore/node.js.map +1 -1
- package/dist/preset/common.d.ts +9 -0
- package/dist/preset/common.d.ts.map +1 -0
- package/dist/preset/common.js +2 -0
- package/dist/preset/common.js.map +1 -0
- package/dist/preset/node.d.ts +3 -5
- package/dist/preset/node.d.ts.map +1 -0
- package/dist/preset/node.js +5 -8
- package/dist/preset/node.js.map +1 -1
- package/dist/preset/test.d.ts +4 -4
- package/dist/preset/test.d.ts.map +1 -0
- package/dist/preset/test.js +8 -11
- package/dist/preset/test.js.map +1 -1
- package/dist/storage/node.d.ts +4 -3
- package/dist/storage/node.d.ts.map +1 -0
- package/dist/storage/node.js +4 -4
- package/dist/storage/node.js.map +1 -1
- package/dist/storage/schema.d.ts +55 -54
- package/dist/storage/schema.d.ts.map +1 -0
- package/dist/storage/sqlite.d.ts +33 -28
- package/dist/storage/sqlite.d.ts.map +1 -0
- package/dist/storage/sqlite.js +330 -291
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/transport/types.d.ts +23 -16
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/websocket.d.ts +26 -0
- package/dist/transport/websocket.d.ts.map +1 -0
- package/dist/transport/websocket.js +83 -0
- package/dist/transport/websocket.js.map +1 -0
- package/dist/types/crypto.d.ts +35 -0
- package/dist/types/crypto.d.ts.map +1 -0
- package/dist/types/crypto.js +9 -0
- package/dist/types/crypto.js.map +1 -0
- package/dist/types/identity.d.ts +17 -0
- package/dist/types/identity.d.ts.map +1 -0
- package/dist/types/identity.js +6 -0
- package/dist/types/identity.js.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/capitalize.d.ts +1 -0
- package/dist/utils/capitalize.d.ts.map +1 -0
- package/dist/utils/createLogger.d.ts +1 -0
- package/dist/utils/createLogger.d.ts.map +1 -0
- package/dist/utils/createLogger.js +3 -3
- package/dist/utils/createLogger.js.map +1 -1
- package/dist/utils/formatBytes.d.ts +1 -0
- package/dist/utils/formatBytes.d.ts.map +1 -0
- package/dist/utils/formatBytes.js +3 -1
- package/dist/utils/formatBytes.js.map +1 -1
- package/dist/utils/sqlSessionToCrypto.d.ts +4 -2
- package/dist/utils/sqlSessionToCrypto.d.ts.map +1 -0
- package/dist/utils/sqlSessionToCrypto.js +5 -5
- package/dist/utils/sqlSessionToCrypto.js.map +1 -1
- package/dist/utils/uint8uuid.d.ts +1 -4
- package/dist/utils/uint8uuid.d.ts.map +1 -0
- package/dist/utils/uint8uuid.js +1 -7
- package/dist/utils/uint8uuid.js.map +1 -1
- package/package.json +58 -83
- package/src/Client.ts +3304 -0
- package/{dist/IStorage.d.ts → src/Storage.ts} +70 -62
- package/src/__tests__/codec.test.ts +251 -0
- package/src/__tests__/ghost.png +0 -0
- package/src/__tests__/harness/fixtures.ts +22 -0
- package/src/__tests__/harness/memory-storage.ts +254 -0
- package/src/__tests__/harness/platform-transports.ts +17 -0
- package/src/__tests__/harness/poison-node-imports.ts +108 -0
- package/src/__tests__/harness/shared-suite.ts +446 -0
- package/src/__tests__/platform-browser.test.ts +19 -0
- package/src/__tests__/platform-node.test.ts +10 -0
- package/src/__tests__/triggered.png +0 -0
- package/src/codec.ts +68 -0
- package/src/codecs.ts +101 -0
- package/src/index.ts +33 -0
- package/src/keystore/memory.ts +30 -0
- package/src/keystore/node.ts +91 -0
- package/src/preset/common.ts +13 -0
- package/src/preset/node.ts +34 -0
- package/src/preset/test.ts +37 -0
- package/src/storage/node.ts +33 -0
- package/src/storage/schema.ts +94 -0
- package/src/storage/sqlite.ts +676 -0
- package/src/transport/types.ts +29 -0
- package/src/transport/websocket.ts +106 -0
- package/src/types/crypto.ts +39 -0
- package/src/types/identity.ts +18 -0
- package/src/types/index.ts +9 -0
- package/src/utils/capitalize.ts +6 -0
- package/src/utils/createLogger.ts +37 -0
- package/src/utils/formatBytes.ts +15 -0
- package/src/utils/sqlSessionToCrypto.ts +16 -0
- package/src/utils/uint8uuid.ts +7 -0
- package/dist/IStorage.js +0 -2
- package/dist/IStorage.js.map +0 -1
- package/dist/keystore/types.d.ts +0 -4
- package/dist/keystore/types.js +0 -2
- package/dist/keystore/types.js.map +0 -1
- package/dist/preset/expo.d.ts +0 -2
- package/dist/preset/expo.js +0 -39
- package/dist/preset/expo.js.map +0 -1
- package/dist/preset/tauri.d.ts +0 -2
- package/dist/preset/tauri.js +0 -36
- package/dist/preset/tauri.js.map +0 -1
- package/dist/preset/types.d.ts +0 -14
- package/dist/preset/types.js +0 -2
- package/dist/preset/types.js.map +0 -1
- package/dist/storage/expo.d.ts +0 -3
- package/dist/storage/expo.js +0 -18
- package/dist/storage/expo.js.map +0 -1
- package/dist/storage/tauri.d.ts +0 -3
- package/dist/storage/tauri.js +0 -21
- package/dist/storage/tauri.js.map +0 -1
- package/dist/transport/browser.d.ts +0 -17
- package/dist/transport/browser.js +0 -56
- package/dist/transport/browser.js.map +0 -1
- package/dist/utils/constants.d.ts +0 -8
- package/dist/utils/constants.js +0 -9
- package/dist/utils/constants.js.map +0 -1
package/dist/Client.js
CHANGED
|
@@ -1,87 +1,42 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import { MailType } from "@vex-chat/types";
|
|
5
|
-
import axios, { AxiosError } from "axios";
|
|
6
|
-
import pc from "picocolors";
|
|
1
|
+
import { xBoxKeyPair, xBoxKeyPairFromSecret, xConcat, xConstants, xDH, xEncode, xHMAC, xKDF, XKeyConvert, xMakeNonce, xMnemonic, xRandomBytes, xSecretbox, xSecretboxOpen, xSign, xSignKeyPair, xSignKeyPairFromSecret, XUtils, } from "@vex-chat/crypto";
|
|
2
|
+
import { MailType, MailWSSchema, PermissionSchema, WSMessageSchema, } from "@vex-chat/types";
|
|
3
|
+
import axios, { isAxiosError } from "axios";
|
|
7
4
|
import { EventEmitter } from "eventemitter3";
|
|
8
|
-
import { Packr } from "msgpackr";
|
|
9
|
-
// useRecords:false emits standard msgpack (no nonstandard record extension).
|
|
10
|
-
// moreTypes:false keeps the extension set to what every other decoder understands.
|
|
11
|
-
const _packr = new Packr({ useRecords: false, moreTypes: false });
|
|
12
|
-
// Packr.encode() returns a subarray of its internal pool buffer.
|
|
13
|
-
// In browsers, XMLHttpRequest.send() sends the full underlying ArrayBuffer,
|
|
14
|
-
// not just the slice — corrupting the payload (axios issue #4068).
|
|
15
|
-
// Wrap encode to always return a fresh copy.
|
|
16
|
-
const msgpack = {
|
|
17
|
-
encode: (value) => {
|
|
18
|
-
const packed = _packr.encode(value);
|
|
19
|
-
return new Uint8Array(packed.buffer.slice(packed.byteOffset, packed.byteOffset + packed.byteLength));
|
|
20
|
-
},
|
|
21
|
-
decode: _packr.decode.bind(_packr),
|
|
22
|
-
};
|
|
23
|
-
import objectHash from "object-hash";
|
|
24
|
-
import nacl from "tweetnacl";
|
|
25
5
|
import * as uuid from "uuid";
|
|
6
|
+
import { z } from "zod/v4";
|
|
7
|
+
import { WebSocketAdapter } from "./transport/websocket.js";
|
|
8
|
+
function sleep(ms) {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
import { msgpack } from "./codec.js";
|
|
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";
|
|
26
13
|
import { capitalize } from "./utils/capitalize.js";
|
|
27
14
|
import { formatBytes } from "./utils/formatBytes.js";
|
|
28
15
|
import { sqlSessionToCrypto } from "./utils/sqlSessionToCrypto.js";
|
|
29
16
|
import { uuidToUint8 } from "./utils/uint8uuid.js";
|
|
30
|
-
const
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*
|
|
54
|
-
* // The authed event fires when login() successfully completes
|
|
55
|
-
* // and the server indicates you are authorized. You must wait to
|
|
56
|
-
* // perform any operations besides register() and login() until
|
|
57
|
-
* // this occurs.
|
|
58
|
-
* client.on("authed", async () => {
|
|
59
|
-
* const me = await client.users.me();
|
|
60
|
-
*
|
|
61
|
-
* // send a message
|
|
62
|
-
* await client.messages.send(me.userID, "Hello world!");
|
|
63
|
-
* })
|
|
64
|
-
*
|
|
65
|
-
* // Outgoing and incoming messages are emitted here.
|
|
66
|
-
* client.on("message", (message) => {
|
|
67
|
-
* console.log("message:", message);
|
|
68
|
-
* })
|
|
69
|
-
*
|
|
70
|
-
* // you must call init() to initialize the keyring and
|
|
71
|
-
* // start the client.
|
|
72
|
-
* client.init();
|
|
73
|
-
* }
|
|
74
|
-
*
|
|
75
|
-
* main();
|
|
76
|
-
* ```
|
|
77
|
-
*/
|
|
78
|
-
export class Client extends EventEmitter {
|
|
79
|
-
/**
|
|
80
|
-
* Encrypts a secret key with a password.
|
|
81
|
-
*
|
|
82
|
-
* Pass-through utility from `@vex-chat/crypto`.
|
|
83
|
-
*/
|
|
84
|
-
static encryptKeyData = XUtils.encryptKeyData;
|
|
17
|
+
const _protocolMsgRegex = /��\w+:\w+��/g;
|
|
18
|
+
/** Zod schema matching the {@link Message} interface for forwarded-message decode. */
|
|
19
|
+
const messageSchema = z.object({
|
|
20
|
+
authorID: z.string(),
|
|
21
|
+
decrypted: z.boolean(),
|
|
22
|
+
direction: z.enum(["incoming", "outgoing"]),
|
|
23
|
+
forward: z.boolean(),
|
|
24
|
+
group: z.string().nullable(),
|
|
25
|
+
mailID: z.string(),
|
|
26
|
+
message: z.string(),
|
|
27
|
+
nonce: z.string(),
|
|
28
|
+
readerID: z.string(),
|
|
29
|
+
recipient: z.string(),
|
|
30
|
+
sender: z.string(),
|
|
31
|
+
timestamp: z.string(),
|
|
32
|
+
});
|
|
33
|
+
/** Zod schema for a single inbox entry from getMail: [header, mailBody, timestamp]. */
|
|
34
|
+
const mailInboxEntry = z.tuple([
|
|
35
|
+
z.custom((val) => val instanceof Uint8Array),
|
|
36
|
+
MailWSSchema,
|
|
37
|
+
z.string(),
|
|
38
|
+
]);
|
|
39
|
+
export class Client {
|
|
85
40
|
/**
|
|
86
41
|
* Decrypts a secret key from encrypted data produced by encryptKeyData().
|
|
87
42
|
*
|
|
@@ -89,158 +44,68 @@ export class Client extends EventEmitter {
|
|
|
89
44
|
*/
|
|
90
45
|
static decryptKeyData = XUtils.decryptKeyData;
|
|
91
46
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* @param privateKey Optional hex secret key. When omitted, a fresh key is generated.
|
|
95
|
-
* @param options Runtime options.
|
|
96
|
-
* @param storage Optional custom storage backend implementing `IStorage`.
|
|
97
|
-
*
|
|
98
|
-
* @example
|
|
99
|
-
* ```ts
|
|
100
|
-
* const client = await Client.create(privateKey, { host: "api.vex.wtf" });
|
|
101
|
-
* ```
|
|
102
|
-
*/
|
|
103
|
-
static create = async (privateKey, options, storage) => {
|
|
104
|
-
let opts = options;
|
|
105
|
-
if (!opts?.adapters) {
|
|
106
|
-
const { default: WebSocket } = await import("ws");
|
|
107
|
-
const { createLogger: makeLog } = await import("./utils/createLogger.js");
|
|
108
|
-
opts = {
|
|
109
|
-
...opts,
|
|
110
|
-
adapters: {
|
|
111
|
-
logger: makeLog("libvex", opts?.logLevel),
|
|
112
|
-
WebSocket: WebSocket,
|
|
113
|
-
},
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
// Lazily create Node Storage only on the Node path (no adapters).
|
|
117
|
-
// When adapters are provided (browser/RN), the caller must supply storage
|
|
118
|
-
// via PlatformPreset.createStorage() — there is no Node fallback.
|
|
119
|
-
let resolvedStorage = storage;
|
|
120
|
-
if (!resolvedStorage) {
|
|
121
|
-
if (opts?.adapters) {
|
|
122
|
-
throw new Error("No storage provided. When using adapters (browser/RN), pass storage from your PlatformPreset.");
|
|
123
|
-
}
|
|
124
|
-
const { createNodeStorage } = await import("./storage/node.js");
|
|
125
|
-
const dbFileName = opts?.inMemoryDb
|
|
126
|
-
? ":memory:"
|
|
127
|
-
: XUtils.encodeHex(nacl.sign.keyPair.fromSecretKey(XUtils.decodeHex(privateKey || "")).publicKey) + ".sqlite";
|
|
128
|
-
const dbPath = opts?.dbFolder
|
|
129
|
-
? opts.dbFolder + "/" + dbFileName
|
|
130
|
-
: dbFileName;
|
|
131
|
-
resolvedStorage = createNodeStorage(dbPath, privateKey || XUtils.encodeHex(nacl.sign.keyPair().secretKey));
|
|
132
|
-
}
|
|
133
|
-
const client = new Client(privateKey, opts, resolvedStorage);
|
|
134
|
-
await client.init();
|
|
135
|
-
return client;
|
|
136
|
-
};
|
|
137
|
-
/**
|
|
138
|
-
* Generates an ed25519 secret key as a hex string.
|
|
139
|
-
*
|
|
140
|
-
* @returns - A secret key to use for the client. Save it permanently somewhere safe.
|
|
141
|
-
*/
|
|
142
|
-
static generateSecretKey() {
|
|
143
|
-
return XUtils.encodeHex(nacl.sign.keyPair().secretKey);
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* Generates a random username using bip39.
|
|
47
|
+
* Encrypts a secret key with a password.
|
|
147
48
|
*
|
|
148
|
-
*
|
|
49
|
+
* Pass-through utility from `@vex-chat/crypto`.
|
|
149
50
|
*/
|
|
150
|
-
static
|
|
151
|
-
|
|
152
|
-
const mnemonic = xMnemonic(IKM).split(" ");
|
|
153
|
-
const addendum = XUtils.uint8ArrToNumber(nacl.randomBytes(1));
|
|
154
|
-
return (capitalize(mnemonic[0]) +
|
|
155
|
-
capitalize(mnemonic[1]) +
|
|
156
|
-
addendum.toString());
|
|
157
|
-
}
|
|
158
|
-
static getMnemonic(session) {
|
|
159
|
-
return xMnemonic(xKDF(XUtils.decodeHex(session.fingerprint)));
|
|
160
|
-
}
|
|
161
|
-
static deserializeExtra(type, extra) {
|
|
162
|
-
switch (type) {
|
|
163
|
-
case MailType.initial:
|
|
164
|
-
/* 32 bytes for signkey, 32 bytes for ephemeral key,
|
|
165
|
-
68 bytes for AD, 6 bytes for otk index (empty for no otk) */
|
|
166
|
-
const signKey = extra.slice(0, 32);
|
|
167
|
-
const ephKey = extra.slice(32, 64);
|
|
168
|
-
const ad = extra.slice(96, 164);
|
|
169
|
-
const index = extra.slice(164, 170);
|
|
170
|
-
return [signKey, ephKey, ad, index];
|
|
171
|
-
case MailType.subsequent:
|
|
172
|
-
const publicKey = extra;
|
|
173
|
-
return [publicKey];
|
|
174
|
-
default:
|
|
175
|
-
return [];
|
|
176
|
-
}
|
|
177
|
-
}
|
|
51
|
+
static encryptKeyData = XUtils.encryptKeyData;
|
|
52
|
+
static NOT_FOUND_TTL = 30 * 60 * 1000;
|
|
178
53
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* @example
|
|
182
|
-
* ```ts
|
|
183
|
-
* const [user] = await client.users.retrieve("alice");
|
|
184
|
-
* const familiarUsers = await client.users.familiars();
|
|
185
|
-
* ```
|
|
54
|
+
* Channel operations.
|
|
186
55
|
*/
|
|
187
|
-
|
|
56
|
+
channels = {
|
|
188
57
|
/**
|
|
189
|
-
*
|
|
190
|
-
* @param
|
|
58
|
+
* Creates a new channel in a server.
|
|
59
|
+
* @param name: The channel name.
|
|
60
|
+
* @param serverID: The unique serverID to create the channel in.
|
|
191
61
|
*
|
|
192
|
-
* @returns - The
|
|
62
|
+
* @returns - The created Channel object.
|
|
193
63
|
*/
|
|
194
|
-
|
|
64
|
+
create: this.createChannel.bind(this),
|
|
195
65
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* @returns - The list of IUser objects.
|
|
66
|
+
* Deletes a channel.
|
|
67
|
+
* @param channelID: The unique channelID to delete.
|
|
199
68
|
*/
|
|
200
|
-
|
|
201
|
-
};
|
|
202
|
-
/**
|
|
203
|
-
* Emoji operations.
|
|
204
|
-
*
|
|
205
|
-
* @example
|
|
206
|
-
* ```ts
|
|
207
|
-
* const emoji = await client.emoji.create(imageBuffer, "party", serverID);
|
|
208
|
-
* const list = await client.emoji.retrieveList(serverID);
|
|
209
|
-
* ```
|
|
210
|
-
*/
|
|
211
|
-
emoji = {
|
|
212
|
-
create: this.uploadEmoji.bind(this),
|
|
213
|
-
retrieveList: this.retrieveEmojiList.bind(this),
|
|
214
|
-
retrieve: this.retrieveEmojiByID.bind(this),
|
|
215
|
-
};
|
|
216
|
-
/**
|
|
217
|
-
* Helpers for information/actions related to the currently authenticated account.
|
|
218
|
-
*/
|
|
219
|
-
me = {
|
|
69
|
+
delete: this.deleteChannel.bind(this),
|
|
220
70
|
/**
|
|
221
|
-
* Retrieves
|
|
71
|
+
* Retrieves all channels in a server.
|
|
222
72
|
*
|
|
223
|
-
* @returns - The
|
|
73
|
+
* @returns - The list of Channel objects.
|
|
224
74
|
*/
|
|
225
|
-
|
|
75
|
+
retrieve: this.getChannelList.bind(this),
|
|
226
76
|
/**
|
|
227
|
-
* Retrieves
|
|
77
|
+
* Retrieves channel details by its unique channelID.
|
|
228
78
|
*
|
|
229
|
-
* @returns - The
|
|
79
|
+
* @returns - The list of Channel objects.
|
|
230
80
|
*/
|
|
231
|
-
|
|
81
|
+
retrieveByID: this.getChannelByID.bind(this),
|
|
232
82
|
/**
|
|
233
|
-
*
|
|
83
|
+
* Retrieves a channel's userlist.
|
|
84
|
+
* @param channelID: The channelID to retrieve userlist for.
|
|
234
85
|
*/
|
|
235
|
-
|
|
86
|
+
userList: this.getUserList.bind(this),
|
|
236
87
|
};
|
|
237
88
|
/**
|
|
238
89
|
* Device management methods.
|
|
239
90
|
*/
|
|
240
91
|
devices = {
|
|
241
|
-
retrieve: this.getDeviceByID.bind(this),
|
|
242
|
-
register: this.registerDevice.bind(this),
|
|
243
92
|
delete: this.deleteDevice.bind(this),
|
|
93
|
+
register: this.registerDevice.bind(this),
|
|
94
|
+
retrieve: this.getDeviceByID.bind(this),
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Emoji operations.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* const emoji = await client.emoji.create(imageBuffer, "party", serverID);
|
|
102
|
+
* const list = await client.emoji.retrieveList(serverID);
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
emoji = {
|
|
106
|
+
create: this.uploadEmoji.bind(this),
|
|
107
|
+
retrieve: this.retrieveEmojiByID.bind(this),
|
|
108
|
+
retrieveList: this.retrieveEmojiList.bind(this),
|
|
244
109
|
};
|
|
245
110
|
/** File upload/download methods. */
|
|
246
111
|
files = {
|
|
@@ -254,19 +119,14 @@ export class Client extends EventEmitter {
|
|
|
254
119
|
retrieve: this.retrieveFile.bind(this),
|
|
255
120
|
};
|
|
256
121
|
/**
|
|
257
|
-
*
|
|
122
|
+
* This is true if the client has ever been initialized. You can only initialize
|
|
123
|
+
* a client once.
|
|
258
124
|
*/
|
|
259
|
-
|
|
260
|
-
retrieve: this.getPermissions.bind(this),
|
|
261
|
-
delete: this.deletePermission.bind(this),
|
|
262
|
-
};
|
|
125
|
+
hasInit = false;
|
|
263
126
|
/**
|
|
264
|
-
*
|
|
127
|
+
* This is true if the client has ever logged in before. You can only login a client once.
|
|
265
128
|
*/
|
|
266
|
-
|
|
267
|
-
kick: this.kickUser.bind(this),
|
|
268
|
-
fetchPermissionList: this.fetchPermissionList.bind(this),
|
|
269
|
-
};
|
|
129
|
+
hasLoggedIn = false;
|
|
270
130
|
/**
|
|
271
131
|
* Invite-management methods.
|
|
272
132
|
*/
|
|
@@ -275,6 +135,27 @@ export class Client extends EventEmitter {
|
|
|
275
135
|
redeem: this.redeemInvite.bind(this),
|
|
276
136
|
retrieve: this.retrieveInvites.bind(this),
|
|
277
137
|
};
|
|
138
|
+
/**
|
|
139
|
+
* Helpers for information/actions related to the currently authenticated account.
|
|
140
|
+
*/
|
|
141
|
+
me = {
|
|
142
|
+
/**
|
|
143
|
+
* Retrieves current device details
|
|
144
|
+
*
|
|
145
|
+
* @returns - The logged in device's Device object.
|
|
146
|
+
*/
|
|
147
|
+
device: this.getDevice.bind(this),
|
|
148
|
+
/**
|
|
149
|
+
* Changes your avatar.
|
|
150
|
+
*/
|
|
151
|
+
setAvatar: this.uploadAvatar.bind(this),
|
|
152
|
+
/**
|
|
153
|
+
* Retrieves your user information
|
|
154
|
+
*
|
|
155
|
+
* @returns - The logged in user's User object.
|
|
156
|
+
*/
|
|
157
|
+
user: this.getUser.bind(this),
|
|
158
|
+
};
|
|
278
159
|
/**
|
|
279
160
|
* Message operations (direct and group).
|
|
280
161
|
*
|
|
@@ -286,60 +167,50 @@ export class Client extends EventEmitter {
|
|
|
286
167
|
* ```
|
|
287
168
|
*/
|
|
288
169
|
messages = {
|
|
289
|
-
|
|
290
|
-
* Send a direct message.
|
|
291
|
-
* @param userID: The userID of the user to send a message to.
|
|
292
|
-
* @param message: The message to send.
|
|
293
|
-
*/
|
|
294
|
-
send: this.sendMessage.bind(this),
|
|
170
|
+
delete: this.deleteHistory.bind(this),
|
|
295
171
|
/**
|
|
296
172
|
* Send a group message to a channel.
|
|
297
173
|
* @param channelID: The channelID of the channel to send a message to.
|
|
298
174
|
* @param message: The message to send.
|
|
299
175
|
*/
|
|
300
176
|
group: this.sendGroupMessage.bind(this),
|
|
177
|
+
purge: this.purgeHistory.bind(this),
|
|
301
178
|
/**
|
|
302
179
|
* Gets the message history with a specific userID.
|
|
303
180
|
* @param userID: The userID of the user to retrieve message history for.
|
|
304
181
|
*
|
|
305
|
-
* @returns - The list of
|
|
182
|
+
* @returns - The list of Message objects.
|
|
306
183
|
*/
|
|
307
184
|
retrieve: this.getMessageHistory.bind(this),
|
|
308
185
|
/**
|
|
309
186
|
* Gets the group message history with a specific channelID.
|
|
310
187
|
* @param chqnnelID: The channelID of the channel to retrieve message history for.
|
|
311
188
|
*
|
|
312
|
-
* @returns - The list of
|
|
189
|
+
* @returns - The list of Message objects.
|
|
313
190
|
*/
|
|
314
191
|
retrieveGroup: this.getGroupHistory.bind(this),
|
|
315
|
-
|
|
316
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Send a direct message.
|
|
194
|
+
* @param userID: The userID of the user to send a message to.
|
|
195
|
+
* @param message: The message to send.
|
|
196
|
+
*/
|
|
197
|
+
send: this.sendMessage.bind(this),
|
|
317
198
|
};
|
|
318
199
|
/**
|
|
319
|
-
*
|
|
200
|
+
* Server moderation helper methods.
|
|
320
201
|
*/
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
*
|
|
332
|
-
* @returns - The mnemonic representation of the session.
|
|
333
|
-
*/
|
|
334
|
-
verify: Client.getMnemonic,
|
|
335
|
-
/**
|
|
336
|
-
* Marks a mnemonic verified, implying that the the user has confirmed
|
|
337
|
-
* that the session mnemonic matches with the other user.
|
|
338
|
-
* @param sessionID the sessionID of the session to mark.
|
|
339
|
-
* @param status Optionally, what to mark it as. Defaults to true.
|
|
340
|
-
*/
|
|
341
|
-
markVerified: this.markSessionVerified.bind(this),
|
|
202
|
+
moderation = {
|
|
203
|
+
fetchPermissionList: this.fetchPermissionList.bind(this),
|
|
204
|
+
kick: this.kickUser.bind(this),
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* Permission-management methods for the current user.
|
|
208
|
+
*/
|
|
209
|
+
permissions = {
|
|
210
|
+
delete: this.deletePermission.bind(this),
|
|
211
|
+
retrieve: this.getPermissions.bind(this),
|
|
342
212
|
};
|
|
213
|
+
sending = new Map();
|
|
343
214
|
/**
|
|
344
215
|
* Server operations.
|
|
345
216
|
*
|
|
@@ -350,23 +221,11 @@ export class Client extends EventEmitter {
|
|
|
350
221
|
* ```
|
|
351
222
|
*/
|
|
352
223
|
servers = {
|
|
353
|
-
/**
|
|
354
|
-
* Retrieves all servers the logged in user has access to.
|
|
355
|
-
*
|
|
356
|
-
* @returns - The list of IServer objects.
|
|
357
|
-
*/
|
|
358
|
-
retrieve: this.getServerList.bind(this),
|
|
359
|
-
/**
|
|
360
|
-
* Retrieves server details by its unique serverID.
|
|
361
|
-
*
|
|
362
|
-
* @returns - The requested IServer object, or null if the id does not exist.
|
|
363
|
-
*/
|
|
364
|
-
retrieveByID: this.getServerByID.bind(this),
|
|
365
224
|
/**
|
|
366
225
|
* Creates a new server.
|
|
367
226
|
* @param name: The server name.
|
|
368
227
|
*
|
|
369
|
-
* @returns - The created
|
|
228
|
+
* @returns - The created Server object.
|
|
370
229
|
*/
|
|
371
230
|
create: this.createServer.bind(this),
|
|
372
231
|
/**
|
|
@@ -375,94 +234,116 @@ export class Client extends EventEmitter {
|
|
|
375
234
|
*/
|
|
376
235
|
delete: this.deleteServer.bind(this),
|
|
377
236
|
leave: this.leaveServer.bind(this),
|
|
378
|
-
};
|
|
379
|
-
/**
|
|
380
|
-
* Channel operations.
|
|
381
|
-
*/
|
|
382
|
-
channels = {
|
|
383
237
|
/**
|
|
384
|
-
* Retrieves all
|
|
238
|
+
* Retrieves all servers the logged in user has access to.
|
|
385
239
|
*
|
|
386
|
-
* @returns - The list of
|
|
240
|
+
* @returns - The list of Server objects.
|
|
387
241
|
*/
|
|
388
|
-
retrieve: this.
|
|
242
|
+
retrieve: this.getServerList.bind(this),
|
|
389
243
|
/**
|
|
390
|
-
* Retrieves
|
|
244
|
+
* Retrieves server details by its unique serverID.
|
|
391
245
|
*
|
|
392
|
-
* @returns - The
|
|
246
|
+
* @returns - The requested Server object, or null if the id does not exist.
|
|
393
247
|
*/
|
|
394
|
-
retrieveByID: this.
|
|
248
|
+
retrieveByID: this.getServerByID.bind(this),
|
|
249
|
+
};
|
|
250
|
+
/**
|
|
251
|
+
* Encryption-session helpers.
|
|
252
|
+
*/
|
|
253
|
+
sessions = {
|
|
395
254
|
/**
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
* @param
|
|
399
|
-
*
|
|
400
|
-
* @returns - The created IChannel object.
|
|
255
|
+
* Marks a mnemonic verified, implying that the the user has confirmed
|
|
256
|
+
* that the session mnemonic matches with the other user.
|
|
257
|
+
* @param sessionID the sessionID of the session to mark.
|
|
258
|
+
* @param status Optionally, what to mark it as. Defaults to true.
|
|
401
259
|
*/
|
|
402
|
-
|
|
260
|
+
markVerified: this.markSessionVerified.bind(this),
|
|
403
261
|
/**
|
|
404
|
-
*
|
|
405
|
-
*
|
|
262
|
+
* Gets all encryption sessions.
|
|
263
|
+
*
|
|
264
|
+
* @returns - The list of Session encryption sessions.
|
|
406
265
|
*/
|
|
407
|
-
|
|
266
|
+
retrieve: this.getSessionList.bind(this),
|
|
408
267
|
/**
|
|
409
|
-
*
|
|
410
|
-
* @param
|
|
268
|
+
* Returns a mnemonic for the session, to verify with the other user.
|
|
269
|
+
* @param session the Session object to get the mnemonic for.
|
|
270
|
+
*
|
|
271
|
+
* @returns - The mnemonic representation of the session.
|
|
411
272
|
*/
|
|
412
|
-
|
|
273
|
+
verify: (session) => Client.getMnemonic(session),
|
|
413
274
|
};
|
|
414
275
|
/**
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
*
|
|
276
|
+
* User operations.
|
|
277
|
+
*
|
|
278
|
+
* @example
|
|
279
|
+
* ```ts
|
|
280
|
+
* const [user] = await client.users.retrieve("alice");
|
|
281
|
+
* const familiarUsers = await client.users.familiars();
|
|
282
|
+
* ```
|
|
421
283
|
*/
|
|
422
|
-
|
|
423
|
-
|
|
284
|
+
users = {
|
|
285
|
+
/**
|
|
286
|
+
* Retrieves the list of users you can currently access, or are already familiar with.
|
|
287
|
+
*
|
|
288
|
+
* @returns - The list of User objects.
|
|
289
|
+
*/
|
|
290
|
+
familiars: this.getFamiliars.bind(this),
|
|
291
|
+
/**
|
|
292
|
+
* Retrieves a user's information by a string identifier.
|
|
293
|
+
* @param identifier: A userID, hex string public key, or a username.
|
|
294
|
+
*
|
|
295
|
+
* @returns - The user's User object, or null if the user does not exist.
|
|
296
|
+
*/
|
|
297
|
+
retrieve: this.fetchUser.bind(this),
|
|
298
|
+
};
|
|
424
299
|
database;
|
|
425
300
|
dbPath;
|
|
426
|
-
conn;
|
|
427
|
-
host;
|
|
428
|
-
adapters;
|
|
429
|
-
firstMailFetch = true;
|
|
430
|
-
// these are created from one set of sign keys
|
|
431
|
-
signKeys;
|
|
432
|
-
idKeys;
|
|
433
|
-
xKeyRing;
|
|
434
|
-
user;
|
|
435
301
|
device;
|
|
436
|
-
userRecords = {};
|
|
437
302
|
deviceRecords = {};
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
reading = false;
|
|
303
|
+
// ── Event subscription (composition over inheritance) ───────────────
|
|
304
|
+
emitter = new EventEmitter();
|
|
441
305
|
fetchingMail = false;
|
|
442
|
-
|
|
306
|
+
firstMailFetch = true;
|
|
307
|
+
forwarded = new Set();
|
|
308
|
+
host;
|
|
309
|
+
http;
|
|
310
|
+
idKeys;
|
|
311
|
+
isAlive = true;
|
|
443
312
|
log;
|
|
444
|
-
pingInterval = null;
|
|
445
313
|
mailInterval;
|
|
446
314
|
manuallyClosing = false;
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
315
|
+
/* Retrieves the userID with the user identifier.
|
|
316
|
+
user identifier is checked for userID, then signkey,
|
|
317
|
+
and finally falls back to username. */
|
|
318
|
+
/** Negative cache for user lookups that returned 404. TTL = 30 minutes. */
|
|
319
|
+
notFoundUsers = new Map();
|
|
450
320
|
options;
|
|
321
|
+
pingInterval = null;
|
|
322
|
+
prefixes;
|
|
323
|
+
reading = false;
|
|
324
|
+
sessionRecords = {};
|
|
325
|
+
// these are created from one set of sign keys
|
|
326
|
+
signKeys;
|
|
327
|
+
socket;
|
|
328
|
+
token = null;
|
|
329
|
+
user;
|
|
330
|
+
userRecords = {};
|
|
331
|
+
xKeyRing;
|
|
451
332
|
constructor(privateKey, options, storage) {
|
|
452
|
-
super
|
|
333
|
+
// (no super — composition, not inheritance)
|
|
453
334
|
this.options = options;
|
|
454
|
-
this.log = options?.
|
|
335
|
+
this.log = options?.logger ?? {
|
|
336
|
+
debug() { },
|
|
337
|
+
error() { },
|
|
455
338
|
info() { },
|
|
456
339
|
warn() { },
|
|
457
|
-
error() { },
|
|
458
|
-
debug() { },
|
|
459
340
|
};
|
|
460
341
|
this.prefixes = options?.unsafeHttp
|
|
461
342
|
? { HTTP: "http://", WS: "ws://" }
|
|
462
343
|
: { HTTP: "https://", WS: "wss://" };
|
|
463
344
|
this.signKeys = privateKey
|
|
464
|
-
?
|
|
465
|
-
:
|
|
345
|
+
? xSignKeyPairFromSecret(XUtils.decodeHex(privateKey))
|
|
346
|
+
: xSignKeyPair();
|
|
466
347
|
this.idKeys = XKeyConvert.convertKeyPair(this.signKeys);
|
|
467
348
|
if (!this.idKeys) {
|
|
468
349
|
throw new Error("Could not convert key to X25519!");
|
|
@@ -472,7 +353,7 @@ export class Client extends EventEmitter {
|
|
|
472
353
|
? ":memory:"
|
|
473
354
|
: XUtils.encodeHex(this.signKeys.publicKey) + ".sqlite";
|
|
474
355
|
this.dbPath = options?.dbFolder
|
|
475
|
-
? options
|
|
356
|
+
? options.dbFolder + "/" + dbFileName
|
|
476
357
|
: dbFileName;
|
|
477
358
|
if (!storage) {
|
|
478
359
|
throw new Error("No storage provided. Use Client.create() which resolves storage automatically.");
|
|
@@ -480,45 +361,117 @@ export class Client extends EventEmitter {
|
|
|
480
361
|
this.database = storage;
|
|
481
362
|
this.database.on("error", (error) => {
|
|
482
363
|
this.log.error(error.toString());
|
|
483
|
-
this.close(true);
|
|
364
|
+
void this.close(true);
|
|
484
365
|
});
|
|
485
|
-
|
|
486
|
-
throw new Error("No adapters provided. Use Client.create() which resolves adapters automatically.");
|
|
487
|
-
}
|
|
488
|
-
this.adapters = options.adapters;
|
|
489
|
-
this.ax = axios.create({ responseType: "arraybuffer" });
|
|
366
|
+
this.http = axios.create({ responseType: "arraybuffer" });
|
|
490
367
|
// Placeholder connection — replaced by initSocket() during connect()
|
|
491
|
-
this.
|
|
492
|
-
this.
|
|
368
|
+
this.socket = new WebSocketAdapter("ws://localhost:1234");
|
|
369
|
+
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 ?? {};
|
|
493
376
|
this.log.info("Client debug information: " +
|
|
494
377
|
JSON.stringify({
|
|
495
|
-
publicKey: this.getKeys().public,
|
|
496
|
-
host: this.getHost(),
|
|
497
378
|
dbPath: this.dbPath,
|
|
498
379
|
environment: {
|
|
499
380
|
platform: this.options?.deviceName ?? "unknown",
|
|
500
381
|
},
|
|
501
|
-
|
|
382
|
+
host: this.getHost(),
|
|
383
|
+
options: safeOptions,
|
|
384
|
+
publicKey: this.getKeys().public,
|
|
502
385
|
}, null, 4));
|
|
503
386
|
}
|
|
504
387
|
/**
|
|
505
|
-
*
|
|
388
|
+
* Creates and initializes a client in one step.
|
|
389
|
+
*
|
|
390
|
+
* @param privateKey Optional hex secret key. When omitted, a fresh key is generated.
|
|
391
|
+
* @param options Runtime options.
|
|
392
|
+
* @param storage Optional custom storage backend implementing `Storage`.
|
|
506
393
|
*
|
|
507
394
|
* @example
|
|
508
395
|
* ```ts
|
|
509
|
-
*
|
|
396
|
+
* const client = await Client.create(privateKey, { host: "api.vex.wtf" });
|
|
510
397
|
* ```
|
|
511
398
|
*/
|
|
512
|
-
|
|
513
|
-
|
|
399
|
+
static create = async (privateKey, options, storage) => {
|
|
400
|
+
let opts = options;
|
|
401
|
+
if (!opts?.logger) {
|
|
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.
|
|
411
|
+
let resolvedStorage = storage;
|
|
412
|
+
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
|
+
const { createNodeStorage } = await import("./storage/node.js");
|
|
417
|
+
const dbFileName = opts.inMemoryDb
|
|
418
|
+
? ":memory:"
|
|
419
|
+
: XUtils.encodeHex(xSignKeyPairFromSecret(XUtils.decodeHex(privateKey || ""))
|
|
420
|
+
.publicKey) + ".sqlite";
|
|
421
|
+
const dbPath = opts.dbFolder
|
|
422
|
+
? opts.dbFolder + "/" + dbFileName
|
|
423
|
+
: dbFileName;
|
|
424
|
+
resolvedStorage = createNodeStorage(dbPath, privateKey || XUtils.encodeHex(xSignKeyPair().secretKey));
|
|
425
|
+
}
|
|
426
|
+
const client = new Client(privateKey, opts, resolvedStorage);
|
|
427
|
+
await client.init();
|
|
428
|
+
return client;
|
|
429
|
+
};
|
|
430
|
+
/**
|
|
431
|
+
* Generates an ed25519 secret key as a hex string.
|
|
432
|
+
*
|
|
433
|
+
* @returns - A secret key to use for the client. Save it permanently somewhere safe.
|
|
434
|
+
*/
|
|
435
|
+
static generateSecretKey() {
|
|
436
|
+
return XUtils.encodeHex(xSignKeyPair().secretKey);
|
|
514
437
|
}
|
|
515
438
|
/**
|
|
516
|
-
*
|
|
439
|
+
* Generates a random username using bip39.
|
|
440
|
+
*
|
|
441
|
+
* @returns - The username.
|
|
517
442
|
*/
|
|
443
|
+
static randomUsername() {
|
|
444
|
+
const IKM = XUtils.decodeHex(XUtils.encodeHex(xRandomBytes(16)));
|
|
445
|
+
const mnemonic = xMnemonic(IKM).split(" ");
|
|
446
|
+
const addendum = XUtils.uint8ArrToNumber(xRandomBytes(1));
|
|
447
|
+
const word0 = mnemonic[0] ?? "";
|
|
448
|
+
const word1 = mnemonic[1] ?? "";
|
|
449
|
+
return capitalize(word0) + capitalize(word1) + addendum.toString();
|
|
450
|
+
}
|
|
451
|
+
static deserializeExtra(type, extra) {
|
|
452
|
+
switch (type) {
|
|
453
|
+
case MailType.initial:
|
|
454
|
+
/* 32 bytes for signkey, 32 bytes for ephemeral key,
|
|
455
|
+
68 bytes for AD, 6 bytes for otk index (empty for no otk) */
|
|
456
|
+
const signKey = extra.slice(0, 32);
|
|
457
|
+
const ephKey = extra.slice(32, 64);
|
|
458
|
+
const ad = extra.slice(96, 164);
|
|
459
|
+
const index = extra.slice(164, 170);
|
|
460
|
+
return [signKey, ephKey, ad, index];
|
|
461
|
+
case MailType.subsequent:
|
|
462
|
+
const publicKey = extra;
|
|
463
|
+
return [publicKey];
|
|
464
|
+
default:
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
static getMnemonic(session) {
|
|
469
|
+
return xMnemonic(xKDF(XUtils.decodeHex(session.fingerprint)));
|
|
470
|
+
}
|
|
518
471
|
async close(muteEvent = false) {
|
|
519
472
|
this.manuallyClosing = true;
|
|
520
473
|
this.log.info("Manually closing client.");
|
|
521
|
-
this.
|
|
474
|
+
this.socket.close();
|
|
522
475
|
await this.database.close();
|
|
523
476
|
if (this.pingInterval) {
|
|
524
477
|
clearInterval(this.pingInterval);
|
|
@@ -528,17 +481,65 @@ export class Client extends EventEmitter {
|
|
|
528
481
|
}
|
|
529
482
|
delete this.xKeyRing;
|
|
530
483
|
if (!muteEvent) {
|
|
531
|
-
this.emit("closed");
|
|
484
|
+
this.emitter.emit("closed");
|
|
532
485
|
}
|
|
533
486
|
return;
|
|
534
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* Connects your device to the chat. You must have a valid Bearer token.
|
|
490
|
+
* You can check whoami() to see before calling connect().
|
|
491
|
+
*/
|
|
492
|
+
async connect() {
|
|
493
|
+
const { token, user } = await this.whoami();
|
|
494
|
+
this.token = token;
|
|
495
|
+
this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
|
|
496
|
+
this.setUser(user);
|
|
497
|
+
this.device = await this.retrieveOrCreateDevice();
|
|
498
|
+
const connectToken = await this.getToken("connect");
|
|
499
|
+
if (!connectToken) {
|
|
500
|
+
throw new Error("Couldn't get connect token.");
|
|
501
|
+
}
|
|
502
|
+
const signed = xSign(Uint8Array.from(uuid.parse(connectToken.key)), this.signKeys.secretKey);
|
|
503
|
+
const res = await this.http.post(this.getHost() + "/device/" + this.device.deviceID + "/connect", msgpack.encode({ signed }), { headers: { "Content-Type": "application/msgpack" } });
|
|
504
|
+
const { deviceToken } = decodeAxios(ConnectResponseCodec, res.data);
|
|
505
|
+
this.http.defaults.headers.common["X-Device-Token"] = deviceToken;
|
|
506
|
+
this.log.info("Starting websocket.");
|
|
507
|
+
this.initSocket();
|
|
508
|
+
// Yield the event loop so the WS open callback fires and sends the
|
|
509
|
+
// auth message before OTK generation blocks for ~5s on mobile.
|
|
510
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
511
|
+
await this.negotiateOTK();
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Manually closes the client. Emits the closed event on successful shutdown.
|
|
515
|
+
*/
|
|
516
|
+
/**
|
|
517
|
+
* Delete all local data — message history, encryption sessions, and prekeys.
|
|
518
|
+
* Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
|
|
519
|
+
*/
|
|
520
|
+
async deleteAllData() {
|
|
521
|
+
await this.database.purgeHistory();
|
|
522
|
+
await this.database.purgeKeyData();
|
|
523
|
+
await this.close(true);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Returns the current HTTP API origin with protocol.
|
|
527
|
+
*
|
|
528
|
+
* @example
|
|
529
|
+
* ```ts
|
|
530
|
+
* console.log(client.getHost()); // "https://api.vex.wtf"
|
|
531
|
+
* ```
|
|
532
|
+
*/
|
|
533
|
+
getHost() {
|
|
534
|
+
return this.prefixes.HTTP + this.host;
|
|
535
|
+
}
|
|
535
536
|
/**
|
|
536
537
|
* Gets the hex string representations of the public and private keys.
|
|
537
538
|
*/
|
|
538
539
|
getKeys() {
|
|
539
540
|
return {
|
|
540
|
-
public: XUtils.encodeHex(this.signKeys.publicKey),
|
|
541
541
|
private: XUtils.encodeHex(this.signKeys.secretKey),
|
|
542
|
+
public: XUtils.encodeHex(this.signKeys.publicKey),
|
|
542
543
|
};
|
|
543
544
|
}
|
|
544
545
|
/**
|
|
@@ -546,32 +547,33 @@ export class Client extends EventEmitter {
|
|
|
546
547
|
*
|
|
547
548
|
* @param username Account username.
|
|
548
549
|
* @param password Account password.
|
|
549
|
-
* @returns `
|
|
550
|
+
* @returns `{ ok: true }` on success, `{ ok: false, error }` on failure.
|
|
550
551
|
*
|
|
551
552
|
* @example
|
|
552
553
|
* ```ts
|
|
553
|
-
* const
|
|
554
|
-
* if (
|
|
554
|
+
* const result = await client.login("alice", "correct horse battery staple");
|
|
555
|
+
* if (!result.ok) console.error(result.error);
|
|
555
556
|
* ```
|
|
556
557
|
*/
|
|
557
558
|
async login(username, password) {
|
|
558
559
|
try {
|
|
559
|
-
const res = await this.
|
|
560
|
-
username,
|
|
560
|
+
const res = await this.http.post(this.getHost() + "/auth", msgpack.encode({
|
|
561
561
|
password,
|
|
562
|
+
username,
|
|
562
563
|
}), {
|
|
563
564
|
headers: { "Content-Type": "application/msgpack" },
|
|
564
565
|
});
|
|
565
|
-
const {
|
|
566
|
+
const { token, user } = decodeAxios(AuthResponseCodec, res.data);
|
|
566
567
|
this.setUser(user);
|
|
567
568
|
this.token = token;
|
|
568
|
-
this.
|
|
569
|
+
this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
|
|
570
|
+
return { ok: true };
|
|
569
571
|
}
|
|
570
572
|
catch (err) {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
574
|
+
this.log.error("Login failed: " + error);
|
|
575
|
+
return { error, ok: false };
|
|
573
576
|
}
|
|
574
|
-
return null;
|
|
575
577
|
}
|
|
576
578
|
/**
|
|
577
579
|
* Authenticates using the device's Ed25519 signing key.
|
|
@@ -588,73 +590,46 @@ export class Client extends EventEmitter {
|
|
|
588
590
|
return new Error("No deviceID — pass it or connect first.");
|
|
589
591
|
}
|
|
590
592
|
const signKeyHex = XUtils.encodeHex(this.signKeys.publicKey);
|
|
591
|
-
const challengeRes = await this.
|
|
593
|
+
const challengeRes = await this.http.post(this.getHost() + "/auth/device", msgpack.encode({
|
|
592
594
|
deviceID: id,
|
|
593
595
|
signKey: signKeyHex,
|
|
594
596
|
}), { headers: { "Content-Type": "application/msgpack" } });
|
|
595
|
-
const {
|
|
596
|
-
const signed = XUtils.encodeHex(
|
|
597
|
-
const verifyRes = await this.
|
|
598
|
-
const {
|
|
597
|
+
const { challenge, challengeID } = decodeAxios(DeviceChallengeCodec, challengeRes.data);
|
|
598
|
+
const signed = XUtils.encodeHex(xSign(XUtils.decodeHex(challenge), this.signKeys.secretKey));
|
|
599
|
+
const verifyRes = await this.http.post(this.getHost() + "/auth/device/verify", msgpack.encode({ challengeID, signed }), { headers: { "Content-Type": "application/msgpack" } });
|
|
600
|
+
const { token, user } = decodeAxios(AuthResponseCodec, verifyRes.data);
|
|
599
601
|
this.setUser(user);
|
|
600
602
|
this.token = token;
|
|
601
|
-
this.
|
|
603
|
+
this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
|
|
602
604
|
}
|
|
603
605
|
catch (err) {
|
|
604
|
-
|
|
605
|
-
|
|
606
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
607
|
+
this.log.error("Device-key auth failed: " + error.message);
|
|
608
|
+
return error;
|
|
606
609
|
}
|
|
607
610
|
return null;
|
|
608
611
|
}
|
|
609
|
-
/**
|
|
610
|
-
* Returns details about the currently authenticated session.
|
|
611
|
-
*
|
|
612
|
-
* @returns The authenticated user, token expiry, and active token.
|
|
613
|
-
*
|
|
614
|
-
* @example
|
|
615
|
-
* ```ts
|
|
616
|
-
* const auth = await client.whoami();
|
|
617
|
-
* console.log(auth.user.username, new Date(auth.exp));
|
|
618
|
-
* ```
|
|
619
|
-
*/
|
|
620
|
-
async whoami() {
|
|
621
|
-
const res = await this.ax.post(this.getHost() + "/whoami");
|
|
622
|
-
const whoami = msgpack.decode(new Uint8Array(res.data));
|
|
623
|
-
return whoami;
|
|
624
|
-
}
|
|
625
612
|
/**
|
|
626
613
|
* Logs out the current authenticated session from the server.
|
|
627
614
|
*/
|
|
628
615
|
async logout() {
|
|
629
|
-
await this.
|
|
616
|
+
await this.http.post(this.getHost() + "/goodbye");
|
|
630
617
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
throw new Error("Couldn't get connect token.");
|
|
647
|
-
}
|
|
648
|
-
const signed = nacl.sign(Uint8Array.from(uuid.parse(connectToken.key)), this.signKeys.secretKey);
|
|
649
|
-
const res = await this.ax.post(this.getHost() + "/device/" + this.device.deviceID + "/connect", msgpack.encode({ signed }), { headers: { "Content-Type": "application/msgpack" } });
|
|
650
|
-
const { deviceToken } = msgpack.decode(new Uint8Array(res.data));
|
|
651
|
-
this.ax.defaults.headers.common["X-Device-Token"] = deviceToken;
|
|
652
|
-
this.log.info("Starting websocket.");
|
|
653
|
-
this.initSocket();
|
|
654
|
-
// Yield the event loop so the WS open callback fires and sends the
|
|
655
|
-
// auth message before OTK generation blocks for ~5s on mobile.
|
|
656
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
657
|
-
await this.negotiateOTK();
|
|
618
|
+
off(event, fn, context) {
|
|
619
|
+
this.emitter.off(event,
|
|
620
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ee3 requires generic listener type; E constraint guarantees safety
|
|
621
|
+
fn, context);
|
|
622
|
+
return this;
|
|
623
|
+
}
|
|
624
|
+
on(event, fn, context) {
|
|
625
|
+
// 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
|
+
this.emitter.on(event, fn, context);
|
|
627
|
+
return this;
|
|
628
|
+
}
|
|
629
|
+
once(event, fn, context) {
|
|
630
|
+
// 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
|
+
this.emitter.once(event, fn, context);
|
|
632
|
+
return this;
|
|
658
633
|
}
|
|
659
634
|
/**
|
|
660
635
|
* Registers a new account on the server.
|
|
@@ -671,340 +646,95 @@ export class Client extends EventEmitter {
|
|
|
671
646
|
const regKey = await this.getToken("register");
|
|
672
647
|
if (regKey) {
|
|
673
648
|
const signKey = XUtils.encodeHex(this.signKeys.publicKey);
|
|
674
|
-
const signed = XUtils.encodeHex(
|
|
649
|
+
const signed = XUtils.encodeHex(xSign(Uint8Array.from(uuid.parse(regKey.key)), this.signKeys.secretKey));
|
|
650
|
+
const preKeyIndex = this.xKeyRing.preKeys.index;
|
|
675
651
|
const regMsg = {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
signed,
|
|
652
|
+
deviceName: this.options?.deviceName ?? "unknown",
|
|
653
|
+
password,
|
|
679
654
|
preKey: XUtils.encodeHex(this.xKeyRing.preKeys.keyPair.publicKey),
|
|
655
|
+
preKeyIndex,
|
|
680
656
|
preKeySignature: XUtils.encodeHex(this.xKeyRing.preKeys.signature),
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
657
|
+
signed,
|
|
658
|
+
signKey,
|
|
659
|
+
username,
|
|
684
660
|
};
|
|
685
661
|
try {
|
|
686
|
-
const res = await this.
|
|
687
|
-
this.setUser(
|
|
662
|
+
const res = await this.http.post(this.getHost() + "/register", msgpack.encode(regMsg), { headers: { "Content-Type": "application/msgpack" } });
|
|
663
|
+
this.setUser(decodeAxios(UserCodec, res.data));
|
|
688
664
|
return [this.getUser(), null];
|
|
689
665
|
}
|
|
690
666
|
catch (err) {
|
|
691
|
-
if (err.response) {
|
|
692
|
-
return [null, new Error(err.response.data
|
|
693
|
-
}
|
|
694
|
-
else {
|
|
695
|
-
return [null, err];
|
|
667
|
+
if (isAxiosError(err) && err.response) {
|
|
668
|
+
return [null, new Error(String(err.response.data))];
|
|
696
669
|
}
|
|
670
|
+
return [
|
|
671
|
+
null,
|
|
672
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
673
|
+
];
|
|
697
674
|
}
|
|
698
675
|
}
|
|
699
676
|
else {
|
|
700
677
|
return [null, new Error("Couldn't get regkey from server.")];
|
|
701
678
|
}
|
|
702
679
|
}
|
|
680
|
+
removeAllListeners(event) {
|
|
681
|
+
this.emitter.removeAllListeners(event);
|
|
682
|
+
return this;
|
|
683
|
+
}
|
|
703
684
|
/**
|
|
704
685
|
* Returns a compact `<username><deviceID>` debug label.
|
|
705
686
|
*/
|
|
706
687
|
toString() {
|
|
707
|
-
return this.user?.username
|
|
688
|
+
return ((this.user?.username ?? "") +
|
|
689
|
+
"<" +
|
|
690
|
+
(this.device?.deviceID ?? "") +
|
|
691
|
+
">");
|
|
708
692
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
693
|
+
/**
|
|
694
|
+
* Returns details about the currently authenticated session.
|
|
695
|
+
*
|
|
696
|
+
* @returns The authenticated user, token expiry, and active token.
|
|
697
|
+
*
|
|
698
|
+
* @example
|
|
699
|
+
* ```ts
|
|
700
|
+
* const auth = await client.whoami();
|
|
701
|
+
* console.log(auth.user.username, new Date(auth.exp));
|
|
702
|
+
* ```
|
|
703
|
+
*/
|
|
704
|
+
async whoami() {
|
|
705
|
+
const res = await this.http.post(this.getHost() + "/whoami");
|
|
706
|
+
const whoami = decodeAxios(WhoamiCodec, res.data);
|
|
707
|
+
return whoami;
|
|
716
708
|
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
duration,
|
|
721
|
-
};
|
|
722
|
-
const res = await this.ax.post(this.getHost() + "/server/" + serverID + "/invites", payload);
|
|
723
|
-
return msgpack.decode(new Uint8Array(res.data));
|
|
724
|
-
}
|
|
725
|
-
async retrieveEmojiList(serverID) {
|
|
726
|
-
const res = await this.ax.get(this.getHost() + "/server/" + serverID + "/emoji");
|
|
727
|
-
return msgpack.decode(new Uint8Array(res.data));
|
|
728
|
-
}
|
|
729
|
-
async retrieveEmojiByID(emojiID) {
|
|
730
|
-
const res = await this.ax.get(this.getHost() + "/emoji/" + emojiID + "/details");
|
|
731
|
-
if (!res.data) {
|
|
732
|
-
return null;
|
|
733
|
-
}
|
|
734
|
-
return msgpack.decode(new Uint8Array(res.data));
|
|
735
|
-
}
|
|
736
|
-
async leaveServer(serverID) {
|
|
737
|
-
const permissionList = await this.permissions.retrieve();
|
|
738
|
-
for (const permission of permissionList) {
|
|
739
|
-
if (permission.resourceID === serverID) {
|
|
740
|
-
await this.deletePermission(permission.permissionID);
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
async kickUser(userID, serverID) {
|
|
745
|
-
const permissionList = await this.fetchPermissionList(serverID);
|
|
746
|
-
for (const permission of permissionList) {
|
|
747
|
-
if (userID === permission.userID) {
|
|
748
|
-
await this.deletePermission(permission.permissionID);
|
|
749
|
-
return;
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
throw new Error("Couldn't kick user.");
|
|
753
|
-
}
|
|
754
|
-
async uploadEmoji(emoji, name, serverID) {
|
|
755
|
-
if (typeof FormData !== "undefined") {
|
|
756
|
-
const fpayload = new FormData();
|
|
757
|
-
fpayload.set("emoji", new Blob([new Uint8Array(emoji)]));
|
|
758
|
-
fpayload.set("name", name);
|
|
759
|
-
try {
|
|
760
|
-
const res = await this.ax.post(this.getHost() + "/emoji/" + serverID, fpayload, {
|
|
761
|
-
headers: { "Content-Type": "multipart/form-data" },
|
|
762
|
-
onUploadProgress: (progressEvent) => {
|
|
763
|
-
const percentCompleted = Math.round((progressEvent.loaded * 100) /
|
|
764
|
-
(progressEvent.total ?? 1));
|
|
765
|
-
const { loaded, total = 0 } = progressEvent;
|
|
766
|
-
const progress = {
|
|
767
|
-
direction: "upload",
|
|
768
|
-
token: name,
|
|
769
|
-
progress: percentCompleted,
|
|
770
|
-
loaded,
|
|
771
|
-
total,
|
|
772
|
-
};
|
|
773
|
-
this.emit("fileProgress", progress);
|
|
774
|
-
},
|
|
775
|
-
});
|
|
776
|
-
return msgpack.decode(new Uint8Array(res.data));
|
|
777
|
-
}
|
|
778
|
-
catch (err) {
|
|
779
|
-
return null;
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
const payload = {
|
|
783
|
-
file: XUtils.encodeBase64(emoji),
|
|
784
|
-
name,
|
|
785
|
-
};
|
|
786
|
-
try {
|
|
787
|
-
const res = await this.ax.post(this.getHost() + "/emoji/" + serverID + "/json", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
|
|
788
|
-
return msgpack.decode(new Uint8Array(res.data));
|
|
789
|
-
}
|
|
790
|
-
catch (err) {
|
|
791
|
-
return null;
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
async retrieveOrCreateDevice() {
|
|
795
|
-
let device;
|
|
796
|
-
try {
|
|
797
|
-
const res = await this.ax.get(this.prefixes.HTTP +
|
|
798
|
-
this.host +
|
|
799
|
-
"/device/" +
|
|
800
|
-
XUtils.encodeHex(this.signKeys.publicKey));
|
|
801
|
-
device = msgpack.decode(new Uint8Array(res.data));
|
|
802
|
-
}
|
|
803
|
-
catch (err) {
|
|
804
|
-
this.log.error(err.toString());
|
|
805
|
-
if (err.response?.status === 404) {
|
|
806
|
-
// just in case
|
|
807
|
-
await this.database.purgeKeyData();
|
|
808
|
-
await this.populateKeyRing();
|
|
809
|
-
this.log.info("Attempting to register device.");
|
|
810
|
-
const newDevice = await this.registerDevice();
|
|
811
|
-
if (newDevice) {
|
|
812
|
-
device = newDevice;
|
|
813
|
-
}
|
|
814
|
-
else {
|
|
815
|
-
throw new Error("Error registering device.");
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
else {
|
|
819
|
-
throw err;
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
this.log.info("Got device " + JSON.stringify(device, null, 4));
|
|
823
|
-
return device;
|
|
824
|
-
}
|
|
825
|
-
async registerDevice() {
|
|
826
|
-
while (!this.xKeyRing) {
|
|
827
|
-
await sleep(100);
|
|
828
|
-
}
|
|
829
|
-
const token = await this.getToken("device");
|
|
830
|
-
const [userDetails, err] = await this.retrieveUserDBEntry(this.user.username);
|
|
831
|
-
if (!userDetails) {
|
|
832
|
-
throw new Error("Username not found " + this.user.username);
|
|
833
|
-
}
|
|
834
|
-
if (err) {
|
|
835
|
-
throw err;
|
|
836
|
-
}
|
|
837
|
-
if (!token) {
|
|
838
|
-
throw new Error("Couldn't fetch token.");
|
|
839
|
-
}
|
|
840
|
-
const signKey = this.getKeys().public;
|
|
841
|
-
const signed = XUtils.encodeHex(nacl.sign(Uint8Array.from(uuid.parse(token.key)), this.signKeys.secretKey));
|
|
842
|
-
const devMsg = {
|
|
843
|
-
username: userDetails.username,
|
|
844
|
-
signKey,
|
|
845
|
-
signed,
|
|
846
|
-
preKey: XUtils.encodeHex(this.xKeyRing.preKeys.keyPair.publicKey),
|
|
847
|
-
preKeySignature: XUtils.encodeHex(this.xKeyRing.preKeys.signature),
|
|
848
|
-
preKeyIndex: this.xKeyRing.preKeys.index,
|
|
849
|
-
deviceName: this.options?.deviceName ?? "unknown",
|
|
850
|
-
};
|
|
851
|
-
try {
|
|
852
|
-
const res = await this.ax.post(this.prefixes.HTTP +
|
|
853
|
-
this.host +
|
|
854
|
-
"/user/" +
|
|
855
|
-
userDetails.userID +
|
|
856
|
-
"/devices", msgpack.encode(devMsg), { headers: { "Content-Type": "application/msgpack" } });
|
|
857
|
-
return msgpack.decode(new Uint8Array(res.data));
|
|
858
|
-
}
|
|
859
|
-
catch (err) {
|
|
860
|
-
throw err;
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
async getToken(type) {
|
|
864
|
-
try {
|
|
865
|
-
const res = await this.ax.get(this.getHost() + "/token/" + type, {
|
|
866
|
-
responseType: "arraybuffer",
|
|
867
|
-
});
|
|
868
|
-
return msgpack.decode(new Uint8Array(res.data));
|
|
869
|
-
}
|
|
870
|
-
catch (err) {
|
|
871
|
-
this.log.warn(err.toString());
|
|
872
|
-
return null;
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
async uploadAvatar(avatar) {
|
|
876
|
-
if (typeof FormData !== "undefined") {
|
|
877
|
-
const fpayload = new FormData();
|
|
878
|
-
fpayload.set("avatar", new Blob([new Uint8Array(avatar)]));
|
|
879
|
-
await this.ax.post(this.prefixes.HTTP +
|
|
880
|
-
this.host +
|
|
881
|
-
"/avatar/" +
|
|
882
|
-
this.me.user().userID, fpayload, {
|
|
883
|
-
headers: { "Content-Type": "multipart/form-data" },
|
|
884
|
-
onUploadProgress: (progressEvent) => {
|
|
885
|
-
const percentCompleted = Math.round((progressEvent.loaded * 100) /
|
|
886
|
-
(progressEvent.total ?? 1));
|
|
887
|
-
const { loaded, total = 0 } = progressEvent;
|
|
888
|
-
const progress = {
|
|
889
|
-
direction: "upload",
|
|
890
|
-
token: this.getUser().userID,
|
|
891
|
-
progress: percentCompleted,
|
|
892
|
-
loaded,
|
|
893
|
-
total,
|
|
894
|
-
};
|
|
895
|
-
this.emit("fileProgress", progress);
|
|
896
|
-
},
|
|
897
|
-
});
|
|
898
|
-
return;
|
|
709
|
+
censorPreKey(preKey) {
|
|
710
|
+
if (!preKey.index) {
|
|
711
|
+
throw new Error("Key index is required.");
|
|
899
712
|
}
|
|
900
|
-
|
|
901
|
-
|
|
713
|
+
return {
|
|
714
|
+
deviceID: this.getDevice().deviceID,
|
|
715
|
+
index: preKey.index,
|
|
716
|
+
publicKey: XUtils.decodeHex(preKey.publicKey),
|
|
717
|
+
signature: XUtils.decodeHex(preKey.signature),
|
|
902
718
|
};
|
|
903
|
-
await this.ax.post(this.prefixes.HTTP +
|
|
904
|
-
this.host +
|
|
905
|
-
"/avatar/" +
|
|
906
|
-
this.me.user().userID +
|
|
907
|
-
"/json", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
|
|
908
|
-
}
|
|
909
|
-
/**
|
|
910
|
-
* Gets a list of permissions for a server.
|
|
911
|
-
*
|
|
912
|
-
* @returns - The list of IPermissions objects.
|
|
913
|
-
*/
|
|
914
|
-
async fetchPermissionList(serverID) {
|
|
915
|
-
const res = await this.ax.get(this.prefixes.HTTP +
|
|
916
|
-
this.host +
|
|
917
|
-
"/server/" +
|
|
918
|
-
serverID +
|
|
919
|
-
"/permissions");
|
|
920
|
-
return msgpack.decode(new Uint8Array(res.data));
|
|
921
|
-
}
|
|
922
|
-
/**
|
|
923
|
-
* Gets all permissions for the logged in user.
|
|
924
|
-
*
|
|
925
|
-
* @returns - The list of IPermissions objects.
|
|
926
|
-
*/
|
|
927
|
-
async getPermissions() {
|
|
928
|
-
const res = await this.ax.get(this.getHost() + "/user/" + this.getUser().userID + "/permissions");
|
|
929
|
-
return msgpack.decode(new Uint8Array(res.data));
|
|
930
|
-
}
|
|
931
|
-
async deletePermission(permissionID) {
|
|
932
|
-
await this.ax.delete(this.getHost() + "/permission/" + permissionID);
|
|
933
|
-
}
|
|
934
|
-
async retrieveFile(fileID, key) {
|
|
935
|
-
try {
|
|
936
|
-
const detailsRes = await this.ax.get(this.getHost() + "/file/" + fileID + "/details");
|
|
937
|
-
const details = msgpack.decode(new Uint8Array(detailsRes.data));
|
|
938
|
-
const res = await this.ax.get(this.getHost() + "/file/" + fileID, {
|
|
939
|
-
onDownloadProgress: (progressEvent) => {
|
|
940
|
-
const percentCompleted = Math.round((progressEvent.loaded * 100) /
|
|
941
|
-
(progressEvent.total ?? 1));
|
|
942
|
-
const { loaded, total = 0 } = progressEvent;
|
|
943
|
-
const progress = {
|
|
944
|
-
direction: "download",
|
|
945
|
-
token: fileID,
|
|
946
|
-
progress: percentCompleted,
|
|
947
|
-
loaded,
|
|
948
|
-
total,
|
|
949
|
-
};
|
|
950
|
-
this.emit("fileProgress", progress);
|
|
951
|
-
},
|
|
952
|
-
});
|
|
953
|
-
const fileData = res.data;
|
|
954
|
-
const decrypted = nacl.secretbox.open(new Uint8Array(fileData), XUtils.decodeHex(details.nonce), XUtils.decodeHex(key));
|
|
955
|
-
if (decrypted) {
|
|
956
|
-
const resp = {
|
|
957
|
-
details,
|
|
958
|
-
data: new Uint8Array(decrypted),
|
|
959
|
-
};
|
|
960
|
-
return resp;
|
|
961
|
-
}
|
|
962
|
-
throw new Error("Decryption failed.");
|
|
963
|
-
}
|
|
964
|
-
catch (err) {
|
|
965
|
-
throw err;
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
async deleteServer(serverID) {
|
|
969
|
-
await this.ax.delete(this.getHost() + "/server/" + serverID);
|
|
970
|
-
}
|
|
971
|
-
/**
|
|
972
|
-
* Initializes the keyring. This must be called before anything else.
|
|
973
|
-
*/
|
|
974
|
-
async init() {
|
|
975
|
-
if (this.hasInit) {
|
|
976
|
-
throw new Error("You should only call init() once.");
|
|
977
|
-
}
|
|
978
|
-
this.hasInit = true;
|
|
979
|
-
await this.populateKeyRing();
|
|
980
|
-
this.on("message", async (message) => {
|
|
981
|
-
if (message.direction === "outgoing" && !message.forward) {
|
|
982
|
-
this.forward(message);
|
|
983
|
-
}
|
|
984
|
-
if (message.direction === "incoming" &&
|
|
985
|
-
message.recipient === message.sender) {
|
|
986
|
-
return;
|
|
987
|
-
}
|
|
988
|
-
await this.database.saveMessage(message);
|
|
989
|
-
});
|
|
990
|
-
this.emit("ready");
|
|
991
719
|
}
|
|
992
|
-
async
|
|
993
|
-
|
|
720
|
+
async createChannel(name, serverID) {
|
|
721
|
+
const body = { name };
|
|
722
|
+
const res = await this.http.post(this.getHost() + "/server/" + serverID + "/channels", msgpack.encode(body), { headers: { "Content-Type": "application/msgpack" } });
|
|
723
|
+
return decodeAxios(ChannelCodec, res.data);
|
|
994
724
|
}
|
|
995
725
|
// returns the file details and the encryption key
|
|
996
726
|
async createFile(file) {
|
|
997
727
|
this.log.info("Creating file, size: " + formatBytes(file.byteLength));
|
|
998
728
|
const nonce = xMakeNonce();
|
|
999
|
-
const key =
|
|
1000
|
-
const box =
|
|
729
|
+
const key = xBoxKeyPair();
|
|
730
|
+
const box = xSecretbox(Uint8Array.from(file), nonce, key.secretKey);
|
|
1001
731
|
this.log.info("Encrypted size: " + formatBytes(box.byteLength));
|
|
1002
732
|
if (typeof FormData !== "undefined") {
|
|
1003
733
|
const fpayload = new FormData();
|
|
1004
734
|
fpayload.set("owner", this.getDevice().deviceID);
|
|
1005
735
|
fpayload.set("nonce", XUtils.encodeHex(nonce));
|
|
1006
736
|
fpayload.set("file", new Blob([new Uint8Array(box)]));
|
|
1007
|
-
const fres = await this.
|
|
737
|
+
const fres = await this.http.post(this.getHost() + "/file", fpayload, {
|
|
1008
738
|
headers: { "Content-Type": "multipart/form-data" },
|
|
1009
739
|
onUploadProgress: (progressEvent) => {
|
|
1010
740
|
const percentCompleted = Math.round((progressEvent.loaded * 100) /
|
|
@@ -1012,337 +742,301 @@ export class Client extends EventEmitter {
|
|
|
1012
742
|
const { loaded, total = 0 } = progressEvent;
|
|
1013
743
|
const progress = {
|
|
1014
744
|
direction: "upload",
|
|
1015
|
-
token: XUtils.encodeHex(nonce),
|
|
1016
|
-
progress: percentCompleted,
|
|
1017
745
|
loaded,
|
|
746
|
+
progress: percentCompleted,
|
|
747
|
+
token: XUtils.encodeHex(nonce),
|
|
1018
748
|
total,
|
|
1019
749
|
};
|
|
1020
|
-
this.emit("fileProgress", progress);
|
|
750
|
+
this.emitter.emit("fileProgress", progress);
|
|
1021
751
|
},
|
|
1022
752
|
});
|
|
1023
|
-
const fcreatedFile =
|
|
753
|
+
const fcreatedFile = decodeAxios(FileSQLCodec, fres.data);
|
|
1024
754
|
return [fcreatedFile, XUtils.encodeHex(key.secretKey)];
|
|
1025
755
|
}
|
|
1026
756
|
const payload = {
|
|
1027
|
-
owner: this.getDevice().deviceID,
|
|
1028
|
-
nonce: XUtils.encodeHex(nonce),
|
|
1029
757
|
file: XUtils.encodeBase64(box),
|
|
758
|
+
nonce: XUtils.encodeHex(nonce),
|
|
759
|
+
owner: this.getDevice().deviceID,
|
|
1030
760
|
};
|
|
1031
|
-
const res = await this.
|
|
1032
|
-
const createdFile =
|
|
761
|
+
const res = await this.http.post(this.getHost() + "/file/json", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
|
|
762
|
+
const createdFile = decodeAxios(FileSQLCodec, res.data);
|
|
1033
763
|
return [createdFile, XUtils.encodeHex(key.secretKey)];
|
|
1034
764
|
}
|
|
1035
|
-
async
|
|
1036
|
-
const
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
async getGroupHistory(channelID) {
|
|
1043
|
-
const messages = await this.database.getGroupHistory(channelID);
|
|
1044
|
-
return messages;
|
|
1045
|
-
}
|
|
1046
|
-
async deleteHistory(channelOrUserID, olderThan) {
|
|
1047
|
-
await this.database.deleteHistory(channelOrUserID, olderThan);
|
|
765
|
+
async createInvite(serverID, duration) {
|
|
766
|
+
const payload = {
|
|
767
|
+
duration,
|
|
768
|
+
serverID,
|
|
769
|
+
};
|
|
770
|
+
const res = await this.http.post(this.getHost() + "/server/" + serverID + "/invites", payload);
|
|
771
|
+
return decodeAxios(InviteCodec, res.data);
|
|
1048
772
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
773
|
+
createPreKey() {
|
|
774
|
+
const preKeyPair = xBoxKeyPair();
|
|
775
|
+
return {
|
|
776
|
+
keyPair: preKeyPair,
|
|
777
|
+
signature: xSign(xEncode(xConstants.CURVE, preKeyPair.publicKey), this.signKeys.secretKey),
|
|
778
|
+
};
|
|
1051
779
|
}
|
|
1052
|
-
async
|
|
1053
|
-
const
|
|
1054
|
-
return
|
|
780
|
+
async createServer(name) {
|
|
781
|
+
const res = await this.http.post(this.getHost() + "/server/" + globalThis.btoa(name));
|
|
782
|
+
return decodeAxios(ServerCodec, res.data);
|
|
1055
783
|
}
|
|
1056
|
-
async
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
let deviceList = await this.getUserDeviceList(userID);
|
|
1066
|
-
if (!deviceList) {
|
|
1067
|
-
let retries = 0;
|
|
1068
|
-
while (!deviceList) {
|
|
1069
|
-
deviceList = await this.getUserDeviceList(userID);
|
|
1070
|
-
retries++;
|
|
1071
|
-
if (retries > 3) {
|
|
1072
|
-
throw new Error("Couldn't get device list.");
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
const mailID = uuid.v4();
|
|
1077
|
-
const promises = [];
|
|
1078
|
-
for (const device of deviceList) {
|
|
1079
|
-
promises.push(this.sendMail(device, userEntry, XUtils.decodeUTF8(message), null, mailID, false));
|
|
1080
|
-
}
|
|
1081
|
-
Promise.allSettled(promises).then((results) => {
|
|
1082
|
-
for (const result of results) {
|
|
1083
|
-
const { status } = result;
|
|
1084
|
-
if (status === "rejected") {
|
|
1085
|
-
this.log.warn("Message failed.");
|
|
1086
|
-
this.log.warn(JSON.stringify(result));
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
});
|
|
784
|
+
async createSession(device, user, message, group,
|
|
785
|
+
/* this is passed through if the first message is
|
|
786
|
+
part of a group message */
|
|
787
|
+
mailID, forward) {
|
|
788
|
+
let keyBundle;
|
|
789
|
+
this.log.info("Requesting key bundle for device: " +
|
|
790
|
+
JSON.stringify(device, null, 4));
|
|
791
|
+
try {
|
|
792
|
+
keyBundle = await this.retrieveKeyBundle(device.deviceID);
|
|
1090
793
|
}
|
|
1091
794
|
catch (err) {
|
|
1092
|
-
this.log.
|
|
1093
|
-
this.log.error(err.toString());
|
|
1094
|
-
if (err.message?.mailID) {
|
|
1095
|
-
await this.database.deleteMessage(err.message.mailID);
|
|
1096
|
-
}
|
|
1097
|
-
throw err;
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
async sendGroupMessage(channelID, message) {
|
|
1101
|
-
const userList = await this.getUserList(channelID);
|
|
1102
|
-
for (const user of userList) {
|
|
1103
|
-
this.userRecords[user.userID] = user;
|
|
1104
|
-
}
|
|
1105
|
-
this.log.info("Sending to userlist:\n" + JSON.stringify(userList, null, 4));
|
|
1106
|
-
const mailID = uuid.v4();
|
|
1107
|
-
const promises = [];
|
|
1108
|
-
const userIDs = [...new Set(userList.map((user) => user.userID))];
|
|
1109
|
-
const devices = await this.getMultiUserDeviceList(userIDs);
|
|
1110
|
-
this.log.info("Retrieved devicelist:\n" + JSON.stringify(devices, null, 4));
|
|
1111
|
-
for (const device of devices) {
|
|
1112
|
-
promises.push(this.sendMail(device, this.userRecords[device.owner], XUtils.decodeUTF8(message), uuidToUint8(channelID), mailID, false));
|
|
1113
|
-
}
|
|
1114
|
-
Promise.allSettled(promises).then((results) => {
|
|
1115
|
-
for (const result of results) {
|
|
1116
|
-
const { status } = result;
|
|
1117
|
-
if (status === "rejected") {
|
|
1118
|
-
this.log.warn("Message failed.");
|
|
1119
|
-
this.log.warn(JSON.stringify(result));
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
});
|
|
1123
|
-
}
|
|
1124
|
-
async createServer(name) {
|
|
1125
|
-
const res = await this.ax.post(this.getHost() + "/server/" + globalThis.btoa(name));
|
|
1126
|
-
return msgpack.decode(new Uint8Array(res.data));
|
|
1127
|
-
}
|
|
1128
|
-
async forward(message) {
|
|
1129
|
-
const copy = { ...message };
|
|
1130
|
-
if (this.forwarded.includes(copy.mailID)) {
|
|
1131
|
-
return;
|
|
1132
|
-
}
|
|
1133
|
-
this.forwarded.push(copy.mailID);
|
|
1134
|
-
if (this.forwarded.length > 1000) {
|
|
1135
|
-
this.forwarded.shift();
|
|
1136
|
-
}
|
|
1137
|
-
const msgBytes = Uint8Array.from(msgpack.encode(copy));
|
|
1138
|
-
const devices = await this.getUserDeviceList(this.getUser().userID);
|
|
1139
|
-
this.log.info("Forwarding to my other devices, deviceList length is " +
|
|
1140
|
-
devices?.length);
|
|
1141
|
-
if (!devices) {
|
|
1142
|
-
throw new Error("Couldn't get own devices.");
|
|
1143
|
-
}
|
|
1144
|
-
const promises = [];
|
|
1145
|
-
for (const device of devices) {
|
|
1146
|
-
if (device.deviceID !== this.getDevice().deviceID) {
|
|
1147
|
-
promises.push(this.sendMail(device, this.getUser(), msgBytes, null, copy.mailID, true));
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
Promise.allSettled(promises).then((results) => {
|
|
1151
|
-
for (const result of results) {
|
|
1152
|
-
const { status } = result;
|
|
1153
|
-
if (status === "rejected") {
|
|
1154
|
-
this.log.warn("Message failed.");
|
|
1155
|
-
this.log.warn(JSON.stringify(result));
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
});
|
|
1159
|
-
}
|
|
1160
|
-
/* Sends encrypted mail to a user. */
|
|
1161
|
-
async sendMail(device, user, msg, group, mailID, forward, retry = false) {
|
|
1162
|
-
while (this.sending[device.deviceID] !== undefined) {
|
|
1163
|
-
this.log.warn("Sending in progress to device ID " +
|
|
1164
|
-
device.deviceID +
|
|
1165
|
-
", waiting.");
|
|
1166
|
-
await sleep(100);
|
|
1167
|
-
}
|
|
1168
|
-
this.log.info("Sending mail to user: \n" + JSON.stringify(user, null, 4));
|
|
1169
|
-
this.log.info("Sending mail to device:\n " +
|
|
1170
|
-
JSON.stringify(device.deviceID, null, 4));
|
|
1171
|
-
this.sending[device.deviceID] = device;
|
|
1172
|
-
const session = await this.database.getSessionByDeviceID(device.deviceID);
|
|
1173
|
-
if (!session || retry) {
|
|
1174
|
-
this.log.info("Creating new session for " + device.deviceID);
|
|
1175
|
-
await this.createSession(device, user, msg, group, mailID, forward);
|
|
795
|
+
this.log.warn("Couldn't get key bundle:", err instanceof Error ? err.message : String(err));
|
|
1176
796
|
return;
|
|
1177
797
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
798
|
+
this.log.warn(this.toString() +
|
|
799
|
+
" retrieved keybundle #" +
|
|
800
|
+
String(keyBundle.otk?.index ?? "none") +
|
|
801
|
+
" for " +
|
|
802
|
+
device.deviceID);
|
|
803
|
+
if (!this.xKeyRing) {
|
|
804
|
+
throw new Error("Key ring not initialized.");
|
|
1180
805
|
}
|
|
806
|
+
// my keys
|
|
807
|
+
const IK_A = this.xKeyRing.identityKeys.secretKey;
|
|
808
|
+
const IK_AP = this.xKeyRing.identityKeys.publicKey;
|
|
809
|
+
const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
|
|
810
|
+
// their keys
|
|
811
|
+
const IK_B_raw = XKeyConvert.convertPublicKey(new Uint8Array(keyBundle.signKey));
|
|
812
|
+
if (!IK_B_raw) {
|
|
813
|
+
throw new Error("Could not convert sign key to X25519.");
|
|
814
|
+
}
|
|
815
|
+
const IK_B = IK_B_raw;
|
|
816
|
+
const SPK_B = new Uint8Array(keyBundle.preKey.publicKey);
|
|
817
|
+
const OPK_B = keyBundle.otk
|
|
818
|
+
? new Uint8Array(keyBundle.otk.publicKey)
|
|
819
|
+
: null;
|
|
820
|
+
// diffie hellman functions
|
|
821
|
+
const DH1 = xDH(new Uint8Array(IK_A), SPK_B);
|
|
822
|
+
const DH2 = xDH(new Uint8Array(EK_A), IK_B);
|
|
823
|
+
const DH3 = xDH(new Uint8Array(EK_A), SPK_B);
|
|
824
|
+
const DH4 = OPK_B ? xDH(new Uint8Array(EK_A), OPK_B) : null;
|
|
825
|
+
// initial key material
|
|
826
|
+
const IKM = DH4 ? xConcat(DH1, DH2, DH3, DH4) : xConcat(DH1, DH2, DH3);
|
|
827
|
+
// one time key index
|
|
828
|
+
const IDX = keyBundle.otk
|
|
829
|
+
? XUtils.numberToUint8Arr(keyBundle.otk.index ?? 0)
|
|
830
|
+
: XUtils.numberToUint8Arr(0);
|
|
831
|
+
// shared secret key
|
|
832
|
+
const SK = xKDF(IKM);
|
|
833
|
+
this.log.info("Obtained SK, " + XUtils.encodeHex(SK));
|
|
834
|
+
const PK = xBoxKeyPairFromSecret(SK).publicKey;
|
|
835
|
+
this.log.info(this.toString() +
|
|
836
|
+
" Obtained PK for " +
|
|
837
|
+
device.deviceID +
|
|
838
|
+
" " +
|
|
839
|
+
XUtils.encodeHex(PK));
|
|
840
|
+
const AD = xConcat(xEncode(xConstants.CURVE, IK_AP), xEncode(xConstants.CURVE, IK_B));
|
|
1181
841
|
const nonce = xMakeNonce();
|
|
1182
|
-
const cipher =
|
|
1183
|
-
|
|
842
|
+
const cipher = xSecretbox(message, nonce, SK);
|
|
843
|
+
this.log.info("Encrypted ciphertext.");
|
|
844
|
+
/* 32 bytes for signkey, 32 bytes for ephemeral key,
|
|
845
|
+
68 bytes for AD, 6 bytes for otk index (empty for no otk) */
|
|
846
|
+
const extra = xConcat(this.signKeys.publicKey, this.xKeyRing.ephemeralKeys.publicKey, PK, AD, IDX);
|
|
1184
847
|
const mail = {
|
|
1185
|
-
|
|
1186
|
-
mailID: mailID || uuid.v4(),
|
|
1187
|
-
recipient: device.deviceID,
|
|
848
|
+
authorID: this.getUser().userID,
|
|
1188
849
|
cipher,
|
|
1189
|
-
nonce,
|
|
1190
850
|
extra,
|
|
1191
|
-
sender: this.getDevice().deviceID,
|
|
1192
|
-
group,
|
|
1193
851
|
forward,
|
|
1194
|
-
|
|
1195
|
-
|
|
852
|
+
group,
|
|
853
|
+
mailID: mailID || uuid.v4(),
|
|
854
|
+
mailType: MailType.initial,
|
|
855
|
+
nonce,
|
|
856
|
+
readerID: user.userID,
|
|
857
|
+
recipient: device.deviceID,
|
|
858
|
+
sender: this.getDevice().deviceID,
|
|
1196
859
|
};
|
|
1197
|
-
const
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
860
|
+
const hmac = xHMAC(mail, SK);
|
|
861
|
+
this.log.info("Mail hash: " + JSON.stringify(mail));
|
|
862
|
+
this.log.info("Generated hmac: " + XUtils.encodeHex(hmac));
|
|
863
|
+
const msg = {
|
|
1201
864
|
action: "CREATE",
|
|
1202
865
|
data: mail,
|
|
866
|
+
resourceType: "mail",
|
|
867
|
+
transmissionID: uuid.v4(),
|
|
868
|
+
type: "resource",
|
|
1203
869
|
};
|
|
1204
|
-
|
|
1205
|
-
this.
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
870
|
+
// discard the ephemeral keys
|
|
871
|
+
this.newEphemeralKeys();
|
|
872
|
+
// save the encryption session
|
|
873
|
+
this.log.info("Saving new session.");
|
|
874
|
+
const sessionEntry = {
|
|
875
|
+
deviceID: device.deviceID,
|
|
876
|
+
fingerprint: XUtils.encodeHex(AD),
|
|
877
|
+
lastUsed: new Date().toISOString(),
|
|
878
|
+
mode: "initiator",
|
|
879
|
+
publicKey: XUtils.encodeHex(PK),
|
|
880
|
+
sessionID: uuid.v4(),
|
|
881
|
+
SK: XUtils.encodeHex(SK),
|
|
882
|
+
userID: user.userID,
|
|
883
|
+
verified: false,
|
|
884
|
+
};
|
|
885
|
+
await this.database.saveSession(sessionEntry);
|
|
886
|
+
this.emitter.emit("session", sessionEntry, user);
|
|
887
|
+
// emit the message
|
|
888
|
+
const forwardedMsg = forward
|
|
889
|
+
? messageSchema.parse(msgpack.decode(message))
|
|
890
|
+
: null;
|
|
891
|
+
const emitMsg = forwardedMsg
|
|
892
|
+
? { ...forwardedMsg, forward: true }
|
|
1209
893
|
: {
|
|
1210
|
-
|
|
1211
|
-
sender: mail.sender,
|
|
1212
|
-
recipient: mail.recipient,
|
|
1213
|
-
nonce: XUtils.encodeHex(mail.nonce),
|
|
1214
|
-
message: XUtils.encodeUTF8(msg),
|
|
1215
|
-
direction: "outgoing",
|
|
1216
|
-
timestamp: new Date(Date.now()),
|
|
894
|
+
authorID: mail.authorID,
|
|
1217
895
|
decrypted: true,
|
|
1218
|
-
|
|
896
|
+
direction: "outgoing",
|
|
1219
897
|
forward: mail.forward,
|
|
1220
|
-
|
|
898
|
+
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
899
|
+
mailID: mail.mailID,
|
|
900
|
+
message: XUtils.encodeUTF8(message),
|
|
901
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
1221
902
|
readerID: mail.readerID,
|
|
903
|
+
recipient: mail.recipient,
|
|
904
|
+
sender: mail.sender,
|
|
905
|
+
timestamp: new Date().toISOString(),
|
|
1222
906
|
};
|
|
1223
|
-
this.emit("message",
|
|
907
|
+
this.emitter.emit("message", emitMsg);
|
|
908
|
+
// send mail and wait for response
|
|
1224
909
|
await new Promise((res, rej) => {
|
|
1225
|
-
const callback =
|
|
1226
|
-
const [
|
|
1227
|
-
if (receivedMsg.transmissionID ===
|
|
1228
|
-
this.
|
|
1229
|
-
|
|
1230
|
-
|
|
910
|
+
const callback = (packedMsg) => {
|
|
911
|
+
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
912
|
+
if (receivedMsg.transmissionID === msg.transmissionID) {
|
|
913
|
+
this.socket.off("message", callback);
|
|
914
|
+
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
915
|
+
if (parsed.success && parsed.data.type === "success") {
|
|
916
|
+
res(parsed.data.data);
|
|
1231
917
|
}
|
|
1232
918
|
else {
|
|
1233
|
-
rej(
|
|
1234
|
-
|
|
1235
|
-
message: outMsg,
|
|
1236
|
-
});
|
|
919
|
+
rej(new Error("Mail delivery failed: " +
|
|
920
|
+
JSON.stringify(receivedMsg)));
|
|
1237
921
|
}
|
|
1238
922
|
}
|
|
1239
923
|
};
|
|
1240
|
-
this.
|
|
1241
|
-
this.send(
|
|
924
|
+
this.socket.on("message", callback);
|
|
925
|
+
void this.send(msg, hmac);
|
|
926
|
+
this.log.info("Mail sent.");
|
|
1242
927
|
});
|
|
1243
|
-
|
|
1244
|
-
}
|
|
1245
|
-
async getSessionList() {
|
|
1246
|
-
return this.database.getAllSessions();
|
|
1247
|
-
}
|
|
1248
|
-
async getServerList() {
|
|
1249
|
-
const res = await this.ax.get(this.getHost() + "/user/" + this.getUser().userID + "/servers");
|
|
1250
|
-
return msgpack.decode(new Uint8Array(res.data));
|
|
1251
|
-
}
|
|
1252
|
-
async createChannel(name, serverID) {
|
|
1253
|
-
const body = { name };
|
|
1254
|
-
const res = await this.ax.post(this.getHost() + "/server/" + serverID + "/channels", msgpack.encode(body), { headers: { "Content-Type": "application/msgpack" } });
|
|
1255
|
-
return msgpack.decode(new Uint8Array(res.data));
|
|
928
|
+
this.sending.delete(device.deviceID);
|
|
1256
929
|
}
|
|
1257
|
-
async
|
|
1258
|
-
|
|
1259
|
-
this.log.info("Found device in local cache.");
|
|
1260
|
-
return this.deviceRecords[deviceID];
|
|
1261
|
-
}
|
|
1262
|
-
const device = await this.database.getDevice(deviceID);
|
|
1263
|
-
if (device) {
|
|
1264
|
-
this.log.info("Found device in local db.");
|
|
1265
|
-
this.deviceRecords[deviceID] = device;
|
|
1266
|
-
return device;
|
|
1267
|
-
}
|
|
1268
|
-
try {
|
|
1269
|
-
const res = await this.ax.get(this.getHost() + "/device/" + deviceID);
|
|
1270
|
-
this.log.info("Retrieved device from server.");
|
|
1271
|
-
const fetchedDevice = msgpack.decode(new Uint8Array(res.data));
|
|
1272
|
-
this.deviceRecords[deviceID] = fetchedDevice;
|
|
1273
|
-
await this.database.saveDevice(fetchedDevice);
|
|
1274
|
-
return fetchedDevice;
|
|
1275
|
-
}
|
|
1276
|
-
catch (err) {
|
|
1277
|
-
return null;
|
|
1278
|
-
}
|
|
930
|
+
async deleteChannel(channelID) {
|
|
931
|
+
await this.http.delete(this.getHost() + "/channel/" + channelID);
|
|
1279
932
|
}
|
|
1280
933
|
async deleteDevice(deviceID) {
|
|
1281
934
|
if (deviceID === this.getDevice().deviceID) {
|
|
1282
935
|
throw new Error("You can't delete the device you're logged in to.");
|
|
1283
936
|
}
|
|
1284
|
-
await this.
|
|
937
|
+
await this.http.delete(this.prefixes.HTTP +
|
|
1285
938
|
this.host +
|
|
1286
939
|
"/user/" +
|
|
1287
940
|
this.getUser().userID +
|
|
1288
941
|
"/devices/" +
|
|
1289
942
|
deviceID);
|
|
1290
943
|
}
|
|
1291
|
-
async
|
|
944
|
+
async deleteHistory(channelOrUserID) {
|
|
945
|
+
await this.database.deleteHistory(channelOrUserID);
|
|
946
|
+
}
|
|
947
|
+
async deletePermission(permissionID) {
|
|
948
|
+
await this.http.delete(this.getHost() + "/permission/" + permissionID);
|
|
949
|
+
}
|
|
950
|
+
async deleteServer(serverID) {
|
|
951
|
+
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Gets a list of permissions for a server.
|
|
955
|
+
*
|
|
956
|
+
* @returns - The list of Permissions objects.
|
|
957
|
+
*/
|
|
958
|
+
async fetchPermissionList(serverID) {
|
|
959
|
+
const res = await this.http.get(this.prefixes.HTTP +
|
|
960
|
+
this.host +
|
|
961
|
+
"/server/" +
|
|
962
|
+
serverID +
|
|
963
|
+
"/permissions");
|
|
964
|
+
return decodeAxios(PermissionArrayCodec, res.data);
|
|
965
|
+
}
|
|
966
|
+
async fetchUser(userIdentifier) {
|
|
967
|
+
// Positive cache
|
|
968
|
+
if (userIdentifier in this.userRecords) {
|
|
969
|
+
return [this.userRecords[userIdentifier] ?? null, null];
|
|
970
|
+
}
|
|
971
|
+
// Negative cache — skip users we know don't exist (TTL-based)
|
|
972
|
+
const notFoundAt = this.notFoundUsers.get(userIdentifier);
|
|
973
|
+
if (notFoundAt && Date.now() - notFoundAt < Client.NOT_FOUND_TTL) {
|
|
974
|
+
return [null, null];
|
|
975
|
+
}
|
|
1292
976
|
try {
|
|
1293
|
-
const res = await this.
|
|
1294
|
-
const
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
return devices;
|
|
977
|
+
const res = await this.http.get(this.getHost() + "/user/" + userIdentifier);
|
|
978
|
+
const userRecord = decodeAxios(UserCodec, res.data);
|
|
979
|
+
this.userRecords[userIdentifier] = userRecord;
|
|
980
|
+
this.notFoundUsers.delete(userIdentifier);
|
|
981
|
+
return [userRecord, null];
|
|
1299
982
|
}
|
|
1300
983
|
catch (err) {
|
|
1301
|
-
|
|
984
|
+
if (isAxiosError(err) && err.response?.status === 404) {
|
|
985
|
+
// Definitive: user doesn't exist — cache and don't retry
|
|
986
|
+
this.notFoundUsers.set(userIdentifier, Date.now());
|
|
987
|
+
return [null, err];
|
|
988
|
+
}
|
|
989
|
+
// Transient (5xx, network error) — don't cache, caller can retry
|
|
990
|
+
return [null, isAxiosError(err) ? err : null];
|
|
1302
991
|
}
|
|
1303
992
|
}
|
|
1304
|
-
async
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
for (const device of devices) {
|
|
1309
|
-
this.deviceRecords[device.deviceID] = device;
|
|
1310
|
-
}
|
|
1311
|
-
return devices;
|
|
993
|
+
async forward(message) {
|
|
994
|
+
const copy = { ...message };
|
|
995
|
+
if (this.forwarded.has(copy.mailID)) {
|
|
996
|
+
return;
|
|
1312
997
|
}
|
|
1313
|
-
|
|
1314
|
-
|
|
998
|
+
this.forwarded.add(copy.mailID);
|
|
999
|
+
if (this.forwarded.size > 1000) {
|
|
1000
|
+
// Remove oldest entry
|
|
1001
|
+
const first = this.forwarded.values().next().value;
|
|
1002
|
+
if (first !== undefined)
|
|
1003
|
+
this.forwarded.delete(first);
|
|
1315
1004
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1005
|
+
const msgBytes = Uint8Array.from(msgpack.encode(copy));
|
|
1006
|
+
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
|
+
if (!devices) {
|
|
1010
|
+
throw new Error("Couldn't get own devices.");
|
|
1321
1011
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1012
|
+
const promises = [];
|
|
1013
|
+
for (const device of devices) {
|
|
1014
|
+
if (device.deviceID !== this.getDevice().deviceID) {
|
|
1015
|
+
promises.push(this.sendMail(device, this.getUser(), msgBytes, null, copy.mailID, true));
|
|
1016
|
+
}
|
|
1324
1017
|
}
|
|
1018
|
+
void Promise.allSettled(promises).then((results) => {
|
|
1019
|
+
for (const result of results) {
|
|
1020
|
+
const { status } = result;
|
|
1021
|
+
if (status === "rejected") {
|
|
1022
|
+
this.log.warn("Message failed.");
|
|
1023
|
+
this.log.warn(JSON.stringify(result));
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1325
1027
|
}
|
|
1326
1028
|
async getChannelByID(channelID) {
|
|
1327
1029
|
try {
|
|
1328
|
-
const res = await this.
|
|
1329
|
-
return
|
|
1030
|
+
const res = await this.http.get(this.getHost() + "/channel/" + channelID);
|
|
1031
|
+
return decodeAxios(ChannelCodec, res.data);
|
|
1330
1032
|
}
|
|
1331
|
-
catch (
|
|
1033
|
+
catch (_err) {
|
|
1332
1034
|
return null;
|
|
1333
1035
|
}
|
|
1334
1036
|
}
|
|
1335
1037
|
async getChannelList(serverID) {
|
|
1336
|
-
const res = await this.
|
|
1337
|
-
return
|
|
1338
|
-
}
|
|
1339
|
-
/* Get the currently logged in user. You cannot call this until
|
|
1340
|
-
after the auth event is emitted. */
|
|
1341
|
-
getUser() {
|
|
1342
|
-
if (!this.user) {
|
|
1343
|
-
throw new Error("You must wait until the auth event is emitted before fetching user details.");
|
|
1344
|
-
}
|
|
1345
|
-
return this.user;
|
|
1038
|
+
const res = await this.http.get(this.getHost() + "/server/" + serverID + "/channels");
|
|
1039
|
+
return decodeAxios(ChannelArrayCodec, res.data);
|
|
1346
1040
|
}
|
|
1347
1041
|
getDevice() {
|
|
1348
1042
|
if (!this.device) {
|
|
@@ -1350,24 +1044,27 @@ export class Client extends EventEmitter {
|
|
|
1350
1044
|
}
|
|
1351
1045
|
return this.device;
|
|
1352
1046
|
}
|
|
1353
|
-
|
|
1354
|
-
this.
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1047
|
+
async getDeviceByID(deviceID) {
|
|
1048
|
+
if (deviceID in this.deviceRecords) {
|
|
1049
|
+
this.log.info("Found device in local cache.");
|
|
1050
|
+
return this.deviceRecords[deviceID] ?? null;
|
|
1051
|
+
}
|
|
1052
|
+
const device = await this.database.getDevice(deviceID);
|
|
1053
|
+
if (device) {
|
|
1054
|
+
this.log.info("Found device in local db.");
|
|
1055
|
+
this.deviceRecords[deviceID] = device;
|
|
1056
|
+
return device;
|
|
1362
1057
|
}
|
|
1363
1058
|
try {
|
|
1364
|
-
const res = await this.
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1059
|
+
const res = await this.http.get(this.getHost() + "/device/" + deviceID);
|
|
1060
|
+
this.log.info("Retrieved device from server.");
|
|
1061
|
+
const fetchedDevice = decodeAxios(DeviceCodec, res.data);
|
|
1062
|
+
this.deviceRecords[deviceID] = fetchedDevice;
|
|
1063
|
+
await this.database.saveDevice(fetchedDevice);
|
|
1064
|
+
return fetchedDevice;
|
|
1368
1065
|
}
|
|
1369
|
-
catch (
|
|
1370
|
-
return
|
|
1066
|
+
catch (_err) {
|
|
1067
|
+
return null;
|
|
1371
1068
|
}
|
|
1372
1069
|
}
|
|
1373
1070
|
/* Retrieves the current list of users you have sessions with. */
|
|
@@ -1375,159 +1072,105 @@ export class Client extends EventEmitter {
|
|
|
1375
1072
|
const sessions = await this.database.getAllSessions();
|
|
1376
1073
|
const familiars = [];
|
|
1377
1074
|
for (const session of sessions) {
|
|
1378
|
-
const [user,
|
|
1075
|
+
const [user, _err] = await this.fetchUser(session.userID);
|
|
1379
1076
|
if (user) {
|
|
1380
1077
|
familiars.push(user);
|
|
1381
1078
|
}
|
|
1382
1079
|
}
|
|
1383
1080
|
return familiars;
|
|
1384
1081
|
}
|
|
1385
|
-
async
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
this.
|
|
1391
|
-
|
|
1082
|
+
async getGroupHistory(channelID) {
|
|
1083
|
+
const messages = await this.database.getGroupHistory(channelID);
|
|
1084
|
+
return messages;
|
|
1085
|
+
}
|
|
1086
|
+
async getMail() {
|
|
1087
|
+
while (this.fetchingMail) {
|
|
1088
|
+
await sleep(500);
|
|
1089
|
+
}
|
|
1090
|
+
this.fetchingMail = true;
|
|
1091
|
+
let firstFetch = false;
|
|
1092
|
+
if (this.firstMailFetch) {
|
|
1093
|
+
firstFetch = true;
|
|
1094
|
+
this.firstMailFetch = false;
|
|
1095
|
+
}
|
|
1096
|
+
if (firstFetch) {
|
|
1097
|
+
this.emitter.emit("decryptingMail");
|
|
1098
|
+
}
|
|
1099
|
+
this.log.info("fetching mail for device " + this.getDevice().deviceID);
|
|
1392
1100
|
try {
|
|
1393
|
-
|
|
1101
|
+
const res = await this.http.post(this.getHost() +
|
|
1102
|
+
"/device/" +
|
|
1103
|
+
this.getDevice().deviceID +
|
|
1104
|
+
"/mail");
|
|
1105
|
+
const mailBuffer = new Uint8Array(res.data);
|
|
1106
|
+
const rawInbox = z
|
|
1107
|
+
.array(mailInboxEntry)
|
|
1108
|
+
.parse(msgpack.decode(mailBuffer));
|
|
1109
|
+
const inbox = rawInbox.sort((a, b) => b[2].localeCompare(a[2]));
|
|
1110
|
+
for (const mailDetails of inbox) {
|
|
1111
|
+
const [mailHeader, mailBody, timestamp] = mailDetails;
|
|
1112
|
+
try {
|
|
1113
|
+
await this.readMail(mailHeader, mailBody, timestamp);
|
|
1114
|
+
}
|
|
1115
|
+
catch (err) {
|
|
1116
|
+
console.warn(String(err));
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1394
1119
|
}
|
|
1395
1120
|
catch (err) {
|
|
1396
|
-
|
|
1397
|
-
return;
|
|
1121
|
+
console.warn(String(err));
|
|
1398
1122
|
}
|
|
1399
|
-
this.
|
|
1400
|
-
" retrieved keybundle #" +
|
|
1401
|
-
keyBundle.otk?.index.toString() +
|
|
1402
|
-
" for " +
|
|
1403
|
-
device.deviceID);
|
|
1404
|
-
// my keys
|
|
1405
|
-
const IK_A = this.xKeyRing.identityKeys.secretKey;
|
|
1406
|
-
const IK_AP = this.xKeyRing.identityKeys.publicKey;
|
|
1407
|
-
const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
|
|
1408
|
-
// their keys
|
|
1409
|
-
const IK_B = XKeyConvert.convertPublicKey(keyBundle.signKey);
|
|
1410
|
-
const SPK_B = keyBundle.preKey.publicKey;
|
|
1411
|
-
const OPK_B = keyBundle.otk ? keyBundle.otk.publicKey : null;
|
|
1412
|
-
// diffie hellman functions
|
|
1413
|
-
const DH1 = xDH(IK_A, SPK_B);
|
|
1414
|
-
const DH2 = xDH(EK_A, IK_B);
|
|
1415
|
-
const DH3 = xDH(EK_A, SPK_B);
|
|
1416
|
-
const DH4 = OPK_B ? xDH(EK_A, OPK_B) : null;
|
|
1417
|
-
// initial key material
|
|
1418
|
-
const IKM = DH4 ? xConcat(DH1, DH2, DH3, DH4) : xConcat(DH1, DH2, DH3);
|
|
1419
|
-
// one time key index
|
|
1420
|
-
const IDX = keyBundle.otk
|
|
1421
|
-
? XUtils.numberToUint8Arr(keyBundle.otk.index)
|
|
1422
|
-
: XUtils.numberToUint8Arr(0);
|
|
1423
|
-
// shared secret key
|
|
1424
|
-
const SK = xKDF(IKM);
|
|
1425
|
-
this.log.info("Obtained SK, " + XUtils.encodeHex(SK));
|
|
1426
|
-
const PK = nacl.box.keyPair.fromSecretKey(SK).publicKey;
|
|
1427
|
-
this.log.info(this.toString() +
|
|
1428
|
-
" Obtained PK for " +
|
|
1429
|
-
device.deviceID +
|
|
1430
|
-
" " +
|
|
1431
|
-
XUtils.encodeHex(PK));
|
|
1432
|
-
const AD = xConcat(xEncode(xConstants.CURVE, IK_AP), xEncode(xConstants.CURVE, IK_B));
|
|
1433
|
-
const nonce = xMakeNonce();
|
|
1434
|
-
const cipher = nacl.secretbox(message, nonce, SK);
|
|
1435
|
-
this.log.info("Encrypted ciphertext.");
|
|
1436
|
-
/* 32 bytes for signkey, 32 bytes for ephemeral key,
|
|
1437
|
-
68 bytes for AD, 6 bytes for otk index (empty for no otk) */
|
|
1438
|
-
const extra = xConcat(this.signKeys.publicKey, this.xKeyRing.ephemeralKeys.publicKey, PK, AD, IDX);
|
|
1439
|
-
const mail = {
|
|
1440
|
-
mailType: MailType.initial,
|
|
1441
|
-
mailID: mailID || uuid.v4(),
|
|
1442
|
-
recipient: device.deviceID,
|
|
1443
|
-
cipher,
|
|
1444
|
-
nonce,
|
|
1445
|
-
extra,
|
|
1446
|
-
sender: this.getDevice().deviceID,
|
|
1447
|
-
group,
|
|
1448
|
-
forward,
|
|
1449
|
-
authorID: this.getUser().userID,
|
|
1450
|
-
readerID: user.userID,
|
|
1451
|
-
};
|
|
1452
|
-
const hmac = xHMAC(mail, SK);
|
|
1453
|
-
this.log.info("Mail hash: " + objectHash(mail));
|
|
1454
|
-
this.log.info("Generated hmac: " + XUtils.encodeHex(hmac));
|
|
1455
|
-
const msg = {
|
|
1456
|
-
transmissionID: uuid.v4(),
|
|
1457
|
-
type: "resource",
|
|
1458
|
-
resourceType: "mail",
|
|
1459
|
-
action: "CREATE",
|
|
1460
|
-
data: mail,
|
|
1461
|
-
};
|
|
1462
|
-
// discard the ephemeral keys
|
|
1463
|
-
this.newEphemeralKeys();
|
|
1464
|
-
// save the encryption session
|
|
1465
|
-
this.log.info("Saving new session.");
|
|
1466
|
-
const sessionEntry = {
|
|
1467
|
-
verified: false,
|
|
1468
|
-
sessionID: uuid.v4(),
|
|
1469
|
-
userID: user.userID,
|
|
1470
|
-
mode: "initiator",
|
|
1471
|
-
SK: XUtils.encodeHex(SK),
|
|
1472
|
-
publicKey: XUtils.encodeHex(PK),
|
|
1473
|
-
lastUsed: new Date(Date.now()),
|
|
1474
|
-
fingerprint: XUtils.encodeHex(AD),
|
|
1475
|
-
deviceID: device.deviceID,
|
|
1476
|
-
};
|
|
1477
|
-
await this.database.saveSession(sessionEntry);
|
|
1478
|
-
this.emit("session", sessionEntry, user);
|
|
1479
|
-
// emit the message
|
|
1480
|
-
const emitMsg = forward
|
|
1481
|
-
? { ...msgpack.decode(message), forward: true }
|
|
1482
|
-
: {
|
|
1483
|
-
nonce: XUtils.encodeHex(mail.nonce),
|
|
1484
|
-
mailID: mail.mailID,
|
|
1485
|
-
sender: mail.sender,
|
|
1486
|
-
recipient: mail.recipient,
|
|
1487
|
-
message: XUtils.encodeUTF8(message),
|
|
1488
|
-
direction: "outgoing",
|
|
1489
|
-
timestamp: new Date(Date.now()),
|
|
1490
|
-
decrypted: true,
|
|
1491
|
-
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
1492
|
-
forward: mail.forward,
|
|
1493
|
-
authorID: mail.authorID,
|
|
1494
|
-
readerID: mail.readerID,
|
|
1495
|
-
};
|
|
1496
|
-
this.emit("message", emitMsg);
|
|
1497
|
-
// send mail and wait for response
|
|
1498
|
-
await new Promise((res, rej) => {
|
|
1499
|
-
const callback = (packedMsg) => {
|
|
1500
|
-
const [header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
1501
|
-
if (receivedMsg.transmissionID === msg.transmissionID) {
|
|
1502
|
-
this.conn.off("message", callback);
|
|
1503
|
-
if (receivedMsg.type === "success") {
|
|
1504
|
-
res(receivedMsg.data);
|
|
1505
|
-
}
|
|
1506
|
-
else {
|
|
1507
|
-
rej({
|
|
1508
|
-
error: receivedMsg,
|
|
1509
|
-
message: emitMsg,
|
|
1510
|
-
});
|
|
1511
|
-
}
|
|
1512
|
-
}
|
|
1513
|
-
};
|
|
1514
|
-
this.conn.on("message", callback);
|
|
1515
|
-
this.send(msg, hmac);
|
|
1516
|
-
this.log.info("Mail sent.");
|
|
1517
|
-
});
|
|
1518
|
-
delete this.sending[device.deviceID];
|
|
1123
|
+
this.fetchingMail = false;
|
|
1519
1124
|
}
|
|
1520
|
-
|
|
1521
|
-
const
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1125
|
+
async getMessageHistory(userID) {
|
|
1126
|
+
const messages = await this.database.getMessageHistory(userID);
|
|
1127
|
+
return messages;
|
|
1128
|
+
}
|
|
1129
|
+
async getMultiUserDeviceList(userIDs) {
|
|
1130
|
+
try {
|
|
1131
|
+
const res = await this.http.post(this.getHost() + "/deviceList", msgpack.encode(userIDs), { headers: { "Content-Type": "application/msgpack" } });
|
|
1132
|
+
const devices = decodeAxios(DeviceArrayCodec, res.data);
|
|
1133
|
+
for (const device of devices) {
|
|
1134
|
+
this.deviceRecords[device.deviceID] = device;
|
|
1135
|
+
}
|
|
1136
|
+
return devices;
|
|
1137
|
+
}
|
|
1138
|
+
catch (_err) {
|
|
1139
|
+
return [];
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
async getOTKCount() {
|
|
1143
|
+
const res = await this.http.get(this.getHost() +
|
|
1144
|
+
"/device/" +
|
|
1145
|
+
this.getDevice().deviceID +
|
|
1146
|
+
"/otk/count");
|
|
1147
|
+
return decodeAxios(OtkCountCodec, res.data).count;
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Gets all permissions for the logged in user.
|
|
1151
|
+
*
|
|
1152
|
+
* @returns - The list of Permissions objects.
|
|
1153
|
+
*/
|
|
1154
|
+
async getPermissions() {
|
|
1155
|
+
const res = await this.http.get(this.getHost() + "/user/" + this.getUser().userID + "/permissions");
|
|
1156
|
+
return decodeAxios(PermissionArrayCodec, res.data);
|
|
1157
|
+
}
|
|
1158
|
+
async getServerByID(serverID) {
|
|
1159
|
+
try {
|
|
1160
|
+
const res = await this.http.get(this.getHost() + "/server/" + serverID);
|
|
1161
|
+
return decodeAxios(ServerCodec, res.data);
|
|
1162
|
+
}
|
|
1163
|
+
catch (_err) {
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
async getServerList() {
|
|
1168
|
+
const res = await this.http.get(this.getHost() + "/user/" + this.getUser().userID + "/servers");
|
|
1169
|
+
return decodeAxios(ServerArrayCodec, res.data);
|
|
1527
1170
|
}
|
|
1528
1171
|
async getSessionByPubkey(publicKey) {
|
|
1529
1172
|
const strPubKey = XUtils.encodeHex(publicKey);
|
|
1530
|
-
if (this.sessionRecords
|
|
1173
|
+
if (strPubKey in this.sessionRecords) {
|
|
1531
1174
|
return this.sessionRecords[strPubKey];
|
|
1532
1175
|
}
|
|
1533
1176
|
const session = await this.database.getSessionByPublicKey(publicKey);
|
|
@@ -1536,266 +1179,45 @@ export class Client extends EventEmitter {
|
|
|
1536
1179
|
}
|
|
1537
1180
|
return session;
|
|
1538
1181
|
}
|
|
1539
|
-
async
|
|
1540
|
-
this.
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1182
|
+
async getSessionList() {
|
|
1183
|
+
return this.database.getAllSessions();
|
|
1184
|
+
}
|
|
1185
|
+
async getToken(type) {
|
|
1186
|
+
try {
|
|
1187
|
+
const res = await this.http.get(this.getHost() + "/token/" + type, {
|
|
1188
|
+
responseType: "arraybuffer",
|
|
1189
|
+
});
|
|
1190
|
+
return decodeAxios(ActionTokenCodec, res.data);
|
|
1545
1191
|
}
|
|
1546
|
-
|
|
1192
|
+
catch (err) {
|
|
1193
|
+
this.log.warn(String(err));
|
|
1194
|
+
return null;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
/* Get the currently logged in user. You cannot call this until
|
|
1198
|
+
after the auth event is emitted. */
|
|
1199
|
+
getUser() {
|
|
1200
|
+
if (!this.user) {
|
|
1201
|
+
throw new Error("You must wait until the auth event is emitted before fetching user details.");
|
|
1202
|
+
}
|
|
1203
|
+
return this.user;
|
|
1204
|
+
}
|
|
1205
|
+
async getUserDeviceList(userID) {
|
|
1547
1206
|
try {
|
|
1548
|
-
const
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
if (deviceEntry && user) {
|
|
1553
|
-
this.createSession(deviceEntry, user, XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`), mail.group, uuid.v4(), false);
|
|
1554
|
-
}
|
|
1555
|
-
};
|
|
1556
|
-
this.log.info("Received mail from " + mail.sender);
|
|
1557
|
-
switch (mail.mailType) {
|
|
1558
|
-
case MailType.subsequent:
|
|
1559
|
-
const [publicKey] = Client.deserializeExtra(mail.mailType, mail.extra);
|
|
1560
|
-
let session = await this.getSessionByPubkey(publicKey);
|
|
1561
|
-
let retries = 0;
|
|
1562
|
-
while (!session) {
|
|
1563
|
-
if (retries > 3) {
|
|
1564
|
-
break;
|
|
1565
|
-
}
|
|
1566
|
-
session = await this.getSessionByPubkey(publicKey);
|
|
1567
|
-
retries++;
|
|
1568
|
-
return;
|
|
1569
|
-
}
|
|
1570
|
-
if (!session) {
|
|
1571
|
-
this.log.warn("Couldn't find session public key " +
|
|
1572
|
-
XUtils.encodeHex(publicKey));
|
|
1573
|
-
healSession();
|
|
1574
|
-
return;
|
|
1575
|
-
}
|
|
1576
|
-
this.log.info("Session found for " + mail.sender);
|
|
1577
|
-
this.log.info("Mail nonce " + XUtils.encodeHex(mail.nonce));
|
|
1578
|
-
const HMAC = xHMAC(mail, session.SK);
|
|
1579
|
-
this.log.info("Mail hash: " + objectHash(mail));
|
|
1580
|
-
this.log.info("Calculated hmac: " + XUtils.encodeHex(HMAC));
|
|
1581
|
-
if (!XUtils.bytesEqual(HMAC, header)) {
|
|
1582
|
-
this.log.warn("Message authentication failed (HMAC does not match).");
|
|
1583
|
-
healSession();
|
|
1584
|
-
return;
|
|
1585
|
-
}
|
|
1586
|
-
const decrypted = nacl.secretbox.open(mail.cipher, mail.nonce, session.SK);
|
|
1587
|
-
if (decrypted) {
|
|
1588
|
-
this.log.info("Decryption successful.");
|
|
1589
|
-
let plaintext = "";
|
|
1590
|
-
if (!mail.forward) {
|
|
1591
|
-
plaintext = XUtils.encodeUTF8(decrypted);
|
|
1592
|
-
}
|
|
1593
|
-
// emit the message
|
|
1594
|
-
const message = mail.forward
|
|
1595
|
-
? {
|
|
1596
|
-
...msgpack.decode(decrypted),
|
|
1597
|
-
forward: true,
|
|
1598
|
-
}
|
|
1599
|
-
: {
|
|
1600
|
-
nonce: XUtils.encodeHex(mail.nonce),
|
|
1601
|
-
mailID: mail.mailID,
|
|
1602
|
-
sender: mail.sender,
|
|
1603
|
-
recipient: mail.recipient,
|
|
1604
|
-
message: XUtils.encodeUTF8(decrypted),
|
|
1605
|
-
direction: "incoming",
|
|
1606
|
-
timestamp: new Date(timestamp),
|
|
1607
|
-
decrypted: true,
|
|
1608
|
-
group: mail.group
|
|
1609
|
-
? uuid.stringify(mail.group)
|
|
1610
|
-
: null,
|
|
1611
|
-
forward: mail.forward,
|
|
1612
|
-
authorID: mail.authorID,
|
|
1613
|
-
readerID: mail.readerID,
|
|
1614
|
-
};
|
|
1615
|
-
this.emit("message", message);
|
|
1616
|
-
this.database.markSessionUsed(session.sessionID);
|
|
1617
|
-
}
|
|
1618
|
-
else {
|
|
1619
|
-
this.log.info("Decryption failed.");
|
|
1620
|
-
healSession();
|
|
1621
|
-
// emit the message
|
|
1622
|
-
const message = {
|
|
1623
|
-
nonce: XUtils.encodeHex(mail.nonce),
|
|
1624
|
-
mailID: mail.mailID,
|
|
1625
|
-
sender: mail.sender,
|
|
1626
|
-
recipient: mail.recipient,
|
|
1627
|
-
message: "",
|
|
1628
|
-
direction: "incoming",
|
|
1629
|
-
timestamp: new Date(timestamp),
|
|
1630
|
-
decrypted: false,
|
|
1631
|
-
group: mail.group
|
|
1632
|
-
? uuid.stringify(mail.group)
|
|
1633
|
-
: null,
|
|
1634
|
-
forward: mail.forward,
|
|
1635
|
-
authorID: mail.authorID,
|
|
1636
|
-
readerID: mail.readerID,
|
|
1637
|
-
};
|
|
1638
|
-
this.emit("message", message);
|
|
1639
|
-
}
|
|
1640
|
-
break;
|
|
1641
|
-
case MailType.initial:
|
|
1642
|
-
this.log.info("Initiating new session.");
|
|
1643
|
-
const [signKey, ephKey, assocData, indexBytes] = Client.deserializeExtra(MailType.initial, mail.extra);
|
|
1644
|
-
const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
|
|
1645
|
-
this.log.info(this.toString() + " otk #" + preKeyIndex + " indicated");
|
|
1646
|
-
const otk = preKeyIndex === 0
|
|
1647
|
-
? null
|
|
1648
|
-
: await this.database.getOneTimeKey(preKeyIndex);
|
|
1649
|
-
if (otk) {
|
|
1650
|
-
this.log.info("otk #" +
|
|
1651
|
-
JSON.stringify(otk?.index) +
|
|
1652
|
-
" retrieved from database.");
|
|
1653
|
-
}
|
|
1654
|
-
this.log.info("signKey: " + XUtils.encodeHex(signKey));
|
|
1655
|
-
this.log.info("preKey: " + XUtils.encodeHex(ephKey));
|
|
1656
|
-
if (otk) {
|
|
1657
|
-
this.log.info("OTK: " + XUtils.encodeHex(otk.keyPair.publicKey));
|
|
1658
|
-
}
|
|
1659
|
-
if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
|
|
1660
|
-
this.log.warn("OTK index mismatch, received " +
|
|
1661
|
-
JSON.stringify(otk?.index) +
|
|
1662
|
-
", expected " +
|
|
1663
|
-
preKeyIndex.toString());
|
|
1664
|
-
return;
|
|
1665
|
-
}
|
|
1666
|
-
// their public keys
|
|
1667
|
-
const IK_A = XKeyConvert.convertPublicKey(signKey);
|
|
1668
|
-
const EK_A = ephKey;
|
|
1669
|
-
// my private keys
|
|
1670
|
-
const IK_B = this.xKeyRing.identityKeys.secretKey;
|
|
1671
|
-
const IK_BP = this.xKeyRing.identityKeys.publicKey;
|
|
1672
|
-
const SPK_B = this.xKeyRing.preKeys.keyPair.secretKey;
|
|
1673
|
-
const OPK_B = otk ? otk.keyPair.secretKey : null;
|
|
1674
|
-
// diffie hellman functions
|
|
1675
|
-
const DH1 = xDH(SPK_B, IK_A);
|
|
1676
|
-
const DH2 = xDH(IK_B, EK_A);
|
|
1677
|
-
const DH3 = xDH(SPK_B, EK_A);
|
|
1678
|
-
const DH4 = OPK_B ? xDH(OPK_B, EK_A) : null;
|
|
1679
|
-
// initial key material
|
|
1680
|
-
const IKM = DH4
|
|
1681
|
-
? xConcat(DH1, DH2, DH3, DH4)
|
|
1682
|
-
: xConcat(DH1, DH2, DH3);
|
|
1683
|
-
// shared secret key
|
|
1684
|
-
const SK = xKDF(IKM);
|
|
1685
|
-
this.log.info("Obtained SK for " +
|
|
1686
|
-
mail.sender +
|
|
1687
|
-
", " +
|
|
1688
|
-
XUtils.encodeHex(SK));
|
|
1689
|
-
// shared public key
|
|
1690
|
-
const PK = nacl.box.keyPair.fromSecretKey(SK).publicKey;
|
|
1691
|
-
this.log.info(this.toString() +
|
|
1692
|
-
"Obtained PK for " +
|
|
1693
|
-
mail.sender +
|
|
1694
|
-
" " +
|
|
1695
|
-
XUtils.encodeHex(PK));
|
|
1696
|
-
const hmac = xHMAC(mail, SK);
|
|
1697
|
-
this.log.info("Mail hash: " + objectHash(mail));
|
|
1698
|
-
this.log.info("Calculated hmac: " + XUtils.encodeHex(hmac));
|
|
1699
|
-
// associated data
|
|
1700
|
-
const AD = xConcat(xEncode(xConstants.CURVE, IK_A), xEncode(xConstants.CURVE, IK_BP));
|
|
1701
|
-
if (!XUtils.bytesEqual(hmac, header)) {
|
|
1702
|
-
console.warn("Mail authentication failed (HMAC did not match).");
|
|
1703
|
-
console.warn(mail);
|
|
1704
|
-
return;
|
|
1705
|
-
}
|
|
1706
|
-
this.log.info("Mail authenticated successfully.");
|
|
1707
|
-
const unsealed = nacl.secretbox.open(mail.cipher, mail.nonce, SK);
|
|
1708
|
-
if (unsealed) {
|
|
1709
|
-
this.log.info("Decryption successful.");
|
|
1710
|
-
let plaintext = "";
|
|
1711
|
-
if (!mail.forward) {
|
|
1712
|
-
plaintext = XUtils.encodeUTF8(unsealed);
|
|
1713
|
-
}
|
|
1714
|
-
// emit the message
|
|
1715
|
-
const message = mail.forward
|
|
1716
|
-
? { ...msgpack.decode(unsealed), forward: true }
|
|
1717
|
-
: {
|
|
1718
|
-
nonce: XUtils.encodeHex(mail.nonce),
|
|
1719
|
-
mailID: mail.mailID,
|
|
1720
|
-
sender: mail.sender,
|
|
1721
|
-
recipient: mail.recipient,
|
|
1722
|
-
message: plaintext,
|
|
1723
|
-
direction: "incoming",
|
|
1724
|
-
timestamp: new Date(timestamp),
|
|
1725
|
-
decrypted: true,
|
|
1726
|
-
group: mail.group
|
|
1727
|
-
? uuid.stringify(mail.group)
|
|
1728
|
-
: null,
|
|
1729
|
-
forward: mail.forward,
|
|
1730
|
-
authorID: mail.authorID,
|
|
1731
|
-
readerID: mail.readerID,
|
|
1732
|
-
};
|
|
1733
|
-
this.emit("message", message);
|
|
1734
|
-
// discard onetimekey
|
|
1735
|
-
await this.database.deleteOneTimeKey(preKeyIndex);
|
|
1736
|
-
const deviceEntry = await this.getDeviceByID(mail.sender);
|
|
1737
|
-
if (!deviceEntry) {
|
|
1738
|
-
throw new Error("Couldn't get device entry.");
|
|
1739
|
-
}
|
|
1740
|
-
const [userEntry, userErr] = await this.retrieveUserDBEntry(deviceEntry.owner);
|
|
1741
|
-
if (!userEntry) {
|
|
1742
|
-
throw new Error("Couldn't get user entry.");
|
|
1743
|
-
}
|
|
1744
|
-
this.userRecords[userEntry.userID] = userEntry;
|
|
1745
|
-
this.deviceRecords[deviceEntry.deviceID] = deviceEntry;
|
|
1746
|
-
// save session
|
|
1747
|
-
const newSession = {
|
|
1748
|
-
verified: false,
|
|
1749
|
-
sessionID: uuid.v4(),
|
|
1750
|
-
userID: userEntry.userID,
|
|
1751
|
-
mode: "receiver",
|
|
1752
|
-
SK: XUtils.encodeHex(SK),
|
|
1753
|
-
publicKey: XUtils.encodeHex(PK),
|
|
1754
|
-
lastUsed: new Date(Date.now()),
|
|
1755
|
-
fingerprint: XUtils.encodeHex(AD),
|
|
1756
|
-
deviceID: mail.sender,
|
|
1757
|
-
};
|
|
1758
|
-
await this.database.saveSession(newSession);
|
|
1759
|
-
let [user, err] = await this.retrieveUserDBEntry(newSession.userID);
|
|
1760
|
-
if (user) {
|
|
1761
|
-
this.emit("session", newSession, user);
|
|
1762
|
-
}
|
|
1763
|
-
else {
|
|
1764
|
-
let failed = 1;
|
|
1765
|
-
// retry a couple times
|
|
1766
|
-
while (!user) {
|
|
1767
|
-
[user, err] = await this.retrieveUserDBEntry(newSession.userID);
|
|
1768
|
-
failed++;
|
|
1769
|
-
if (failed > 3) {
|
|
1770
|
-
this.log.warn("Couldn't retrieve user entry.");
|
|
1771
|
-
break;
|
|
1772
|
-
}
|
|
1773
|
-
}
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
else {
|
|
1777
|
-
this.log.warn("Mail decryption failed.");
|
|
1778
|
-
}
|
|
1779
|
-
break;
|
|
1780
|
-
default:
|
|
1781
|
-
this.log.warn("Unsupported MailType:", mail.mailType);
|
|
1782
|
-
break;
|
|
1207
|
+
const res = await this.http.get(this.getHost() + "/user/" + userID + "/devices");
|
|
1208
|
+
const devices = decodeAxios(DeviceArrayCodec, res.data);
|
|
1209
|
+
for (const device of devices) {
|
|
1210
|
+
this.deviceRecords[device.deviceID] = device;
|
|
1783
1211
|
}
|
|
1212
|
+
return devices;
|
|
1784
1213
|
}
|
|
1785
|
-
|
|
1786
|
-
|
|
1214
|
+
catch (_err) {
|
|
1215
|
+
return null;
|
|
1787
1216
|
}
|
|
1788
1217
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
createPreKey() {
|
|
1793
|
-
const preKeyPair = nacl.box.keyPair();
|
|
1794
|
-
const preKeys = {
|
|
1795
|
-
keyPair: preKeyPair,
|
|
1796
|
-
signature: nacl.sign(xEncode(xConstants.CURVE, preKeyPair.publicKey), this.signKeys.secretKey),
|
|
1797
|
-
};
|
|
1798
|
-
return preKeys;
|
|
1218
|
+
async getUserList(channelID) {
|
|
1219
|
+
const res = await this.http.post(this.getHost() + "/userList/" + channelID);
|
|
1220
|
+
return decodeAxios(UserArrayCodec, res.data);
|
|
1799
1221
|
}
|
|
1800
1222
|
async handleNotify(msg) {
|
|
1801
1223
|
switch (msg.event) {
|
|
@@ -1805,42 +1227,36 @@ export class Client extends EventEmitter {
|
|
|
1805
1227
|
this.fetchingMail = false;
|
|
1806
1228
|
break;
|
|
1807
1229
|
case "permission":
|
|
1808
|
-
this.emit("permission", msg.data);
|
|
1230
|
+
this.emitter.emit("permission", PermissionSchema.parse(msg.data));
|
|
1809
1231
|
break;
|
|
1810
1232
|
case "retryRequest":
|
|
1811
|
-
|
|
1233
|
+
// msg.data is the messageID for retry
|
|
1812
1234
|
break;
|
|
1813
1235
|
default:
|
|
1814
1236
|
this.log.info("Unsupported notification event " + msg.event);
|
|
1815
1237
|
break;
|
|
1816
1238
|
}
|
|
1817
1239
|
}
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
if (
|
|
1823
|
-
|
|
1824
|
-
preKeys = this.createPreKey();
|
|
1825
|
-
await this.database.savePreKeys([preKeys], false);
|
|
1826
|
-
}
|
|
1827
|
-
const sessions = await this.database.getAllSessions();
|
|
1828
|
-
for (const session of sessions) {
|
|
1829
|
-
this.sessionRecords[session.publicKey] =
|
|
1830
|
-
sqlSessionToCrypto(session);
|
|
1240
|
+
/**
|
|
1241
|
+
* Initializes the keyring. This must be called before anything else.
|
|
1242
|
+
*/
|
|
1243
|
+
async init() {
|
|
1244
|
+
if (this.hasInit) {
|
|
1245
|
+
throw new Error("You should only call init() once.");
|
|
1831
1246
|
}
|
|
1832
|
-
|
|
1833
|
-
this.
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1247
|
+
this.hasInit = true;
|
|
1248
|
+
await this.populateKeyRing();
|
|
1249
|
+
this.emitter.on("message", (message) => {
|
|
1250
|
+
if (message.direction === "outgoing" && !message.forward) {
|
|
1251
|
+
void this.forward(message);
|
|
1252
|
+
}
|
|
1253
|
+
if (message.direction === "incoming" &&
|
|
1254
|
+
message.recipient === message.sender) {
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
void this.database.saveMessage(message);
|
|
1258
|
+
});
|
|
1259
|
+
this.emitter.emit("ready");
|
|
1844
1260
|
}
|
|
1845
1261
|
initSocket() {
|
|
1846
1262
|
try {
|
|
@@ -1849,148 +1265,757 @@ export class Client extends EventEmitter {
|
|
|
1849
1265
|
}
|
|
1850
1266
|
const wsUrl = this.prefixes.WS + this.host + "/socket";
|
|
1851
1267
|
// Auth sent as first message after open
|
|
1852
|
-
this.
|
|
1853
|
-
this.
|
|
1268
|
+
this.socket = new WebSocketAdapter(wsUrl);
|
|
1269
|
+
this.socket.on("open", () => {
|
|
1854
1270
|
this.log.info("Connection opened.");
|
|
1855
|
-
// Send auth as first message
|
|
1856
|
-
|
|
1271
|
+
// Send auth as first message (encoded to bytes — protocol is binary).
|
|
1272
|
+
const authMsg = JSON.stringify({
|
|
1273
|
+
token: this.token,
|
|
1274
|
+
type: "auth",
|
|
1275
|
+
});
|
|
1276
|
+
this.socket.send(new TextEncoder().encode(authMsg));
|
|
1857
1277
|
this.pingInterval = setInterval(this.ping.bind(this), 15000);
|
|
1858
1278
|
});
|
|
1859
|
-
this.
|
|
1279
|
+
this.socket.on("close", () => {
|
|
1860
1280
|
this.log.info("Connection closed.");
|
|
1861
1281
|
if (this.pingInterval) {
|
|
1862
1282
|
clearInterval(this.pingInterval);
|
|
1863
1283
|
this.pingInterval = null;
|
|
1864
1284
|
}
|
|
1865
1285
|
if (!this.manuallyClosing) {
|
|
1866
|
-
this.emit("disconnect");
|
|
1286
|
+
this.emitter.emit("disconnect");
|
|
1867
1287
|
}
|
|
1868
1288
|
});
|
|
1869
|
-
this.
|
|
1289
|
+
this.socket.on("error", (error) => {
|
|
1870
1290
|
throw error;
|
|
1871
1291
|
});
|
|
1872
|
-
this.
|
|
1873
|
-
const [header,
|
|
1874
|
-
this.log.debug(
|
|
1875
|
-
this.log.debug(
|
|
1292
|
+
this.socket.on("message", (message) => {
|
|
1293
|
+
const [header, raw] = XUtils.unpackMessage(message);
|
|
1294
|
+
this.log.debug("INH " + XUtils.encodeHex(header));
|
|
1295
|
+
this.log.debug("IN " + JSON.stringify(raw, null, 4));
|
|
1296
|
+
const parseResult = WSMessageSchema.safeParse(raw);
|
|
1297
|
+
if (!parseResult.success) {
|
|
1298
|
+
this.log.warn("Unknown WS message: " + JSON.stringify(raw));
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
const msg = parseResult.data;
|
|
1876
1302
|
switch (msg.type) {
|
|
1303
|
+
case "challenge":
|
|
1304
|
+
this.log.info("Received challenge from server.");
|
|
1305
|
+
this.respond(msg);
|
|
1306
|
+
break;
|
|
1307
|
+
case "error":
|
|
1308
|
+
this.log.warn(JSON.stringify(msg));
|
|
1309
|
+
break;
|
|
1310
|
+
case "notify":
|
|
1311
|
+
void this.handleNotify(msg);
|
|
1312
|
+
break;
|
|
1877
1313
|
case "ping":
|
|
1878
1314
|
this.pong(msg.transmissionID);
|
|
1879
1315
|
break;
|
|
1880
1316
|
case "pong":
|
|
1881
1317
|
this.setAlive(true);
|
|
1882
1318
|
break;
|
|
1883
|
-
case "
|
|
1884
|
-
this.log.info("Received challenge from server.");
|
|
1885
|
-
this.respond(msg);
|
|
1319
|
+
case "success":
|
|
1886
1320
|
break;
|
|
1887
1321
|
case "unauthorized":
|
|
1888
1322
|
throw new Error("Received unauthorized message from server.");
|
|
1889
1323
|
case "authorized":
|
|
1890
|
-
this.log.info("Authenticated with userID " +
|
|
1891
|
-
|
|
1892
|
-
this.
|
|
1324
|
+
this.log.info("Authenticated with userID " +
|
|
1325
|
+
(this.user?.userID ?? "unknown"));
|
|
1326
|
+
this.emitter.emit("connected");
|
|
1327
|
+
void this.postAuth();
|
|
1893
1328
|
break;
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
case "error":
|
|
1897
|
-
this.log.warn(JSON.stringify(msg));
|
|
1898
|
-
break;
|
|
1899
|
-
case "notify":
|
|
1900
|
-
this.handleNotify(msg);
|
|
1901
|
-
break;
|
|
1902
|
-
default:
|
|
1903
|
-
this.log.info("Unsupported message " + msg.type);
|
|
1329
|
+
default:
|
|
1330
|
+
this.log.info("Unsupported message " + msg.type);
|
|
1904
1331
|
break;
|
|
1905
1332
|
}
|
|
1906
1333
|
});
|
|
1907
1334
|
}
|
|
1908
1335
|
catch (err) {
|
|
1909
|
-
throw new Error("Error initiating websocket connection " + err
|
|
1336
|
+
throw new Error("Error initiating websocket connection " + String(err));
|
|
1910
1337
|
}
|
|
1911
1338
|
}
|
|
1912
|
-
|
|
1913
|
-
|
|
1339
|
+
async kickUser(userID, serverID) {
|
|
1340
|
+
const permissionList = await this.fetchPermissionList(serverID);
|
|
1341
|
+
for (const permission of permissionList) {
|
|
1342
|
+
if (userID === permission.userID) {
|
|
1343
|
+
await this.deletePermission(permission.permissionID);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
throw new Error("Couldn't kick user.");
|
|
1348
|
+
}
|
|
1349
|
+
async leaveServer(serverID) {
|
|
1350
|
+
const permissionList = await this.permissions.retrieve();
|
|
1351
|
+
for (const permission of permissionList) {
|
|
1352
|
+
if (permission.resourceID === serverID) {
|
|
1353
|
+
await this.deletePermission(permission.permissionID);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
async markSessionVerified(sessionID) {
|
|
1358
|
+
return this.database.markSessionVerified(sessionID);
|
|
1359
|
+
}
|
|
1360
|
+
async negotiateOTK() {
|
|
1361
|
+
const otkCount = await this.getOTKCount();
|
|
1362
|
+
this.log.info("Server reported OTK: " + otkCount.toString());
|
|
1363
|
+
const needs = xConstants.MIN_OTK_SUPPLY - otkCount;
|
|
1364
|
+
if (needs === 0) {
|
|
1365
|
+
this.log.info("Server otk supply full.");
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
await this.submitOTK(needs);
|
|
1369
|
+
}
|
|
1370
|
+
newEphemeralKeys() {
|
|
1371
|
+
if (!this.xKeyRing) {
|
|
1372
|
+
throw new Error("Key ring not initialized.");
|
|
1373
|
+
}
|
|
1374
|
+
this.xKeyRing.ephemeralKeys = xBoxKeyPair();
|
|
1375
|
+
}
|
|
1376
|
+
ping() {
|
|
1377
|
+
if (!this.isAlive) {
|
|
1378
|
+
this.log.warn("Ping failed.");
|
|
1379
|
+
}
|
|
1380
|
+
this.setAlive(false);
|
|
1381
|
+
void this.send({ transmissionID: uuid.v4(), type: "ping" });
|
|
1382
|
+
}
|
|
1383
|
+
pong(transmissionID) {
|
|
1384
|
+
void this.send({ transmissionID, type: "pong" });
|
|
1385
|
+
}
|
|
1386
|
+
async populateKeyRing() {
|
|
1387
|
+
// we've checked in the constructor that these exist
|
|
1388
|
+
if (!this.idKeys) {
|
|
1389
|
+
throw new Error("Identity keys are missing.");
|
|
1390
|
+
}
|
|
1391
|
+
const identityKeys = this.idKeys;
|
|
1392
|
+
const existingPreKeys = await this.database.getPreKeys();
|
|
1393
|
+
const preKeys = existingPreKeys ??
|
|
1394
|
+
(await (async () => {
|
|
1395
|
+
this.log.warn("No prekeys found in database, creating a new one.");
|
|
1396
|
+
const unsaved = this.createPreKey();
|
|
1397
|
+
const [saved] = await this.database.savePreKeys([unsaved], false);
|
|
1398
|
+
if (!saved || saved.index == null)
|
|
1399
|
+
throw new Error("Failed to save prekey — no index returned.");
|
|
1400
|
+
return { ...unsaved, index: saved.index };
|
|
1401
|
+
})());
|
|
1402
|
+
const sessions = await this.database.getAllSessions();
|
|
1403
|
+
for (const session of sessions) {
|
|
1404
|
+
this.sessionRecords[session.publicKey] =
|
|
1405
|
+
sqlSessionToCrypto(session);
|
|
1406
|
+
}
|
|
1407
|
+
const ephemeralKeys = xBoxKeyPair();
|
|
1408
|
+
this.xKeyRing = {
|
|
1409
|
+
ephemeralKeys,
|
|
1410
|
+
identityKeys,
|
|
1411
|
+
preKeys,
|
|
1412
|
+
};
|
|
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));
|
|
1914
1419
|
}
|
|
1915
1420
|
async postAuth() {
|
|
1916
1421
|
let count = 0;
|
|
1917
|
-
|
|
1422
|
+
for (;;) {
|
|
1918
1423
|
try {
|
|
1919
1424
|
await this.getMail();
|
|
1920
1425
|
count++;
|
|
1921
1426
|
this.fetchingMail = false;
|
|
1922
1427
|
if (count > 10) {
|
|
1923
|
-
this.negotiateOTK();
|
|
1428
|
+
void this.negotiateOTK();
|
|
1924
1429
|
count = 0;
|
|
1925
1430
|
}
|
|
1926
1431
|
}
|
|
1927
1432
|
catch (err) {
|
|
1928
|
-
this.log.warn("Problem fetching mail" + err
|
|
1433
|
+
this.log.warn("Problem fetching mail" + String(err));
|
|
1929
1434
|
}
|
|
1930
1435
|
await sleep(1000 * 60);
|
|
1931
1436
|
}
|
|
1932
1437
|
}
|
|
1933
|
-
async
|
|
1934
|
-
|
|
1935
|
-
|
|
1438
|
+
async purgeHistory() {
|
|
1439
|
+
await this.database.purgeHistory();
|
|
1440
|
+
}
|
|
1441
|
+
async readMail(header, mail, timestamp) {
|
|
1442
|
+
this.sendReceipt(new Uint8Array(mail.nonce));
|
|
1443
|
+
let timeout = 1;
|
|
1444
|
+
while (this.reading) {
|
|
1445
|
+
await sleep(timeout);
|
|
1446
|
+
timeout *= 2;
|
|
1936
1447
|
}
|
|
1937
|
-
this.
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1448
|
+
this.reading = true;
|
|
1449
|
+
try {
|
|
1450
|
+
const healSession = async () => {
|
|
1451
|
+
this.log.info("Requesting retry of " + mail.mailID);
|
|
1452
|
+
const deviceEntry = await this.getDeviceByID(mail.sender);
|
|
1453
|
+
const [user, _err] = await this.fetchUser(mail.authorID);
|
|
1454
|
+
if (deviceEntry && user) {
|
|
1455
|
+
void this.createSession(deviceEntry, user, XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`), mail.group, uuid.v4(), false);
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
this.log.info("Received mail from " + mail.sender);
|
|
1459
|
+
switch (mail.mailType) {
|
|
1460
|
+
case MailType.initial:
|
|
1461
|
+
this.log.info("Initiating new session.");
|
|
1462
|
+
const extraParts = Client.deserializeExtra(MailType.initial, new Uint8Array(mail.extra));
|
|
1463
|
+
const signKey = extraParts[0];
|
|
1464
|
+
const ephKey = extraParts[1];
|
|
1465
|
+
const indexBytes = extraParts[3];
|
|
1466
|
+
if (!signKey || !ephKey || !indexBytes) {
|
|
1467
|
+
throw new Error("Malformed initial mail extra: missing signKey, ephKey, or indexBytes");
|
|
1468
|
+
}
|
|
1469
|
+
const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
|
|
1470
|
+
this.log.info(this.toString() +
|
|
1471
|
+
" otk #" +
|
|
1472
|
+
String(preKeyIndex) +
|
|
1473
|
+
" indicated");
|
|
1474
|
+
const otk = preKeyIndex === 0
|
|
1475
|
+
? null
|
|
1476
|
+
: 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
|
+
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
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
// their public keys
|
|
1495
|
+
const IK_A_raw = XKeyConvert.convertPublicKey(signKey);
|
|
1496
|
+
if (!IK_A_raw) {
|
|
1497
|
+
this.log.warn("Could not convert sign key to X25519.");
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
const IK_A = IK_A_raw;
|
|
1501
|
+
const EK_A = ephKey;
|
|
1502
|
+
if (!this.xKeyRing) {
|
|
1503
|
+
throw new Error("Key ring not initialized.");
|
|
1504
|
+
}
|
|
1505
|
+
// my private keys
|
|
1506
|
+
const IK_B = this.xKeyRing.identityKeys.secretKey;
|
|
1507
|
+
const IK_BP = this.xKeyRing.identityKeys.publicKey;
|
|
1508
|
+
const SPK_B = this.xKeyRing.preKeys.keyPair.secretKey;
|
|
1509
|
+
const OPK_B = otk ? otk.keyPair.secretKey : null;
|
|
1510
|
+
// diffie hellman functions
|
|
1511
|
+
const DH1 = xDH(SPK_B, IK_A);
|
|
1512
|
+
const DH2 = xDH(IK_B, EK_A);
|
|
1513
|
+
const DH3 = xDH(SPK_B, EK_A);
|
|
1514
|
+
const DH4 = OPK_B ? xDH(OPK_B, EK_A) : null;
|
|
1515
|
+
// initial key material
|
|
1516
|
+
const IKM = DH4
|
|
1517
|
+
? xConcat(DH1, DH2, DH3, DH4)
|
|
1518
|
+
: xConcat(DH1, DH2, DH3);
|
|
1519
|
+
// shared secret key
|
|
1520
|
+
const SK = xKDF(IKM);
|
|
1521
|
+
this.log.info("Obtained SK for " +
|
|
1522
|
+
mail.sender +
|
|
1523
|
+
", " +
|
|
1524
|
+
XUtils.encodeHex(SK));
|
|
1525
|
+
// shared public key
|
|
1526
|
+
const PK = xBoxKeyPairFromSecret(SK).publicKey;
|
|
1527
|
+
this.log.info(this.toString() +
|
|
1528
|
+
"Obtained PK for " +
|
|
1529
|
+
mail.sender +
|
|
1530
|
+
" " +
|
|
1531
|
+
XUtils.encodeHex(PK));
|
|
1532
|
+
const hmac = xHMAC(mail, SK);
|
|
1533
|
+
this.log.info("Mail hash: " + JSON.stringify(mail));
|
|
1534
|
+
this.log.info("Calculated hmac: " + XUtils.encodeHex(hmac));
|
|
1535
|
+
// associated data
|
|
1536
|
+
const AD = xConcat(xEncode(xConstants.CURVE, IK_A), xEncode(xConstants.CURVE, IK_BP));
|
|
1537
|
+
if (!XUtils.bytesEqual(hmac, header)) {
|
|
1538
|
+
console.warn("Mail authentication failed (HMAC did not match).");
|
|
1539
|
+
console.warn(mail);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
this.log.info("Mail authenticated successfully.");
|
|
1543
|
+
const unsealed = xSecretboxOpen(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), SK);
|
|
1544
|
+
if (unsealed) {
|
|
1545
|
+
this.log.info("Decryption successful.");
|
|
1546
|
+
let plaintext = "";
|
|
1547
|
+
if (!mail.forward) {
|
|
1548
|
+
plaintext = XUtils.encodeUTF8(unsealed);
|
|
1549
|
+
}
|
|
1550
|
+
// emit the message
|
|
1551
|
+
const fwdMsg1 = mail.forward
|
|
1552
|
+
? messageSchema.parse(msgpack.decode(unsealed))
|
|
1553
|
+
: null;
|
|
1554
|
+
const message = fwdMsg1
|
|
1555
|
+
? { ...fwdMsg1, forward: true }
|
|
1556
|
+
: {
|
|
1557
|
+
authorID: mail.authorID,
|
|
1558
|
+
decrypted: true,
|
|
1559
|
+
direction: "incoming",
|
|
1560
|
+
forward: mail.forward,
|
|
1561
|
+
group: mail.group
|
|
1562
|
+
? uuid.stringify(mail.group)
|
|
1563
|
+
: null,
|
|
1564
|
+
mailID: mail.mailID,
|
|
1565
|
+
message: plaintext,
|
|
1566
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
1567
|
+
readerID: mail.readerID,
|
|
1568
|
+
recipient: mail.recipient,
|
|
1569
|
+
sender: mail.sender,
|
|
1570
|
+
timestamp: timestamp,
|
|
1571
|
+
};
|
|
1572
|
+
this.emitter.emit("message", message);
|
|
1573
|
+
// discard onetimekey
|
|
1574
|
+
await this.database.deleteOneTimeKey(preKeyIndex);
|
|
1575
|
+
const deviceEntry = await this.getDeviceByID(mail.sender);
|
|
1576
|
+
if (!deviceEntry) {
|
|
1577
|
+
throw new Error("Couldn't get device entry.");
|
|
1578
|
+
}
|
|
1579
|
+
const [userEntry, _userErr] = await this.fetchUser(deviceEntry.owner);
|
|
1580
|
+
if (!userEntry) {
|
|
1581
|
+
throw new Error("Couldn't get user entry.");
|
|
1582
|
+
}
|
|
1583
|
+
this.userRecords[userEntry.userID] = userEntry;
|
|
1584
|
+
this.deviceRecords[deviceEntry.deviceID] = deviceEntry;
|
|
1585
|
+
// save session
|
|
1586
|
+
const newSession = {
|
|
1587
|
+
deviceID: mail.sender,
|
|
1588
|
+
fingerprint: XUtils.encodeHex(AD),
|
|
1589
|
+
lastUsed: new Date().toISOString(),
|
|
1590
|
+
mode: "receiver",
|
|
1591
|
+
publicKey: XUtils.encodeHex(PK),
|
|
1592
|
+
sessionID: uuid.v4(),
|
|
1593
|
+
SK: XUtils.encodeHex(SK),
|
|
1594
|
+
userID: userEntry.userID,
|
|
1595
|
+
verified: false,
|
|
1596
|
+
};
|
|
1597
|
+
await this.database.saveSession(newSession);
|
|
1598
|
+
const [user] = await this.fetchUser(newSession.userID);
|
|
1599
|
+
if (user) {
|
|
1600
|
+
this.emitter.emit("session", newSession, user);
|
|
1601
|
+
}
|
|
1602
|
+
else {
|
|
1603
|
+
this.log.warn("Couldn't retrieve user " + newSession.userID);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
else {
|
|
1607
|
+
this.log.warn("Mail decryption failed.");
|
|
1608
|
+
}
|
|
1609
|
+
break;
|
|
1610
|
+
case MailType.subsequent:
|
|
1611
|
+
const publicKey = Client.deserializeExtra(mail.mailType, new Uint8Array(mail.extra))[0];
|
|
1612
|
+
if (!publicKey) {
|
|
1613
|
+
throw new Error("Malformed subsequent mail extra: missing publicKey");
|
|
1614
|
+
}
|
|
1615
|
+
let session = await this.getSessionByPubkey(publicKey);
|
|
1616
|
+
let retries = 0;
|
|
1617
|
+
while (!session) {
|
|
1618
|
+
if (retries > 3) {
|
|
1619
|
+
break;
|
|
1620
|
+
}
|
|
1621
|
+
session = await this.getSessionByPubkey(publicKey);
|
|
1622
|
+
retries++;
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
if (!session) {
|
|
1626
|
+
this.log.warn("Couldn't find session public key " +
|
|
1627
|
+
XUtils.encodeHex(publicKey));
|
|
1628
|
+
void healSession();
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
this.log.info("Session found for " + mail.sender);
|
|
1632
|
+
this.log.info("Mail nonce " +
|
|
1633
|
+
XUtils.encodeHex(new Uint8Array(mail.nonce)));
|
|
1634
|
+
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
|
+
if (!XUtils.bytesEqual(HMAC, header)) {
|
|
1638
|
+
this.log.warn("Message authentication failed (HMAC does not match).");
|
|
1639
|
+
void healSession();
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
const decrypted = xSecretboxOpen(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), session.SK);
|
|
1643
|
+
if (decrypted) {
|
|
1644
|
+
this.log.info("Decryption successful.");
|
|
1645
|
+
// emit the message
|
|
1646
|
+
const fwdMsg2 = mail.forward
|
|
1647
|
+
? messageSchema.parse(msgpack.decode(decrypted))
|
|
1648
|
+
: null;
|
|
1649
|
+
const message = fwdMsg2
|
|
1650
|
+
? {
|
|
1651
|
+
...fwdMsg2,
|
|
1652
|
+
forward: true,
|
|
1653
|
+
}
|
|
1654
|
+
: {
|
|
1655
|
+
authorID: mail.authorID,
|
|
1656
|
+
decrypted: true,
|
|
1657
|
+
direction: "incoming",
|
|
1658
|
+
forward: mail.forward,
|
|
1659
|
+
group: mail.group
|
|
1660
|
+
? uuid.stringify(mail.group)
|
|
1661
|
+
: null,
|
|
1662
|
+
mailID: mail.mailID,
|
|
1663
|
+
message: XUtils.encodeUTF8(decrypted),
|
|
1664
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
1665
|
+
readerID: mail.readerID,
|
|
1666
|
+
recipient: mail.recipient,
|
|
1667
|
+
sender: mail.sender,
|
|
1668
|
+
timestamp: timestamp,
|
|
1669
|
+
};
|
|
1670
|
+
this.emitter.emit("message", message);
|
|
1671
|
+
void this.database.markSessionUsed(session.sessionID);
|
|
1672
|
+
}
|
|
1673
|
+
else {
|
|
1674
|
+
this.log.info("Decryption failed.");
|
|
1675
|
+
void healSession();
|
|
1676
|
+
// emit the message
|
|
1677
|
+
const message = {
|
|
1678
|
+
authorID: mail.authorID,
|
|
1679
|
+
decrypted: false,
|
|
1680
|
+
direction: "incoming",
|
|
1681
|
+
forward: mail.forward,
|
|
1682
|
+
group: mail.group
|
|
1683
|
+
? uuid.stringify(mail.group)
|
|
1684
|
+
: null,
|
|
1685
|
+
mailID: mail.mailID,
|
|
1686
|
+
message: "",
|
|
1687
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
1688
|
+
readerID: mail.readerID,
|
|
1689
|
+
recipient: mail.recipient,
|
|
1690
|
+
sender: mail.sender,
|
|
1691
|
+
timestamp: timestamp,
|
|
1692
|
+
};
|
|
1693
|
+
this.emitter.emit("message", message);
|
|
1694
|
+
}
|
|
1695
|
+
break;
|
|
1696
|
+
default:
|
|
1697
|
+
this.log.warn("Unsupported MailType:", mail.mailType);
|
|
1698
|
+
break;
|
|
1699
|
+
}
|
|
1942
1700
|
}
|
|
1943
|
-
|
|
1944
|
-
this.
|
|
1701
|
+
finally {
|
|
1702
|
+
this.reading = false;
|
|
1945
1703
|
}
|
|
1946
|
-
|
|
1704
|
+
}
|
|
1705
|
+
async redeemInvite(inviteID) {
|
|
1706
|
+
const res = await this.http.patch(this.getHost() + "/invite/" + inviteID);
|
|
1707
|
+
return decodeAxios(PermissionCodec, res.data);
|
|
1708
|
+
}
|
|
1709
|
+
async registerDevice() {
|
|
1710
|
+
while (!this.xKeyRing) {
|
|
1711
|
+
await sleep(100);
|
|
1712
|
+
}
|
|
1713
|
+
const token = await this.getToken("device");
|
|
1714
|
+
const username = this.user?.username;
|
|
1715
|
+
if (!username) {
|
|
1716
|
+
throw new Error("No user set — log in first.");
|
|
1717
|
+
}
|
|
1718
|
+
const [userDetails, err] = await this.fetchUser(username);
|
|
1719
|
+
if (!userDetails) {
|
|
1720
|
+
throw new Error("Username not found " + username);
|
|
1721
|
+
}
|
|
1722
|
+
if (err) {
|
|
1723
|
+
throw err;
|
|
1724
|
+
}
|
|
1725
|
+
if (!token) {
|
|
1726
|
+
throw new Error("Couldn't fetch token.");
|
|
1727
|
+
}
|
|
1728
|
+
const signKey = this.getKeys().public;
|
|
1729
|
+
const signed = XUtils.encodeHex(xSign(Uint8Array.from(uuid.parse(token.key)), this.signKeys.secretKey));
|
|
1730
|
+
const devPreKeyIndex = this.xKeyRing.preKeys.index;
|
|
1731
|
+
const devMsg = {
|
|
1732
|
+
deviceName: this.options?.deviceName ?? "unknown",
|
|
1733
|
+
preKey: XUtils.encodeHex(this.xKeyRing.preKeys.keyPair.publicKey),
|
|
1734
|
+
preKeyIndex: devPreKeyIndex,
|
|
1735
|
+
preKeySignature: XUtils.encodeHex(this.xKeyRing.preKeys.signature),
|
|
1736
|
+
signed,
|
|
1737
|
+
signKey,
|
|
1738
|
+
username: userDetails.username,
|
|
1739
|
+
};
|
|
1740
|
+
const res = await this.http.post(this.prefixes.HTTP +
|
|
1741
|
+
this.host +
|
|
1742
|
+
"/user/" +
|
|
1743
|
+
userDetails.userID +
|
|
1744
|
+
"/devices", msgpack.encode(devMsg), { headers: { "Content-Type": "application/msgpack" } });
|
|
1745
|
+
return decodeAxios(DeviceCodec, res.data);
|
|
1746
|
+
}
|
|
1747
|
+
respond(msg) {
|
|
1748
|
+
const response = {
|
|
1749
|
+
signed: xSign(new Uint8Array(msg.challenge), this.signKeys.secretKey),
|
|
1750
|
+
transmissionID: msg.transmissionID,
|
|
1751
|
+
type: "response",
|
|
1752
|
+
};
|
|
1753
|
+
void this.send(response);
|
|
1754
|
+
}
|
|
1755
|
+
async retrieveEmojiByID(emojiID) {
|
|
1756
|
+
const res = await this.http.get(this.getHost() + "/emoji/" + emojiID + "/details");
|
|
1757
|
+
if (!res.data) {
|
|
1758
|
+
return null;
|
|
1759
|
+
}
|
|
1760
|
+
return decodeAxios(EmojiCodec, res.data);
|
|
1761
|
+
}
|
|
1762
|
+
async retrieveEmojiList(serverID) {
|
|
1763
|
+
const res = await this.http.get(this.getHost() + "/server/" + serverID + "/emoji");
|
|
1764
|
+
return decodeAxios(EmojiArrayCodec, res.data);
|
|
1765
|
+
}
|
|
1766
|
+
async retrieveFile(fileID, key) {
|
|
1767
|
+
const detailsRes = await this.http.get(this.getHost() + "/file/" + fileID + "/details");
|
|
1768
|
+
const details = decodeAxios(FileSQLCodec, detailsRes.data);
|
|
1769
|
+
const res = await this.http.get(this.getHost() + "/file/" + fileID, {
|
|
1770
|
+
onDownloadProgress: (progressEvent) => {
|
|
1771
|
+
const percentCompleted = Math.round((progressEvent.loaded * 100) /
|
|
1772
|
+
(progressEvent.total ?? 1));
|
|
1773
|
+
const { loaded, total = 0 } = progressEvent;
|
|
1774
|
+
const progress = {
|
|
1775
|
+
direction: "download",
|
|
1776
|
+
loaded,
|
|
1777
|
+
progress: percentCompleted,
|
|
1778
|
+
token: fileID,
|
|
1779
|
+
total,
|
|
1780
|
+
};
|
|
1781
|
+
this.emitter.emit("fileProgress", progress);
|
|
1782
|
+
},
|
|
1783
|
+
});
|
|
1784
|
+
const fileData = res.data;
|
|
1785
|
+
const decrypted = xSecretboxOpen(new Uint8Array(fileData), XUtils.decodeHex(details.nonce), XUtils.decodeHex(key));
|
|
1786
|
+
if (decrypted) {
|
|
1787
|
+
return {
|
|
1788
|
+
data: new Uint8Array(decrypted),
|
|
1789
|
+
details,
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
throw new Error("Decryption failed.");
|
|
1793
|
+
}
|
|
1794
|
+
async retrieveInvites(serverID) {
|
|
1795
|
+
const res = await this.http.get(this.getHost() + "/server/" + serverID + "/invites");
|
|
1796
|
+
return decodeAxios(InviteArrayCodec, res.data);
|
|
1797
|
+
}
|
|
1798
|
+
async retrieveKeyBundle(deviceID) {
|
|
1799
|
+
const res = await this.http.post(this.getHost() + "/device/" + deviceID + "/keyBundle");
|
|
1800
|
+
return decodeAxios(KeyBundleCodec, res.data);
|
|
1801
|
+
}
|
|
1802
|
+
async retrieveOrCreateDevice() {
|
|
1803
|
+
let device;
|
|
1947
1804
|
try {
|
|
1948
|
-
const res = await this.
|
|
1805
|
+
const res = await this.http.get(this.prefixes.HTTP +
|
|
1806
|
+
this.host +
|
|
1949
1807
|
"/device/" +
|
|
1950
|
-
this.
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1808
|
+
XUtils.encodeHex(this.signKeys.publicKey));
|
|
1809
|
+
device = decodeAxios(DeviceCodec, res.data);
|
|
1810
|
+
}
|
|
1811
|
+
catch (err) {
|
|
1812
|
+
this.log.error(err instanceof Error ? err.message : String(err));
|
|
1813
|
+
if (isAxiosError(err) && err.response?.status === 404) {
|
|
1814
|
+
// just in case
|
|
1815
|
+
await this.database.purgeKeyData();
|
|
1816
|
+
await this.populateKeyRing();
|
|
1817
|
+
this.log.info("Attempting to register device.");
|
|
1818
|
+
const newDevice = await this.registerDevice();
|
|
1819
|
+
if (newDevice) {
|
|
1820
|
+
device = newDevice;
|
|
1959
1821
|
}
|
|
1960
|
-
|
|
1961
|
-
|
|
1822
|
+
else {
|
|
1823
|
+
throw new Error("Error registering device.");
|
|
1962
1824
|
}
|
|
1963
1825
|
}
|
|
1826
|
+
else {
|
|
1827
|
+
throw err;
|
|
1828
|
+
}
|
|
1964
1829
|
}
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
}
|
|
1968
|
-
this.fetchingMail = false;
|
|
1830
|
+
this.log.info("Got device " + JSON.stringify(device, null, 4));
|
|
1831
|
+
return device;
|
|
1969
1832
|
}
|
|
1970
1833
|
/* header is 32 bytes and is either empty
|
|
1971
1834
|
or contains an HMAC of the message with
|
|
1972
1835
|
a derived SK */
|
|
1973
1836
|
async send(msg, header) {
|
|
1974
1837
|
let i = 0;
|
|
1975
|
-
while (this.
|
|
1838
|
+
while (this.socket.readyState !== 1) {
|
|
1976
1839
|
await sleep(i);
|
|
1977
1840
|
i *= 2;
|
|
1978
1841
|
}
|
|
1979
|
-
this.log.debug(
|
|
1980
|
-
|
|
1981
|
-
this.
|
|
1982
|
-
this.conn.send(XUtils.packMessage(msg, header));
|
|
1842
|
+
this.log.debug("OUTH " + XUtils.encodeHex(header || XUtils.emptyHeader()));
|
|
1843
|
+
this.log.debug("OUT " + JSON.stringify(msg, null, 4));
|
|
1844
|
+
this.socket.send(XUtils.packMessage(msg, header));
|
|
1983
1845
|
}
|
|
1984
|
-
async
|
|
1985
|
-
const
|
|
1986
|
-
|
|
1846
|
+
async sendGroupMessage(channelID, message) {
|
|
1847
|
+
const userList = await this.getUserList(channelID);
|
|
1848
|
+
for (const user of userList) {
|
|
1849
|
+
this.userRecords[user.userID] = user;
|
|
1850
|
+
}
|
|
1851
|
+
this.log.info("Sending to userlist:\n" + JSON.stringify(userList, null, 4));
|
|
1852
|
+
const mailID = uuid.v4();
|
|
1853
|
+
const promises = [];
|
|
1854
|
+
const userIDs = [...new Set(userList.map((user) => user.userID))];
|
|
1855
|
+
const devices = await this.getMultiUserDeviceList(userIDs);
|
|
1856
|
+
this.log.info("Retrieved devicelist:\n" + JSON.stringify(devices, null, 4));
|
|
1857
|
+
for (const device of devices) {
|
|
1858
|
+
const ownerRecord = this.userRecords[device.owner];
|
|
1859
|
+
if (!ownerRecord) {
|
|
1860
|
+
this.log.warn("Skipping device " +
|
|
1861
|
+
device.deviceID +
|
|
1862
|
+
": no user record for owner " +
|
|
1863
|
+
device.owner);
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
promises.push(this.sendMail(device, ownerRecord, XUtils.decodeUTF8(message), uuidToUint8(channelID), mailID, false));
|
|
1867
|
+
}
|
|
1868
|
+
void Promise.allSettled(promises).then((results) => {
|
|
1869
|
+
for (const result of results) {
|
|
1870
|
+
const { status } = result;
|
|
1871
|
+
if (status === "rejected") {
|
|
1872
|
+
this.log.warn("Message failed.");
|
|
1873
|
+
this.log.warn(JSON.stringify(result));
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1987
1877
|
}
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
this.
|
|
1992
|
-
|
|
1993
|
-
|
|
1878
|
+
/* Sends encrypted mail to a user. */
|
|
1879
|
+
async sendMail(device, user, msg, group, mailID, forward, retry = false) {
|
|
1880
|
+
while (this.sending.has(device.deviceID)) {
|
|
1881
|
+
this.log.warn("Sending in progress to device ID " +
|
|
1882
|
+
device.deviceID +
|
|
1883
|
+
", waiting.");
|
|
1884
|
+
await sleep(100);
|
|
1885
|
+
}
|
|
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
|
+
this.sending.set(device.deviceID, device);
|
|
1890
|
+
const session = await this.database.getSessionByDeviceID(device.deviceID);
|
|
1891
|
+
if (!session || retry) {
|
|
1892
|
+
this.log.info("Creating new session for " + device.deviceID);
|
|
1893
|
+
await this.createSession(device, user, msg, group, mailID, forward);
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
else {
|
|
1897
|
+
this.log.info("Found existing session for " + device.deviceID);
|
|
1898
|
+
}
|
|
1899
|
+
const nonce = xMakeNonce();
|
|
1900
|
+
const cipher = xSecretbox(msg, nonce, session.SK);
|
|
1901
|
+
const extra = session.publicKey;
|
|
1902
|
+
const mail = {
|
|
1903
|
+
authorID: this.getUser().userID,
|
|
1904
|
+
cipher,
|
|
1905
|
+
extra,
|
|
1906
|
+
forward,
|
|
1907
|
+
group,
|
|
1908
|
+
mailID: mailID || uuid.v4(),
|
|
1909
|
+
mailType: MailType.subsequent,
|
|
1910
|
+
nonce,
|
|
1911
|
+
readerID: session.userID,
|
|
1912
|
+
recipient: device.deviceID,
|
|
1913
|
+
sender: this.getDevice().deviceID,
|
|
1914
|
+
};
|
|
1915
|
+
const msgb = {
|
|
1916
|
+
action: "CREATE",
|
|
1917
|
+
data: mail,
|
|
1918
|
+
resourceType: "mail",
|
|
1919
|
+
transmissionID: uuid.v4(),
|
|
1920
|
+
type: "resource",
|
|
1921
|
+
};
|
|
1922
|
+
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
|
+
const fwdOut = forward
|
|
1926
|
+
? messageSchema.parse(msgpack.decode(msg))
|
|
1927
|
+
: null;
|
|
1928
|
+
const outMsg = fwdOut
|
|
1929
|
+
? { ...fwdOut, forward: true }
|
|
1930
|
+
: {
|
|
1931
|
+
authorID: mail.authorID,
|
|
1932
|
+
decrypted: true,
|
|
1933
|
+
direction: "outgoing",
|
|
1934
|
+
forward: mail.forward,
|
|
1935
|
+
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
1936
|
+
mailID: mail.mailID,
|
|
1937
|
+
message: XUtils.encodeUTF8(msg),
|
|
1938
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
1939
|
+
readerID: mail.readerID,
|
|
1940
|
+
recipient: mail.recipient,
|
|
1941
|
+
sender: mail.sender,
|
|
1942
|
+
timestamp: new Date().toISOString(),
|
|
1943
|
+
};
|
|
1944
|
+
this.emitter.emit("message", outMsg);
|
|
1945
|
+
await new Promise((res, rej) => {
|
|
1946
|
+
const callback = (packedMsg) => {
|
|
1947
|
+
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
1948
|
+
if (receivedMsg.transmissionID === msgb.transmissionID) {
|
|
1949
|
+
this.socket.off("message", callback);
|
|
1950
|
+
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
1951
|
+
if (parsed.success && parsed.data.type === "success") {
|
|
1952
|
+
res(parsed.data.data);
|
|
1953
|
+
}
|
|
1954
|
+
else {
|
|
1955
|
+
rej(new Error("Mail delivery failed: " +
|
|
1956
|
+
JSON.stringify(receivedMsg)));
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1960
|
+
this.socket.on("message", callback);
|
|
1961
|
+
void this.send(msgb, hmac);
|
|
1962
|
+
});
|
|
1963
|
+
this.sending.delete(device.deviceID);
|
|
1964
|
+
}
|
|
1965
|
+
async sendMessage(userID, message) {
|
|
1966
|
+
try {
|
|
1967
|
+
const [userEntry, err] = await this.fetchUser(userID);
|
|
1968
|
+
if (err) {
|
|
1969
|
+
throw err;
|
|
1970
|
+
}
|
|
1971
|
+
if (!userEntry) {
|
|
1972
|
+
throw new Error("Couldn't get user entry.");
|
|
1973
|
+
}
|
|
1974
|
+
let deviceList = await this.getUserDeviceList(userID);
|
|
1975
|
+
if (!deviceList) {
|
|
1976
|
+
let retries = 0;
|
|
1977
|
+
while (!deviceList) {
|
|
1978
|
+
deviceList = await this.getUserDeviceList(userID);
|
|
1979
|
+
retries++;
|
|
1980
|
+
if (retries > 3) {
|
|
1981
|
+
throw new Error("Couldn't get device list.");
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
const mailID = uuid.v4();
|
|
1986
|
+
const promises = [];
|
|
1987
|
+
for (const device of deviceList) {
|
|
1988
|
+
promises.push(this.sendMail(device, userEntry, XUtils.decodeUTF8(message), null, mailID, false));
|
|
1989
|
+
}
|
|
1990
|
+
void Promise.allSettled(promises).then((results) => {
|
|
1991
|
+
for (const result of results) {
|
|
1992
|
+
const { status } = result;
|
|
1993
|
+
if (status === "rejected") {
|
|
1994
|
+
this.log.warn("Message failed.");
|
|
1995
|
+
this.log.warn(JSON.stringify(result));
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
catch (err) {
|
|
2001
|
+
this.log.error("Message threw exception.");
|
|
2002
|
+
this.log.error(err instanceof Error ? err.message : String(err));
|
|
2003
|
+
throw err;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
sendReceipt(nonce) {
|
|
2007
|
+
const receipt = {
|
|
2008
|
+
nonce,
|
|
2009
|
+
transmissionID: uuid.v4(),
|
|
2010
|
+
type: "receipt",
|
|
2011
|
+
};
|
|
2012
|
+
void this.send(receipt);
|
|
2013
|
+
}
|
|
2014
|
+
setAlive(status) {
|
|
2015
|
+
this.isAlive = status;
|
|
2016
|
+
}
|
|
2017
|
+
setUser(user) {
|
|
2018
|
+
this.user = user;
|
|
1994
2019
|
}
|
|
1995
2020
|
async submitOTK(amount) {
|
|
1996
2021
|
const otks = [];
|
|
@@ -1999,50 +2024,89 @@ export class Client extends EventEmitter {
|
|
|
1999
2024
|
otks[i] = this.createPreKey();
|
|
2000
2025
|
}
|
|
2001
2026
|
const t1 = performance.now();
|
|
2002
|
-
this.log.info("Generated " +
|
|
2027
|
+
this.log.info("Generated " +
|
|
2028
|
+
String(amount) +
|
|
2029
|
+
" one time keys in " +
|
|
2030
|
+
String(t1 - t0) +
|
|
2031
|
+
" ms.");
|
|
2003
2032
|
const savedKeys = await this.database.savePreKeys(otks, true);
|
|
2004
|
-
await this.
|
|
2033
|
+
await this.http.post(this.getHost() + "/device/" + this.getDevice().deviceID + "/otk", msgpack.encode(savedKeys.map((key) => this.censorPreKey(key))), {
|
|
2005
2034
|
headers: { "Content-Type": "application/msgpack" },
|
|
2006
2035
|
});
|
|
2007
2036
|
}
|
|
2008
|
-
async
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2037
|
+
async uploadAvatar(avatar) {
|
|
2038
|
+
if (typeof FormData !== "undefined") {
|
|
2039
|
+
const fpayload = new FormData();
|
|
2040
|
+
fpayload.set("avatar", new Blob([new Uint8Array(avatar)]));
|
|
2041
|
+
await this.http.post(this.prefixes.HTTP +
|
|
2042
|
+
this.host +
|
|
2043
|
+
"/avatar/" +
|
|
2044
|
+
this.me.user().userID, fpayload, {
|
|
2045
|
+
headers: { "Content-Type": "multipart/form-data" },
|
|
2046
|
+
onUploadProgress: (progressEvent) => {
|
|
2047
|
+
const percentCompleted = Math.round((progressEvent.loaded * 100) /
|
|
2048
|
+
(progressEvent.total ?? 1));
|
|
2049
|
+
const { loaded, total = 0 } = progressEvent;
|
|
2050
|
+
const progress = {
|
|
2051
|
+
direction: "upload",
|
|
2052
|
+
loaded,
|
|
2053
|
+
progress: percentCompleted,
|
|
2054
|
+
token: this.getUser().userID,
|
|
2055
|
+
total,
|
|
2056
|
+
};
|
|
2057
|
+
this.emitter.emit("fileProgress", progress);
|
|
2058
|
+
},
|
|
2059
|
+
});
|
|
2014
2060
|
return;
|
|
2015
2061
|
}
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
respond(msg) {
|
|
2019
|
-
const response = {
|
|
2020
|
-
transmissionID: msg.transmissionID,
|
|
2021
|
-
type: "response",
|
|
2022
|
-
signed: nacl.sign(msg.challenge, this.signKeys.secretKey),
|
|
2062
|
+
const payload = {
|
|
2063
|
+
file: XUtils.encodeBase64(avatar),
|
|
2023
2064
|
};
|
|
2024
|
-
this.
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
async ping() {
|
|
2030
|
-
if (!this.isAlive) {
|
|
2031
|
-
this.log.warn("Ping failed.");
|
|
2032
|
-
}
|
|
2033
|
-
this.setAlive(false);
|
|
2034
|
-
this.send({ transmissionID: uuid.v4(), type: "ping" });
|
|
2065
|
+
await this.http.post(this.prefixes.HTTP +
|
|
2066
|
+
this.host +
|
|
2067
|
+
"/avatar/" +
|
|
2068
|
+
this.me.user().userID +
|
|
2069
|
+
"/json", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
|
|
2035
2070
|
}
|
|
2036
|
-
|
|
2037
|
-
if (
|
|
2038
|
-
|
|
2071
|
+
async uploadEmoji(emoji, name, serverID) {
|
|
2072
|
+
if (typeof FormData !== "undefined") {
|
|
2073
|
+
const fpayload = new FormData();
|
|
2074
|
+
fpayload.set("emoji", new Blob([new Uint8Array(emoji)]));
|
|
2075
|
+
fpayload.set("name", name);
|
|
2076
|
+
try {
|
|
2077
|
+
const res = await this.http.post(this.getHost() + "/emoji/" + serverID, fpayload, {
|
|
2078
|
+
headers: { "Content-Type": "multipart/form-data" },
|
|
2079
|
+
onUploadProgress: (progressEvent) => {
|
|
2080
|
+
const percentCompleted = Math.round((progressEvent.loaded * 100) /
|
|
2081
|
+
(progressEvent.total ?? 1));
|
|
2082
|
+
const { loaded, total = 0 } = progressEvent;
|
|
2083
|
+
const progress = {
|
|
2084
|
+
direction: "upload",
|
|
2085
|
+
loaded,
|
|
2086
|
+
progress: percentCompleted,
|
|
2087
|
+
token: name,
|
|
2088
|
+
total,
|
|
2089
|
+
};
|
|
2090
|
+
this.emitter.emit("fileProgress", progress);
|
|
2091
|
+
},
|
|
2092
|
+
});
|
|
2093
|
+
return decodeAxios(EmojiCodec, res.data);
|
|
2094
|
+
}
|
|
2095
|
+
catch (_err) {
|
|
2096
|
+
return null;
|
|
2097
|
+
}
|
|
2039
2098
|
}
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
index: preKey.index,
|
|
2044
|
-
deviceID: this.getDevice().deviceID,
|
|
2099
|
+
const payload = {
|
|
2100
|
+
file: XUtils.encodeBase64(emoji),
|
|
2101
|
+
name,
|
|
2045
2102
|
};
|
|
2103
|
+
try {
|
|
2104
|
+
const res = await this.http.post(this.getHost() + "/emoji/" + serverID + "/json", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
|
|
2105
|
+
return decodeAxios(EmojiCodec, res.data);
|
|
2106
|
+
}
|
|
2107
|
+
catch (_err) {
|
|
2108
|
+
return null;
|
|
2109
|
+
}
|
|
2046
2110
|
}
|
|
2047
2111
|
}
|
|
2048
2112
|
//# sourceMappingURL=Client.js.map
|