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