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