@vex-chat/libvex 1.0.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -41
- package/dist/Client.d.ts +449 -554
- package/dist/Client.d.ts.map +1 -0
- package/dist/Client.js +1542 -1484
- package/dist/Client.js.map +1 -1
- package/dist/Storage.d.ts +111 -0
- package/dist/Storage.d.ts.map +1 -0
- package/dist/Storage.js +2 -0
- package/dist/Storage.js.map +1 -0
- package/dist/__tests__/harness/memory-storage.d.ts +29 -27
- package/dist/__tests__/harness/memory-storage.d.ts.map +1 -0
- package/dist/__tests__/harness/memory-storage.js +120 -109
- package/dist/__tests__/harness/memory-storage.js.map +1 -1
- package/dist/codec.d.ts +44 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +51 -0
- package/dist/codec.js.map +1 -0
- package/dist/codecs.d.ts +201 -0
- package/dist/codecs.d.ts.map +1 -0
- package/dist/codecs.js +67 -0
- package/dist/codecs.js.map +1 -0
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/keystore/memory.d.ts +5 -4
- package/dist/keystore/memory.d.ts.map +1 -0
- package/dist/keystore/memory.js +9 -7
- package/dist/keystore/memory.js.map +1 -1
- package/dist/keystore/node.d.ts +6 -5
- package/dist/keystore/node.d.ts.map +1 -0
- package/dist/keystore/node.js +38 -19
- package/dist/keystore/node.js.map +1 -1
- package/dist/preset/common.d.ts +9 -0
- package/dist/preset/common.d.ts.map +1 -0
- package/dist/preset/common.js +2 -0
- package/dist/preset/common.js.map +1 -0
- package/dist/preset/node.d.ts +3 -5
- package/dist/preset/node.d.ts.map +1 -0
- package/dist/preset/node.js +5 -7
- package/dist/preset/node.js.map +1 -1
- package/dist/preset/test.d.ts +4 -4
- package/dist/preset/test.d.ts.map +1 -0
- package/dist/preset/test.js +8 -10
- package/dist/preset/test.js.map +1 -1
- package/dist/storage/node.d.ts +4 -3
- package/dist/storage/node.d.ts.map +1 -0
- package/dist/storage/node.js +4 -4
- package/dist/storage/node.js.map +1 -1
- package/dist/storage/schema.d.ts +55 -57
- package/dist/storage/schema.d.ts.map +1 -0
- package/dist/storage/sqlite.d.ts +33 -28
- package/dist/storage/sqlite.d.ts.map +1 -0
- package/dist/storage/sqlite.js +330 -290
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/transport/types.d.ts +23 -16
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/websocket.d.ts +26 -0
- package/dist/transport/websocket.d.ts.map +1 -0
- package/dist/transport/websocket.js +83 -0
- package/dist/transport/websocket.js.map +1 -0
- package/dist/types/crypto.d.ts +35 -0
- package/dist/types/crypto.d.ts.map +1 -0
- package/dist/types/crypto.js +9 -0
- package/dist/types/crypto.js.map +1 -0
- package/dist/types/identity.d.ts +17 -0
- package/dist/types/identity.d.ts.map +1 -0
- package/dist/types/identity.js +6 -0
- package/dist/types/identity.js.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/capitalize.d.ts +1 -0
- package/dist/utils/capitalize.d.ts.map +1 -0
- package/dist/utils/createLogger.d.ts +1 -0
- package/dist/utils/createLogger.d.ts.map +1 -0
- package/dist/utils/createLogger.js +4 -11
- package/dist/utils/createLogger.js.map +1 -1
- package/dist/utils/formatBytes.d.ts +1 -0
- package/dist/utils/formatBytes.d.ts.map +1 -0
- package/dist/utils/formatBytes.js +3 -1
- package/dist/utils/formatBytes.js.map +1 -1
- package/dist/utils/sqlSessionToCrypto.d.ts +4 -2
- package/dist/utils/sqlSessionToCrypto.d.ts.map +1 -0
- package/dist/utils/sqlSessionToCrypto.js +5 -5
- package/dist/utils/sqlSessionToCrypto.js.map +1 -1
- package/dist/utils/uint8uuid.d.ts +1 -4
- package/dist/utils/uint8uuid.d.ts.map +1 -0
- package/dist/utils/uint8uuid.js +1 -7
- package/dist/utils/uint8uuid.js.map +1 -1
- package/package.json +58 -87
- package/src/Client.ts +3304 -0
- package/{dist/IStorage.d.ts → src/Storage.ts} +70 -62
- package/src/__tests__/codec.test.ts +251 -0
- package/src/__tests__/ghost.png +0 -0
- package/src/__tests__/harness/fixtures.ts +22 -0
- package/src/__tests__/harness/memory-storage.ts +254 -0
- package/src/__tests__/harness/platform-transports.ts +17 -0
- package/src/__tests__/harness/poison-node-imports.ts +108 -0
- package/src/__tests__/harness/shared-suite.ts +446 -0
- package/src/__tests__/platform-browser.test.ts +19 -0
- package/src/__tests__/platform-node.test.ts +10 -0
- package/src/__tests__/triggered.png +0 -0
- package/src/codec.ts +68 -0
- package/src/codecs.ts +101 -0
- package/src/index.ts +33 -0
- package/src/keystore/memory.ts +30 -0
- package/src/keystore/node.ts +91 -0
- package/src/preset/common.ts +13 -0
- package/src/preset/node.ts +34 -0
- package/src/preset/test.ts +37 -0
- package/src/storage/node.ts +33 -0
- package/src/storage/schema.ts +94 -0
- package/src/storage/sqlite.ts +676 -0
- package/src/transport/types.ts +29 -0
- package/src/transport/websocket.ts +106 -0
- package/src/types/crypto.ts +39 -0
- package/src/types/identity.ts +18 -0
- package/src/types/index.ts +9 -0
- package/src/utils/capitalize.ts +6 -0
- package/src/utils/createLogger.ts +37 -0
- package/src/utils/formatBytes.ts +15 -0
- package/src/utils/sqlSessionToCrypto.ts +16 -0
- package/src/utils/uint8uuid.ts +7 -0
- package/dist/IStorage.js +0 -2
- package/dist/IStorage.js.map +0 -1
- package/dist/keystore/types.d.ts +0 -4
- package/dist/keystore/types.js +0 -2
- package/dist/keystore/types.js.map +0 -1
- package/dist/preset/expo.d.ts +0 -2
- package/dist/preset/expo.js +0 -37
- package/dist/preset/expo.js.map +0 -1
- package/dist/preset/tauri.d.ts +0 -2
- package/dist/preset/tauri.js +0 -35
- package/dist/preset/tauri.js.map +0 -1
- package/dist/preset/types.d.ts +0 -13
- package/dist/preset/types.js +0 -2
- package/dist/preset/types.js.map +0 -1
- package/dist/storage/expo.d.ts +0 -3
- package/dist/storage/expo.js +0 -18
- package/dist/storage/expo.js.map +0 -1
- package/dist/storage/tauri.d.ts +0 -3
- package/dist/storage/tauri.js +0 -21
- package/dist/storage/tauri.js.map +0 -1
- package/dist/transport/browser.d.ts +0 -17
- package/dist/transport/browser.js +0 -56
- package/dist/transport/browser.js.map +0 -1
- package/dist/utils/constants.d.ts +0 -8
- package/dist/utils/constants.js +0 -9
- package/dist/utils/constants.js.map +0 -1
package/src/Client.ts
ADDED
|
@@ -0,0 +1,3304 @@
|
|
|
1
|
+
import type { Storage } from "./Storage.js";
|
|
2
|
+
import type { Logger, WebSocketLike } from "./transport/types.js";
|
|
3
|
+
import type {
|
|
4
|
+
PreKeysCrypto,
|
|
5
|
+
SessionCrypto,
|
|
6
|
+
UnsavedPreKey,
|
|
7
|
+
XKeyRing,
|
|
8
|
+
} from "./types/index.js";
|
|
9
|
+
import type { KeyPair } from "@vex-chat/crypto";
|
|
10
|
+
import type {
|
|
11
|
+
ActionToken,
|
|
12
|
+
ChallMsg,
|
|
13
|
+
Channel,
|
|
14
|
+
Device,
|
|
15
|
+
DevicePayload,
|
|
16
|
+
Emoji,
|
|
17
|
+
FileResponse,
|
|
18
|
+
FileSQL,
|
|
19
|
+
Invite,
|
|
20
|
+
KeyBundle,
|
|
21
|
+
MailWS,
|
|
22
|
+
NotifyMsg,
|
|
23
|
+
Permission,
|
|
24
|
+
PreKeysSQL,
|
|
25
|
+
PreKeysWS,
|
|
26
|
+
ReceiptMsg,
|
|
27
|
+
RegistrationPayload,
|
|
28
|
+
ResourceMsg,
|
|
29
|
+
RespMsg,
|
|
30
|
+
Server,
|
|
31
|
+
SessionSQL,
|
|
32
|
+
} from "@vex-chat/types";
|
|
33
|
+
import type { ClientMessage } from "@vex-chat/types";
|
|
34
|
+
import type { AxiosInstance } from "axios";
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
xBoxKeyPair,
|
|
38
|
+
xBoxKeyPairFromSecret,
|
|
39
|
+
xConcat,
|
|
40
|
+
xConstants,
|
|
41
|
+
xDH,
|
|
42
|
+
xEncode,
|
|
43
|
+
xHMAC,
|
|
44
|
+
xKDF,
|
|
45
|
+
XKeyConvert,
|
|
46
|
+
xMakeNonce,
|
|
47
|
+
xMnemonic,
|
|
48
|
+
xRandomBytes,
|
|
49
|
+
xSecretbox,
|
|
50
|
+
xSecretboxOpen,
|
|
51
|
+
xSign,
|
|
52
|
+
xSignKeyPair,
|
|
53
|
+
xSignKeyPairFromSecret,
|
|
54
|
+
XUtils,
|
|
55
|
+
} from "@vex-chat/crypto";
|
|
56
|
+
import {
|
|
57
|
+
MailType,
|
|
58
|
+
MailWSSchema,
|
|
59
|
+
PermissionSchema,
|
|
60
|
+
WSMessageSchema,
|
|
61
|
+
} from "@vex-chat/types";
|
|
62
|
+
|
|
63
|
+
import axios, { type AxiosError, isAxiosError } from "axios";
|
|
64
|
+
import { EventEmitter } from "eventemitter3";
|
|
65
|
+
import * as uuid from "uuid";
|
|
66
|
+
import { z } from "zod/v4";
|
|
67
|
+
|
|
68
|
+
import { WebSocketAdapter } from "./transport/websocket.js";
|
|
69
|
+
|
|
70
|
+
function sleep(ms: number): Promise<void> {
|
|
71
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
import { msgpack } from "./codec.js";
|
|
75
|
+
import {
|
|
76
|
+
ActionTokenCodec,
|
|
77
|
+
AuthResponseCodec,
|
|
78
|
+
ChannelArrayCodec,
|
|
79
|
+
ChannelCodec,
|
|
80
|
+
ConnectResponseCodec,
|
|
81
|
+
decodeAxios,
|
|
82
|
+
DeviceArrayCodec,
|
|
83
|
+
DeviceChallengeCodec,
|
|
84
|
+
DeviceCodec,
|
|
85
|
+
EmojiArrayCodec,
|
|
86
|
+
EmojiCodec,
|
|
87
|
+
FileSQLCodec,
|
|
88
|
+
InviteArrayCodec,
|
|
89
|
+
InviteCodec,
|
|
90
|
+
KeyBundleCodec,
|
|
91
|
+
OtkCountCodec,
|
|
92
|
+
PermissionArrayCodec,
|
|
93
|
+
PermissionCodec,
|
|
94
|
+
ServerArrayCodec,
|
|
95
|
+
ServerCodec,
|
|
96
|
+
UserArrayCodec,
|
|
97
|
+
UserCodec,
|
|
98
|
+
WhoamiCodec,
|
|
99
|
+
} from "./codecs.js";
|
|
100
|
+
import { capitalize } from "./utils/capitalize.js";
|
|
101
|
+
import { formatBytes } from "./utils/formatBytes.js";
|
|
102
|
+
import { sqlSessionToCrypto } from "./utils/sqlSessionToCrypto.js";
|
|
103
|
+
import { uuidToUint8 } from "./utils/uint8uuid.js";
|
|
104
|
+
|
|
105
|
+
const _protocolMsgRegex = /��\w+:\w+��/g;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Permission is a permission to a resource.
|
|
109
|
+
*
|
|
110
|
+
* Common fields:
|
|
111
|
+
* - `permissionID`: unique permission row ID
|
|
112
|
+
* - `userID`: user receiving this grant
|
|
113
|
+
* - `resourceID`: target server/channel/etc.
|
|
114
|
+
* - `resourceType`: type string for the resource
|
|
115
|
+
* - `powerLevel`: authorization level
|
|
116
|
+
*/
|
|
117
|
+
export type { Permission } from "@vex-chat/types";
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @ignore
|
|
121
|
+
*/
|
|
122
|
+
export interface Channels {
|
|
123
|
+
/** Creates a channel in a server. */
|
|
124
|
+
create: (name: string, serverID: string) => Promise<Channel>;
|
|
125
|
+
/** Deletes a channel. */
|
|
126
|
+
delete: (channelID: string) => Promise<void>;
|
|
127
|
+
/** Lists channels in a server. */
|
|
128
|
+
retrieve: (serverID: string) => Promise<Channel[]>;
|
|
129
|
+
/** Gets one channel by ID. */
|
|
130
|
+
retrieveByID: (channelID: string) => Promise<Channel | null>;
|
|
131
|
+
/** Lists users currently visible in a channel. */
|
|
132
|
+
userList: (channelID: string) => Promise<User[]>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Device record associated with a user account.
|
|
137
|
+
*
|
|
138
|
+
* Common fields:
|
|
139
|
+
* - `deviceID`: unique device identifier
|
|
140
|
+
* - `owner`: owning user ID
|
|
141
|
+
* - `signKey`: signing public key
|
|
142
|
+
* - `name`: user-facing device name
|
|
143
|
+
* - `lastLogin`: last login timestamp string
|
|
144
|
+
* - `deleted`: soft-delete flag
|
|
145
|
+
*/
|
|
146
|
+
export type { Device } from "@vex-chat/types";
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* ClientOptions are the options you can pass into the client.
|
|
150
|
+
*/
|
|
151
|
+
export interface ClientOptions {
|
|
152
|
+
/** Folder path where the sqlite file is created. */
|
|
153
|
+
dbFolder?: string;
|
|
154
|
+
/** Logging level for storage/database logs. */
|
|
155
|
+
dbLogLevel?:
|
|
156
|
+
| "debug"
|
|
157
|
+
| "error"
|
|
158
|
+
| "http"
|
|
159
|
+
| "info"
|
|
160
|
+
| "silly"
|
|
161
|
+
| "verbose"
|
|
162
|
+
| "warn";
|
|
163
|
+
/** Platform label for device registration (e.g. "ios", "macos", "linux"). */
|
|
164
|
+
deviceName?: string;
|
|
165
|
+
/** API host without protocol. Defaults to `api.vex.wtf`. */
|
|
166
|
+
host?: string;
|
|
167
|
+
/** Use sqlite in-memory mode (`:memory:`) instead of a file. */
|
|
168
|
+
inMemoryDb?: boolean;
|
|
169
|
+
/** Logger implementation. When omitted, defaults to winston (Node.js). */
|
|
170
|
+
logger?: Logger;
|
|
171
|
+
/** Logging level for client runtime logs. */
|
|
172
|
+
logLevel?:
|
|
173
|
+
| "debug"
|
|
174
|
+
| "error"
|
|
175
|
+
| "http"
|
|
176
|
+
| "info"
|
|
177
|
+
| "silly"
|
|
178
|
+
| "verbose"
|
|
179
|
+
| "warn";
|
|
180
|
+
/** Whether local message history should be persisted by default storage. */
|
|
181
|
+
saveHistory?: boolean;
|
|
182
|
+
/** Use `http/ws` instead of `https/wss`. Intended for local/dev environments. */
|
|
183
|
+
unsafeHttp?: boolean;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @ignore
|
|
188
|
+
*/
|
|
189
|
+
export interface Devices {
|
|
190
|
+
/** Deletes one of the account's devices (except the currently active one). */
|
|
191
|
+
delete: (deviceID: string) => Promise<void>;
|
|
192
|
+
/** Registers the current key material as a new device. */
|
|
193
|
+
register: () => Promise<Device | null>;
|
|
194
|
+
/** Fetches one device by ID. */
|
|
195
|
+
retrieve: (deviceIdentifier: string) => Promise<Device | null>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Channel is a chat channel on a server.
|
|
200
|
+
*
|
|
201
|
+
* Common fields:
|
|
202
|
+
* - `channelID`
|
|
203
|
+
* - `serverID`
|
|
204
|
+
* - `name`
|
|
205
|
+
*/
|
|
206
|
+
export type { Channel } from "@vex-chat/types";
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Server is a single chat server.
|
|
210
|
+
*
|
|
211
|
+
* Common fields:
|
|
212
|
+
* - `serverID`
|
|
213
|
+
* - `name`
|
|
214
|
+
* - `icon` (optional URL/data)
|
|
215
|
+
*/
|
|
216
|
+
export type { Server } from "@vex-chat/types";
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @ignore
|
|
220
|
+
*/
|
|
221
|
+
export interface Emojis {
|
|
222
|
+
/** Uploads a custom emoji to a server. */
|
|
223
|
+
create: (
|
|
224
|
+
emoji: Uint8Array,
|
|
225
|
+
name: string,
|
|
226
|
+
serverID: string,
|
|
227
|
+
) => Promise<Emoji | null>;
|
|
228
|
+
/** Fetches one emoji's metadata by ID. */
|
|
229
|
+
retrieve: (emojiID: string) => Promise<Emoji | null>;
|
|
230
|
+
/** Lists emojis available on a server. */
|
|
231
|
+
retrieveList: (serverID: string) => Promise<Emoji[]>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Progress payload emitted by the `fileProgress` event.
|
|
236
|
+
*/
|
|
237
|
+
export interface FileProgress {
|
|
238
|
+
/** Whether this progress event is for upload or download. */
|
|
239
|
+
direction: "download" | "upload";
|
|
240
|
+
/** Bytes transferred so far. */
|
|
241
|
+
loaded: number;
|
|
242
|
+
/** Integer percentage from `0` to `100`. */
|
|
243
|
+
progress: number;
|
|
244
|
+
/** Correlation token (file ID, nonce, or label depending on operation). */
|
|
245
|
+
token: string;
|
|
246
|
+
/** Total expected bytes when available, otherwise `0`. */
|
|
247
|
+
total: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* FileRes is a server response to a file retrieval request.
|
|
252
|
+
*
|
|
253
|
+
* Structure:
|
|
254
|
+
* - `details`: metadata (`VexFile`)
|
|
255
|
+
* - `data`: decrypted binary bytes
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* ```ts
|
|
259
|
+
* const response: FileRes = {
|
|
260
|
+
* details: {
|
|
261
|
+
* fileID: "bb1c3fd1-4928-48ab-9d09-3ea0972fbd9d",
|
|
262
|
+
* owner: "9b0f3f46-06ad-4bc4-8adf-4de10e13cb9c",
|
|
263
|
+
* nonce: "aa6c8d42f3fdd032a1e9fced4be379582d26ce8f69822d64",
|
|
264
|
+
* },
|
|
265
|
+
* data: Buffer.from("hello"),
|
|
266
|
+
* };
|
|
267
|
+
* ```
|
|
268
|
+
*/
|
|
269
|
+
export type FileRes = FileResponse;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* @ignore
|
|
273
|
+
*/
|
|
274
|
+
export interface Files {
|
|
275
|
+
/** Uploads and encrypts a file. */
|
|
276
|
+
create: (file: Uint8Array) => Promise<[FileSQL, string]>;
|
|
277
|
+
/** Downloads and decrypts a file using a file ID and key. */
|
|
278
|
+
retrieve: (fileID: string, key: string) => Promise<FileResponse | null>;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* @ignore
|
|
283
|
+
*/
|
|
284
|
+
export interface Invites {
|
|
285
|
+
/** Creates an invite for a server and duration. */
|
|
286
|
+
create: (serverID: string, duration: string) => Promise<Invite>;
|
|
287
|
+
/** Redeems an invite and returns the created permission grant. */
|
|
288
|
+
redeem: (inviteID: string) => Promise<Permission>;
|
|
289
|
+
/** Lists active invites for a server. */
|
|
290
|
+
retrieve: (serverID: string) => Promise<Invite[]>;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Keys are a pair of ed25519 public and private keys,
|
|
295
|
+
* encoded as hex strings.
|
|
296
|
+
*/
|
|
297
|
+
export interface Keys {
|
|
298
|
+
/** Secret Ed25519 key as hex. Store securely. */
|
|
299
|
+
private: string;
|
|
300
|
+
/** Public Ed25519 key as hex. */
|
|
301
|
+
public: string;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* @ignore
|
|
306
|
+
*/
|
|
307
|
+
export interface Me {
|
|
308
|
+
/** Returns metadata for the currently authenticated device. */
|
|
309
|
+
device: () => Device;
|
|
310
|
+
/** Uploads and sets a new avatar image for the current user. */
|
|
311
|
+
setAvatar: (avatar: Uint8Array) => Promise<void>;
|
|
312
|
+
/** Returns the currently authenticated user profile. */
|
|
313
|
+
user: () => User;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Message is a chat message.
|
|
318
|
+
*/
|
|
319
|
+
export interface Message {
|
|
320
|
+
/** User ID of the original author. */
|
|
321
|
+
authorID: string;
|
|
322
|
+
/** Whether payload decryption succeeded. */
|
|
323
|
+
decrypted: boolean;
|
|
324
|
+
/** Whether this message was received or sent by the current client. */
|
|
325
|
+
direction: "incoming" | "outgoing";
|
|
326
|
+
/** `true` when this message was forwarded to another owned device. */
|
|
327
|
+
forward: boolean;
|
|
328
|
+
/** Channel ID for group messages; `null` for direct messages. */
|
|
329
|
+
group: null | string;
|
|
330
|
+
/** Globally unique message identifier. */
|
|
331
|
+
mailID: string;
|
|
332
|
+
/** Plaintext message content (or empty string when decryption failed). */
|
|
333
|
+
message: string;
|
|
334
|
+
/** Hex-encoded nonce used for message encryption. */
|
|
335
|
+
nonce: string;
|
|
336
|
+
/** User ID of the intended reader. */
|
|
337
|
+
readerID: string;
|
|
338
|
+
/** Recipient device ID. */
|
|
339
|
+
recipient: string;
|
|
340
|
+
/** Sender device ID. */
|
|
341
|
+
sender: string;
|
|
342
|
+
/** Time the message was created/received. */
|
|
343
|
+
timestamp: string;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Zod schema matching the {@link Message} interface for forwarded-message decode. */
|
|
347
|
+
const messageSchema: z.ZodType<Message> = z.object({
|
|
348
|
+
authorID: z.string(),
|
|
349
|
+
decrypted: z.boolean(),
|
|
350
|
+
direction: z.enum(["incoming", "outgoing"]),
|
|
351
|
+
forward: z.boolean(),
|
|
352
|
+
group: z.string().nullable(),
|
|
353
|
+
mailID: z.string(),
|
|
354
|
+
message: z.string(),
|
|
355
|
+
nonce: z.string(),
|
|
356
|
+
readerID: z.string(),
|
|
357
|
+
recipient: z.string(),
|
|
358
|
+
sender: z.string(),
|
|
359
|
+
timestamp: z.string(),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
/** Zod schema for a single inbox entry from getMail: [header, mailBody, timestamp]. */
|
|
363
|
+
const mailInboxEntry = z.tuple([
|
|
364
|
+
z.custom<Uint8Array>((val) => val instanceof Uint8Array),
|
|
365
|
+
MailWSSchema,
|
|
366
|
+
z.string(),
|
|
367
|
+
]);
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @ignore
|
|
371
|
+
*/
|
|
372
|
+
export interface Messages {
|
|
373
|
+
/** Deletes local history for a user/channel. */
|
|
374
|
+
delete: (userOrChannelID: string) => Promise<void>;
|
|
375
|
+
/** Sends an encrypted message to all members of a channel. */
|
|
376
|
+
group: (channelID: string, message: string) => Promise<void>;
|
|
377
|
+
/** Deletes all locally stored message history. */
|
|
378
|
+
purge: () => Promise<void>;
|
|
379
|
+
/** Returns local direct-message history with one user. */
|
|
380
|
+
retrieve: (userID: string) => Promise<Message[]>;
|
|
381
|
+
/** Returns local group-message history for one channel. */
|
|
382
|
+
retrieveGroup: (channelID: string) => Promise<Message[]>;
|
|
383
|
+
/** Sends an encrypted direct message to one user. */
|
|
384
|
+
send: (userID: string, message: string) => Promise<void>;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* @ignore
|
|
389
|
+
*/
|
|
390
|
+
export interface Moderation {
|
|
391
|
+
/** Returns all permission entries for a server. */
|
|
392
|
+
fetchPermissionList: (serverID: string) => Promise<Permission[]>;
|
|
393
|
+
/** Removes a user from a server by revoking their server permission(s). */
|
|
394
|
+
kick: (userID: string, serverID: string) => Promise<void>;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* @ignore
|
|
399
|
+
*/
|
|
400
|
+
export interface Permissions {
|
|
401
|
+
/** Deletes one permission grant. */
|
|
402
|
+
delete: (permissionID: string) => Promise<void>;
|
|
403
|
+
/** Lists permissions granted to the authenticated user. */
|
|
404
|
+
retrieve: () => Promise<Permission[]>;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* @ignore
|
|
409
|
+
*/
|
|
410
|
+
export interface Servers {
|
|
411
|
+
/** Creates a server. */
|
|
412
|
+
create: (name: string) => Promise<Server>;
|
|
413
|
+
/** Deletes a server. */
|
|
414
|
+
delete: (serverID: string) => Promise<void>;
|
|
415
|
+
/** Leaves a server by removing the user's permission entry. */
|
|
416
|
+
leave: (serverID: string) => Promise<void>;
|
|
417
|
+
/** Lists servers available to the authenticated user. */
|
|
418
|
+
retrieve: () => Promise<Server[]>;
|
|
419
|
+
/** Gets one server by ID. */
|
|
420
|
+
retrieveByID: (serverID: string) => Promise<null | Server>;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Session is an end to end encryption session with another peer.
|
|
425
|
+
*
|
|
426
|
+
* Key fields include:
|
|
427
|
+
* - `sessionID`
|
|
428
|
+
* - `userID`
|
|
429
|
+
* - `deviceID`
|
|
430
|
+
* - `mode` (`initiator` or `receiver`)
|
|
431
|
+
* - `publicKey` and `fingerprint`
|
|
432
|
+
* - `lastUsed`
|
|
433
|
+
* - `verified`
|
|
434
|
+
*
|
|
435
|
+
* @example
|
|
436
|
+
* ```ts
|
|
437
|
+
* const session: Session = {
|
|
438
|
+
* sessionID: "f6e4fbd0-7222-4ba8-b799-c227faf5c8de",
|
|
439
|
+
* userID: "f34f5e37-616f-4d3a-a437-e7c27c31cb73",
|
|
440
|
+
* deviceID: "9b0f3f46-06ad-4bc4-8adf-4de10e13cb9c",
|
|
441
|
+
* mode: "initiator",
|
|
442
|
+
* SK: "7d9afde6683ecc2d1f55e34e1b95de9d4042dfd4e8cda7fdf3f0f7e02fef8f9a",
|
|
443
|
+
* publicKey: "d58f39dc4bcfe4e8ef022f34e8b6f4f6ddc9c4acee30c0d58f126aa5db3f61b0",
|
|
444
|
+
* fingerprint: "05294b9aa81d0fd0ca12a4b585f531d8ef1f53f8ea3d0200a0df3f9c44a7d8b1",
|
|
445
|
+
* lastUsed: new Date(),
|
|
446
|
+
* verified: false,
|
|
447
|
+
* };
|
|
448
|
+
* ```
|
|
449
|
+
*/
|
|
450
|
+
export type Session = SessionSQL;
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* @ignore
|
|
454
|
+
*/
|
|
455
|
+
export interface Sessions {
|
|
456
|
+
/** Marks one session as verification-confirmed. */
|
|
457
|
+
markVerified: (fingerprint: string) => Promise<void>;
|
|
458
|
+
/** Returns all locally known sessions. */
|
|
459
|
+
retrieve: () => Promise<SessionSQL[]>;
|
|
460
|
+
/** Builds a human-readable verification phrase from a session fingerprint. */
|
|
461
|
+
verify: (session: SessionSQL) => string;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* User is a single user on the vex platform.
|
|
466
|
+
*
|
|
467
|
+
* This is intentionally a censored user shape for client use, containing:
|
|
468
|
+
* - `userID`
|
|
469
|
+
* - `username`
|
|
470
|
+
* - `lastSeen`
|
|
471
|
+
*/
|
|
472
|
+
export interface User {
|
|
473
|
+
/** Last-seen timestamp (ISO 8601 string). */
|
|
474
|
+
lastSeen: string;
|
|
475
|
+
/** User identifier. */
|
|
476
|
+
userID: string;
|
|
477
|
+
/** Public username. */
|
|
478
|
+
username: string;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* @ignore
|
|
483
|
+
*/
|
|
484
|
+
export interface Users {
|
|
485
|
+
/** Returns users with whom the current device has active sessions. */
|
|
486
|
+
familiars: () => Promise<User[]>;
|
|
487
|
+
/**
|
|
488
|
+
* Looks up a user by user ID, username, or signing key.
|
|
489
|
+
*/
|
|
490
|
+
retrieve: (userID: string) => Promise<[null | User, AxiosError | null]>;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* VexFile is an uploaded encrypted file.
|
|
495
|
+
*
|
|
496
|
+
* Common fields:
|
|
497
|
+
* - `fileID`: file identifier
|
|
498
|
+
* - `owner`: owner device/user ID
|
|
499
|
+
* - `nonce`: file encryption nonce (hex)
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* ```ts
|
|
503
|
+
* const file: VexFile = {
|
|
504
|
+
* fileID: "bb1c3fd1-4928-48ab-9d09-3ea0972fbd9d",
|
|
505
|
+
* owner: "9b0f3f46-06ad-4bc4-8adf-4de10e13cb9c",
|
|
506
|
+
* nonce: "aa6c8d42f3fdd032a1e9fced4be379582d26ce8f69822d64",
|
|
507
|
+
* };
|
|
508
|
+
* ```
|
|
509
|
+
*/
|
|
510
|
+
export type VexFile = FileSQL;
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Client provides an interface for you to use a vex chat server and
|
|
514
|
+
* send end to end encrypted messages to other users.
|
|
515
|
+
*
|
|
516
|
+
* @example
|
|
517
|
+
* ```ts
|
|
518
|
+
* import { Client } from "@vex-chat/libvex";
|
|
519
|
+
*
|
|
520
|
+
* async function main() {
|
|
521
|
+
* // generate a secret key to use, save this somewhere permanent
|
|
522
|
+
* const privateKey = Client.generateSecretKey();
|
|
523
|
+
*
|
|
524
|
+
* const client = await Client.create(privateKey);
|
|
525
|
+
*
|
|
526
|
+
* // you must register once before you can log in
|
|
527
|
+
* await client.register(Client.randomUsername());
|
|
528
|
+
* await client.login();
|
|
529
|
+
*
|
|
530
|
+
* // The authed event fires when login() successfully completes
|
|
531
|
+
* // and the server indicates you are authorized. You must wait to
|
|
532
|
+
* // perform any operations besides register() and login() until
|
|
533
|
+
* // this occurs.
|
|
534
|
+
* client.on("authed", async () => {
|
|
535
|
+
* const me = await client.users.me();
|
|
536
|
+
*
|
|
537
|
+
* // send a message
|
|
538
|
+
* await client.messages.send(me.userID, "Hello world!");
|
|
539
|
+
* })
|
|
540
|
+
*
|
|
541
|
+
* // Outgoing and incoming messages are emitted here.
|
|
542
|
+
* client.on("message", (message) => {
|
|
543
|
+
* console.log("message:", message);
|
|
544
|
+
* })
|
|
545
|
+
* }
|
|
546
|
+
*
|
|
547
|
+
* main();
|
|
548
|
+
* ```
|
|
549
|
+
*/
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Event signatures emitted by {@link Client}.
|
|
553
|
+
*/
|
|
554
|
+
interface ClientEvents {
|
|
555
|
+
closed: () => void;
|
|
556
|
+
connected: () => void;
|
|
557
|
+
decryptingMail: () => void;
|
|
558
|
+
disconnect: () => void;
|
|
559
|
+
fileProgress: (progress: FileProgress) => void;
|
|
560
|
+
message: (message: Message) => void;
|
|
561
|
+
permission: (permission: Permission) => void;
|
|
562
|
+
ready: () => void;
|
|
563
|
+
session: (session: Session, user: User) => void;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export class Client {
|
|
567
|
+
/**
|
|
568
|
+
* Decrypts a secret key from encrypted data produced by encryptKeyData().
|
|
569
|
+
*
|
|
570
|
+
* Pass-through utility from `@vex-chat/crypto`.
|
|
571
|
+
*/
|
|
572
|
+
public static decryptKeyData = XUtils.decryptKeyData;
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Encrypts a secret key with a password.
|
|
576
|
+
*
|
|
577
|
+
* Pass-through utility from `@vex-chat/crypto`.
|
|
578
|
+
*/
|
|
579
|
+
public static encryptKeyData = XUtils.encryptKeyData;
|
|
580
|
+
|
|
581
|
+
private static readonly NOT_FOUND_TTL = 30 * 60 * 1000;
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Channel operations.
|
|
585
|
+
*/
|
|
586
|
+
public channels: Channels = {
|
|
587
|
+
/**
|
|
588
|
+
* Creates a new channel in a server.
|
|
589
|
+
* @param name: The channel name.
|
|
590
|
+
* @param serverID: The unique serverID to create the channel in.
|
|
591
|
+
*
|
|
592
|
+
* @returns - The created Channel object.
|
|
593
|
+
*/
|
|
594
|
+
create: this.createChannel.bind(this),
|
|
595
|
+
/**
|
|
596
|
+
* Deletes a channel.
|
|
597
|
+
* @param channelID: The unique channelID to delete.
|
|
598
|
+
*/
|
|
599
|
+
delete: this.deleteChannel.bind(this),
|
|
600
|
+
/**
|
|
601
|
+
* Retrieves all channels in a server.
|
|
602
|
+
*
|
|
603
|
+
* @returns - The list of Channel objects.
|
|
604
|
+
*/
|
|
605
|
+
retrieve: this.getChannelList.bind(this),
|
|
606
|
+
/**
|
|
607
|
+
* Retrieves channel details by its unique channelID.
|
|
608
|
+
*
|
|
609
|
+
* @returns - The list of Channel objects.
|
|
610
|
+
*/
|
|
611
|
+
retrieveByID: this.getChannelByID.bind(this),
|
|
612
|
+
/**
|
|
613
|
+
* Retrieves a channel's userlist.
|
|
614
|
+
* @param channelID: The channelID to retrieve userlist for.
|
|
615
|
+
*/
|
|
616
|
+
userList: this.getUserList.bind(this),
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Device management methods.
|
|
621
|
+
*/
|
|
622
|
+
public devices: Devices = {
|
|
623
|
+
delete: this.deleteDevice.bind(this),
|
|
624
|
+
register: this.registerDevice.bind(this),
|
|
625
|
+
retrieve: this.getDeviceByID.bind(this),
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Emoji operations.
|
|
630
|
+
*
|
|
631
|
+
* @example
|
|
632
|
+
* ```ts
|
|
633
|
+
* const emoji = await client.emoji.create(imageBuffer, "party", serverID);
|
|
634
|
+
* const list = await client.emoji.retrieveList(serverID);
|
|
635
|
+
* ```
|
|
636
|
+
*/
|
|
637
|
+
public emoji: Emojis = {
|
|
638
|
+
create: this.uploadEmoji.bind(this),
|
|
639
|
+
retrieve: this.retrieveEmojiByID.bind(this),
|
|
640
|
+
retrieveList: this.retrieveEmojiList.bind(this),
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
/** File upload/download methods. */
|
|
644
|
+
public files: Files = {
|
|
645
|
+
/**
|
|
646
|
+
* Uploads an encrypted file and returns the details and the secret key.
|
|
647
|
+
* @param file: The file as a Buffer.
|
|
648
|
+
*
|
|
649
|
+
* @returns Details of the file uploaded and the key to encrypt in the form [details, key].
|
|
650
|
+
*/
|
|
651
|
+
create: this.createFile.bind(this),
|
|
652
|
+
retrieve: this.retrieveFile.bind(this),
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* This is true if the client has ever been initialized. You can only initialize
|
|
657
|
+
* a client once.
|
|
658
|
+
*/
|
|
659
|
+
public hasInit: boolean = false;
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* This is true if the client has ever logged in before. You can only login a client once.
|
|
663
|
+
*/
|
|
664
|
+
public hasLoggedIn: boolean = false;
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Invite-management methods.
|
|
668
|
+
*/
|
|
669
|
+
public invites: Invites = {
|
|
670
|
+
create: this.createInvite.bind(this),
|
|
671
|
+
redeem: this.redeemInvite.bind(this),
|
|
672
|
+
retrieve: this.retrieveInvites.bind(this),
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Helpers for information/actions related to the currently authenticated account.
|
|
677
|
+
*/
|
|
678
|
+
public me: Me = {
|
|
679
|
+
/**
|
|
680
|
+
* Retrieves current device details
|
|
681
|
+
*
|
|
682
|
+
* @returns - The logged in device's Device object.
|
|
683
|
+
*/
|
|
684
|
+
device: this.getDevice.bind(this),
|
|
685
|
+
/**
|
|
686
|
+
* Changes your avatar.
|
|
687
|
+
*/
|
|
688
|
+
setAvatar: this.uploadAvatar.bind(this),
|
|
689
|
+
/**
|
|
690
|
+
* Retrieves your user information
|
|
691
|
+
*
|
|
692
|
+
* @returns - The logged in user's User object.
|
|
693
|
+
*/
|
|
694
|
+
user: this.getUser.bind(this),
|
|
695
|
+
};
|
|
696
|
+
/**
|
|
697
|
+
* Message operations (direct and group).
|
|
698
|
+
*
|
|
699
|
+
* @example
|
|
700
|
+
* ```ts
|
|
701
|
+
* await client.messages.send(userID, "Hello!");
|
|
702
|
+
* await client.messages.group(channelID, "Hello channel!");
|
|
703
|
+
* const dmHistory = await client.messages.retrieve(userID);
|
|
704
|
+
* ```
|
|
705
|
+
*/
|
|
706
|
+
public messages: Messages = {
|
|
707
|
+
delete: this.deleteHistory.bind(this),
|
|
708
|
+
/**
|
|
709
|
+
* Send a group message to a channel.
|
|
710
|
+
* @param channelID: The channelID of the channel to send a message to.
|
|
711
|
+
* @param message: The message to send.
|
|
712
|
+
*/
|
|
713
|
+
group: this.sendGroupMessage.bind(this),
|
|
714
|
+
purge: this.purgeHistory.bind(this),
|
|
715
|
+
/**
|
|
716
|
+
* Gets the message history with a specific userID.
|
|
717
|
+
* @param userID: The userID of the user to retrieve message history for.
|
|
718
|
+
*
|
|
719
|
+
* @returns - The list of Message objects.
|
|
720
|
+
*/
|
|
721
|
+
retrieve: this.getMessageHistory.bind(this),
|
|
722
|
+
/**
|
|
723
|
+
* Gets the group message history with a specific channelID.
|
|
724
|
+
* @param chqnnelID: The channelID of the channel to retrieve message history for.
|
|
725
|
+
*
|
|
726
|
+
* @returns - The list of Message objects.
|
|
727
|
+
*/
|
|
728
|
+
retrieveGroup: this.getGroupHistory.bind(this),
|
|
729
|
+
/**
|
|
730
|
+
* Send a direct message.
|
|
731
|
+
* @param userID: The userID of the user to send a message to.
|
|
732
|
+
* @param message: The message to send.
|
|
733
|
+
*/
|
|
734
|
+
send: this.sendMessage.bind(this),
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Server moderation helper methods.
|
|
739
|
+
*/
|
|
740
|
+
public moderation: Moderation = {
|
|
741
|
+
fetchPermissionList: this.fetchPermissionList.bind(this),
|
|
742
|
+
kick: this.kickUser.bind(this),
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Permission-management methods for the current user.
|
|
747
|
+
*/
|
|
748
|
+
public permissions: Permissions = {
|
|
749
|
+
delete: this.deletePermission.bind(this),
|
|
750
|
+
retrieve: this.getPermissions.bind(this),
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
public sending = new Map<string, Device>();
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Server operations.
|
|
757
|
+
*
|
|
758
|
+
* @example
|
|
759
|
+
* ```ts
|
|
760
|
+
* const servers = await client.servers.retrieve();
|
|
761
|
+
* const created = await client.servers.create("Team Space");
|
|
762
|
+
* ```
|
|
763
|
+
*/
|
|
764
|
+
public servers: Servers = {
|
|
765
|
+
/**
|
|
766
|
+
* Creates a new server.
|
|
767
|
+
* @param name: The server name.
|
|
768
|
+
*
|
|
769
|
+
* @returns - The created Server object.
|
|
770
|
+
*/
|
|
771
|
+
create: this.createServer.bind(this),
|
|
772
|
+
/**
|
|
773
|
+
* Deletes a server.
|
|
774
|
+
* @param serverID: The unique serverID to delete.
|
|
775
|
+
*/
|
|
776
|
+
delete: this.deleteServer.bind(this),
|
|
777
|
+
leave: this.leaveServer.bind(this),
|
|
778
|
+
/**
|
|
779
|
+
* Retrieves all servers the logged in user has access to.
|
|
780
|
+
*
|
|
781
|
+
* @returns - The list of Server objects.
|
|
782
|
+
*/
|
|
783
|
+
retrieve: this.getServerList.bind(this),
|
|
784
|
+
/**
|
|
785
|
+
* Retrieves server details by its unique serverID.
|
|
786
|
+
*
|
|
787
|
+
* @returns - The requested Server object, or null if the id does not exist.
|
|
788
|
+
*/
|
|
789
|
+
retrieveByID: this.getServerByID.bind(this),
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Encryption-session helpers.
|
|
794
|
+
*/
|
|
795
|
+
public sessions: Sessions = {
|
|
796
|
+
/**
|
|
797
|
+
* Marks a mnemonic verified, implying that the the user has confirmed
|
|
798
|
+
* that the session mnemonic matches with the other user.
|
|
799
|
+
* @param sessionID the sessionID of the session to mark.
|
|
800
|
+
* @param status Optionally, what to mark it as. Defaults to true.
|
|
801
|
+
*/
|
|
802
|
+
markVerified: this.markSessionVerified.bind(this),
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Gets all encryption sessions.
|
|
806
|
+
*
|
|
807
|
+
* @returns - The list of Session encryption sessions.
|
|
808
|
+
*/
|
|
809
|
+
retrieve: this.getSessionList.bind(this),
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Returns a mnemonic for the session, to verify with the other user.
|
|
813
|
+
* @param session the Session object to get the mnemonic for.
|
|
814
|
+
*
|
|
815
|
+
* @returns - The mnemonic representation of the session.
|
|
816
|
+
*/
|
|
817
|
+
verify: (session: SessionSQL) => Client.getMnemonic(session),
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* User operations.
|
|
822
|
+
*
|
|
823
|
+
* @example
|
|
824
|
+
* ```ts
|
|
825
|
+
* const [user] = await client.users.retrieve("alice");
|
|
826
|
+
* const familiarUsers = await client.users.familiars();
|
|
827
|
+
* ```
|
|
828
|
+
*/
|
|
829
|
+
public users: Users = {
|
|
830
|
+
/**
|
|
831
|
+
* Retrieves the list of users you can currently access, or are already familiar with.
|
|
832
|
+
*
|
|
833
|
+
* @returns - The list of User objects.
|
|
834
|
+
*/
|
|
835
|
+
familiars: this.getFamiliars.bind(this),
|
|
836
|
+
/**
|
|
837
|
+
* Retrieves a user's information by a string identifier.
|
|
838
|
+
* @param identifier: A userID, hex string public key, or a username.
|
|
839
|
+
*
|
|
840
|
+
* @returns - The user's User object, or null if the user does not exist.
|
|
841
|
+
*/
|
|
842
|
+
retrieve: this.fetchUser.bind(this),
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
private readonly database: Storage;
|
|
846
|
+
|
|
847
|
+
private readonly dbPath: string;
|
|
848
|
+
|
|
849
|
+
private device?: Device;
|
|
850
|
+
|
|
851
|
+
private deviceRecords: Record<string, Device> = {};
|
|
852
|
+
|
|
853
|
+
// ── Event subscription (composition over inheritance) ───────────────
|
|
854
|
+
private readonly emitter = new EventEmitter<ClientEvents>();
|
|
855
|
+
|
|
856
|
+
private fetchingMail: boolean = false;
|
|
857
|
+
private firstMailFetch = true;
|
|
858
|
+
|
|
859
|
+
private readonly forwarded = new Set<string>();
|
|
860
|
+
|
|
861
|
+
private readonly host: string;
|
|
862
|
+
private readonly http: AxiosInstance;
|
|
863
|
+
private readonly idKeys: KeyPair | null;
|
|
864
|
+
private isAlive: boolean = true;
|
|
865
|
+
private readonly log: Logger;
|
|
866
|
+
|
|
867
|
+
private readonly mailInterval?: NodeJS.Timeout;
|
|
868
|
+
|
|
869
|
+
private manuallyClosing: boolean = false;
|
|
870
|
+
/* Retrieves the userID with the user identifier.
|
|
871
|
+
user identifier is checked for userID, then signkey,
|
|
872
|
+
and finally falls back to username. */
|
|
873
|
+
/** Negative cache for user lookups that returned 404. TTL = 30 minutes. */
|
|
874
|
+
private readonly notFoundUsers = new Map<string, number>();
|
|
875
|
+
|
|
876
|
+
private readonly options?: ClientOptions | undefined;
|
|
877
|
+
|
|
878
|
+
private pingInterval: null | ReturnType<typeof setTimeout> = null;
|
|
879
|
+
private readonly prefixes:
|
|
880
|
+
| { HTTP: "http://"; WS: "ws://" }
|
|
881
|
+
| { HTTP: "https://"; WS: "wss://" };
|
|
882
|
+
|
|
883
|
+
private reading: boolean = false;
|
|
884
|
+
private sessionRecords: Record<string, SessionCrypto> = {};
|
|
885
|
+
// these are created from one set of sign keys
|
|
886
|
+
private readonly signKeys: KeyPair;
|
|
887
|
+
|
|
888
|
+
private socket: WebSocketLike;
|
|
889
|
+
private token: null | string = null;
|
|
890
|
+
private user?: User;
|
|
891
|
+
|
|
892
|
+
private userRecords: Record<string, User> = {};
|
|
893
|
+
|
|
894
|
+
private xKeyRing?: XKeyRing;
|
|
895
|
+
|
|
896
|
+
private constructor(
|
|
897
|
+
privateKey?: string,
|
|
898
|
+
options?: ClientOptions,
|
|
899
|
+
storage?: Storage,
|
|
900
|
+
) {
|
|
901
|
+
// (no super — composition, not inheritance)
|
|
902
|
+
this.options = options;
|
|
903
|
+
|
|
904
|
+
this.log = options?.logger ?? {
|
|
905
|
+
debug() {},
|
|
906
|
+
error() {},
|
|
907
|
+
info() {},
|
|
908
|
+
warn() {},
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
this.prefixes = options?.unsafeHttp
|
|
912
|
+
? { HTTP: "http://", WS: "ws://" }
|
|
913
|
+
: { HTTP: "https://", WS: "wss://" };
|
|
914
|
+
|
|
915
|
+
this.signKeys = privateKey
|
|
916
|
+
? xSignKeyPairFromSecret(XUtils.decodeHex(privateKey))
|
|
917
|
+
: xSignKeyPair();
|
|
918
|
+
this.idKeys = XKeyConvert.convertKeyPair(this.signKeys);
|
|
919
|
+
|
|
920
|
+
if (!this.idKeys) {
|
|
921
|
+
throw new Error("Could not convert key to X25519!");
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
this.host = options?.host || "api.vex.wtf";
|
|
925
|
+
const dbFileName = options?.inMemoryDb
|
|
926
|
+
? ":memory:"
|
|
927
|
+
: XUtils.encodeHex(this.signKeys.publicKey) + ".sqlite";
|
|
928
|
+
this.dbPath = options?.dbFolder
|
|
929
|
+
? options.dbFolder + "/" + dbFileName
|
|
930
|
+
: dbFileName;
|
|
931
|
+
|
|
932
|
+
if (!storage) {
|
|
933
|
+
throw new Error(
|
|
934
|
+
"No storage provided. Use Client.create() which resolves storage automatically.",
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
this.database = storage;
|
|
938
|
+
|
|
939
|
+
this.database.on("error", (error: Error) => {
|
|
940
|
+
this.log.error(error.toString());
|
|
941
|
+
void this.close(true);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
this.http = axios.create({ responseType: "arraybuffer" });
|
|
945
|
+
|
|
946
|
+
// Placeholder connection — replaced by initSocket() during connect()
|
|
947
|
+
this.socket = new WebSocketAdapter("ws://localhost:1234");
|
|
948
|
+
this.socket.onerror = () => {};
|
|
949
|
+
|
|
950
|
+
// Strip the `logger` field before stringifying — when a consumer
|
|
951
|
+
// passes a Winston logger instance (which has a circular
|
|
952
|
+
// `_readableState.pipes[0].parent` back-reference from the
|
|
953
|
+
// underlying file transport), JSON.stringify throws
|
|
954
|
+
// `TypeError: Converting circular structure to JSON`.
|
|
955
|
+
const { logger: _logger, ...safeOptions } = options ?? {};
|
|
956
|
+
this.log.info(
|
|
957
|
+
"Client debug information: " +
|
|
958
|
+
JSON.stringify(
|
|
959
|
+
{
|
|
960
|
+
dbPath: this.dbPath,
|
|
961
|
+
environment: {
|
|
962
|
+
platform: this.options?.deviceName ?? "unknown",
|
|
963
|
+
},
|
|
964
|
+
host: this.getHost(),
|
|
965
|
+
options: safeOptions,
|
|
966
|
+
publicKey: this.getKeys().public,
|
|
967
|
+
},
|
|
968
|
+
null,
|
|
969
|
+
4,
|
|
970
|
+
),
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Creates and initializes a client in one step.
|
|
975
|
+
*
|
|
976
|
+
* @param privateKey Optional hex secret key. When omitted, a fresh key is generated.
|
|
977
|
+
* @param options Runtime options.
|
|
978
|
+
* @param storage Optional custom storage backend implementing `Storage`.
|
|
979
|
+
*
|
|
980
|
+
* @example
|
|
981
|
+
* ```ts
|
|
982
|
+
* const client = await Client.create(privateKey, { host: "api.vex.wtf" });
|
|
983
|
+
* ```
|
|
984
|
+
*/
|
|
985
|
+
public static create = async (
|
|
986
|
+
privateKey?: string,
|
|
987
|
+
options?: ClientOptions,
|
|
988
|
+
storage?: Storage,
|
|
989
|
+
): Promise<Client> => {
|
|
990
|
+
let opts = options;
|
|
991
|
+
if (!opts?.logger) {
|
|
992
|
+
const { createLogger: makeLog } =
|
|
993
|
+
await import("./utils/createLogger.js");
|
|
994
|
+
opts = {
|
|
995
|
+
...opts,
|
|
996
|
+
logger: makeLog("libvex", opts?.logLevel),
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
// Lazily create Node Storage only on the Node path (no logger override).
|
|
1000
|
+
// When a logger is provided (browser/RN), the caller must supply storage
|
|
1001
|
+
// via BootstrapConfig.createStorage() — there is no Node fallback.
|
|
1002
|
+
let resolvedStorage = storage;
|
|
1003
|
+
if (!resolvedStorage) {
|
|
1004
|
+
if (opts.logger) {
|
|
1005
|
+
throw new Error(
|
|
1006
|
+
"No storage provided. When using a custom logger (browser/RN), pass storage from your BootstrapConfig.",
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
const { createNodeStorage } = await import("./storage/node.js");
|
|
1010
|
+
const dbFileName = opts.inMemoryDb
|
|
1011
|
+
? ":memory:"
|
|
1012
|
+
: XUtils.encodeHex(
|
|
1013
|
+
xSignKeyPairFromSecret(XUtils.decodeHex(privateKey || ""))
|
|
1014
|
+
.publicKey,
|
|
1015
|
+
) + ".sqlite";
|
|
1016
|
+
const dbPath = opts.dbFolder
|
|
1017
|
+
? opts.dbFolder + "/" + dbFileName
|
|
1018
|
+
: dbFileName;
|
|
1019
|
+
resolvedStorage = createNodeStorage(
|
|
1020
|
+
dbPath,
|
|
1021
|
+
privateKey || XUtils.encodeHex(xSignKeyPair().secretKey),
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
const client = new Client(privateKey, opts, resolvedStorage);
|
|
1025
|
+
await client.init();
|
|
1026
|
+
return client;
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Generates an ed25519 secret key as a hex string.
|
|
1031
|
+
*
|
|
1032
|
+
* @returns - A secret key to use for the client. Save it permanently somewhere safe.
|
|
1033
|
+
*/
|
|
1034
|
+
public static generateSecretKey(): string {
|
|
1035
|
+
return XUtils.encodeHex(xSignKeyPair().secretKey);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Generates a random username using bip39.
|
|
1040
|
+
*
|
|
1041
|
+
* @returns - The username.
|
|
1042
|
+
*/
|
|
1043
|
+
public static randomUsername() {
|
|
1044
|
+
const IKM = XUtils.decodeHex(XUtils.encodeHex(xRandomBytes(16)));
|
|
1045
|
+
const mnemonic = xMnemonic(IKM).split(" ");
|
|
1046
|
+
const addendum = XUtils.uint8ArrToNumber(xRandomBytes(1));
|
|
1047
|
+
|
|
1048
|
+
const word0 = mnemonic[0] ?? "";
|
|
1049
|
+
const word1 = mnemonic[1] ?? "";
|
|
1050
|
+
return capitalize(word0) + capitalize(word1) + addendum.toString();
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
private static deserializeExtra(
|
|
1054
|
+
type: MailType,
|
|
1055
|
+
extra: Uint8Array,
|
|
1056
|
+
): Uint8Array[] {
|
|
1057
|
+
switch (type) {
|
|
1058
|
+
case MailType.initial:
|
|
1059
|
+
/* 32 bytes for signkey, 32 bytes for ephemeral key,
|
|
1060
|
+
68 bytes for AD, 6 bytes for otk index (empty for no otk) */
|
|
1061
|
+
const signKey = extra.slice(0, 32);
|
|
1062
|
+
const ephKey = extra.slice(32, 64);
|
|
1063
|
+
const ad = extra.slice(96, 164);
|
|
1064
|
+
const index = extra.slice(164, 170);
|
|
1065
|
+
return [signKey, ephKey, ad, index];
|
|
1066
|
+
case MailType.subsequent:
|
|
1067
|
+
const publicKey = extra;
|
|
1068
|
+
return [publicKey];
|
|
1069
|
+
default:
|
|
1070
|
+
return [];
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
private static getMnemonic(session: SessionSQL): string {
|
|
1075
|
+
return xMnemonic(xKDF(XUtils.decodeHex(session.fingerprint)));
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
public async close(muteEvent = false): Promise<void> {
|
|
1079
|
+
this.manuallyClosing = true;
|
|
1080
|
+
this.log.info("Manually closing client.");
|
|
1081
|
+
|
|
1082
|
+
this.socket.close();
|
|
1083
|
+
await this.database.close();
|
|
1084
|
+
|
|
1085
|
+
if (this.pingInterval) {
|
|
1086
|
+
clearInterval(this.pingInterval);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (this.mailInterval) {
|
|
1090
|
+
clearInterval(this.mailInterval);
|
|
1091
|
+
}
|
|
1092
|
+
delete this.xKeyRing;
|
|
1093
|
+
|
|
1094
|
+
if (!muteEvent) {
|
|
1095
|
+
this.emitter.emit("closed");
|
|
1096
|
+
}
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Connects your device to the chat. You must have a valid Bearer token.
|
|
1102
|
+
* You can check whoami() to see before calling connect().
|
|
1103
|
+
*/
|
|
1104
|
+
public async connect(): Promise<void> {
|
|
1105
|
+
const { token, user } = await this.whoami();
|
|
1106
|
+
this.token = token;
|
|
1107
|
+
this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
|
|
1108
|
+
this.setUser(user);
|
|
1109
|
+
|
|
1110
|
+
this.device = await this.retrieveOrCreateDevice();
|
|
1111
|
+
|
|
1112
|
+
const connectToken = await this.getToken("connect");
|
|
1113
|
+
if (!connectToken) {
|
|
1114
|
+
throw new Error("Couldn't get connect token.");
|
|
1115
|
+
}
|
|
1116
|
+
const signed = xSign(
|
|
1117
|
+
Uint8Array.from(uuid.parse(connectToken.key)),
|
|
1118
|
+
this.signKeys.secretKey,
|
|
1119
|
+
);
|
|
1120
|
+
|
|
1121
|
+
const res = await this.http.post(
|
|
1122
|
+
this.getHost() + "/device/" + this.device.deviceID + "/connect",
|
|
1123
|
+
msgpack.encode({ signed }),
|
|
1124
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
1125
|
+
);
|
|
1126
|
+
const { deviceToken } = decodeAxios(ConnectResponseCodec, res.data);
|
|
1127
|
+
this.http.defaults.headers.common["X-Device-Token"] = deviceToken;
|
|
1128
|
+
|
|
1129
|
+
this.log.info("Starting websocket.");
|
|
1130
|
+
this.initSocket();
|
|
1131
|
+
// Yield the event loop so the WS open callback fires and sends the
|
|
1132
|
+
// auth message before OTK generation blocks for ~5s on mobile.
|
|
1133
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
1134
|
+
await this.negotiateOTK();
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Manually closes the client. Emits the closed event on successful shutdown.
|
|
1139
|
+
*/
|
|
1140
|
+
/**
|
|
1141
|
+
* Delete all local data — message history, encryption sessions, and prekeys.
|
|
1142
|
+
* Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
|
|
1143
|
+
*/
|
|
1144
|
+
public async deleteAllData(): Promise<void> {
|
|
1145
|
+
await this.database.purgeHistory();
|
|
1146
|
+
await this.database.purgeKeyData();
|
|
1147
|
+
await this.close(true);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Returns the current HTTP API origin with protocol.
|
|
1152
|
+
*
|
|
1153
|
+
* @example
|
|
1154
|
+
* ```ts
|
|
1155
|
+
* console.log(client.getHost()); // "https://api.vex.wtf"
|
|
1156
|
+
* ```
|
|
1157
|
+
*/
|
|
1158
|
+
public getHost() {
|
|
1159
|
+
return this.prefixes.HTTP + this.host;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Gets the hex string representations of the public and private keys.
|
|
1164
|
+
*/
|
|
1165
|
+
public getKeys(): Keys {
|
|
1166
|
+
return {
|
|
1167
|
+
private: XUtils.encodeHex(this.signKeys.secretKey),
|
|
1168
|
+
public: XUtils.encodeHex(this.signKeys.publicKey),
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Authenticates with username/password and stores the Bearer auth token.
|
|
1174
|
+
*
|
|
1175
|
+
* @param username Account username.
|
|
1176
|
+
* @param password Account password.
|
|
1177
|
+
* @returns `{ ok: true }` on success, `{ ok: false, error }` on failure.
|
|
1178
|
+
*
|
|
1179
|
+
* @example
|
|
1180
|
+
* ```ts
|
|
1181
|
+
* const result = await client.login("alice", "correct horse battery staple");
|
|
1182
|
+
* if (!result.ok) console.error(result.error);
|
|
1183
|
+
* ```
|
|
1184
|
+
*/
|
|
1185
|
+
public async login(
|
|
1186
|
+
username: string,
|
|
1187
|
+
password: string,
|
|
1188
|
+
): Promise<{ error?: string; ok: boolean }> {
|
|
1189
|
+
try {
|
|
1190
|
+
const res = await this.http.post(
|
|
1191
|
+
this.getHost() + "/auth",
|
|
1192
|
+
msgpack.encode({
|
|
1193
|
+
password,
|
|
1194
|
+
username,
|
|
1195
|
+
}),
|
|
1196
|
+
{
|
|
1197
|
+
headers: { "Content-Type": "application/msgpack" },
|
|
1198
|
+
},
|
|
1199
|
+
);
|
|
1200
|
+
const { token, user } = decodeAxios(AuthResponseCodec, res.data);
|
|
1201
|
+
|
|
1202
|
+
this.setUser(user);
|
|
1203
|
+
this.token = token;
|
|
1204
|
+
this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
|
|
1205
|
+
return { ok: true };
|
|
1206
|
+
} catch (err: unknown) {
|
|
1207
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
1208
|
+
this.log.error("Login failed: " + error);
|
|
1209
|
+
return { error, ok: false };
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Authenticates using the device's Ed25519 signing key.
|
|
1215
|
+
* No password needed — proves possession of the private key via
|
|
1216
|
+
* challenge-response. Issues a short-lived (1-hour) JWT.
|
|
1217
|
+
*
|
|
1218
|
+
* Used by auto-login when stored credentials have a deviceKey
|
|
1219
|
+
* but no valid session.
|
|
1220
|
+
*/
|
|
1221
|
+
public async loginWithDeviceKey(deviceID?: string): Promise<Error | null> {
|
|
1222
|
+
try {
|
|
1223
|
+
const id = deviceID ?? this.device?.deviceID;
|
|
1224
|
+
if (!id) {
|
|
1225
|
+
return new Error("No deviceID — pass it or connect first.");
|
|
1226
|
+
}
|
|
1227
|
+
const signKeyHex = XUtils.encodeHex(this.signKeys.publicKey);
|
|
1228
|
+
|
|
1229
|
+
const challengeRes = await this.http.post(
|
|
1230
|
+
this.getHost() + "/auth/device",
|
|
1231
|
+
msgpack.encode({
|
|
1232
|
+
deviceID: id,
|
|
1233
|
+
signKey: signKeyHex,
|
|
1234
|
+
}),
|
|
1235
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
1236
|
+
);
|
|
1237
|
+
const { challenge, challengeID } = decodeAxios(
|
|
1238
|
+
DeviceChallengeCodec,
|
|
1239
|
+
challengeRes.data,
|
|
1240
|
+
);
|
|
1241
|
+
|
|
1242
|
+
const signed = XUtils.encodeHex(
|
|
1243
|
+
xSign(XUtils.decodeHex(challenge), this.signKeys.secretKey),
|
|
1244
|
+
);
|
|
1245
|
+
|
|
1246
|
+
const verifyRes = await this.http.post(
|
|
1247
|
+
this.getHost() + "/auth/device/verify",
|
|
1248
|
+
msgpack.encode({ challengeID, signed }),
|
|
1249
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
1250
|
+
);
|
|
1251
|
+
const { token, user } = decodeAxios(
|
|
1252
|
+
AuthResponseCodec,
|
|
1253
|
+
verifyRes.data,
|
|
1254
|
+
);
|
|
1255
|
+
|
|
1256
|
+
this.setUser(user);
|
|
1257
|
+
this.token = token;
|
|
1258
|
+
this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
|
|
1259
|
+
} catch (err: unknown) {
|
|
1260
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1261
|
+
this.log.error("Device-key auth failed: " + error.message);
|
|
1262
|
+
return error;
|
|
1263
|
+
}
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Logs out the current authenticated session from the server.
|
|
1269
|
+
*/
|
|
1270
|
+
public async logout(): Promise<void> {
|
|
1271
|
+
await this.http.post(this.getHost() + "/goodbye");
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
off<E extends keyof ClientEvents>(
|
|
1275
|
+
event: E,
|
|
1276
|
+
fn?: ClientEvents[E],
|
|
1277
|
+
context?: unknown,
|
|
1278
|
+
): this {
|
|
1279
|
+
this.emitter.off(
|
|
1280
|
+
event,
|
|
1281
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ee3 requires generic listener type; E constraint guarantees safety
|
|
1282
|
+
fn as ((...args: unknown[]) => void) | undefined,
|
|
1283
|
+
context,
|
|
1284
|
+
);
|
|
1285
|
+
return this;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
on<E extends keyof ClientEvents>(
|
|
1289
|
+
event: E,
|
|
1290
|
+
fn: ClientEvents[E],
|
|
1291
|
+
context?: unknown,
|
|
1292
|
+
): this {
|
|
1293
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- EventEmitter requires a generic listener type; the generic constraint on E guarantees type safety
|
|
1294
|
+
this.emitter.on(event, fn as (...args: unknown[]) => void, context);
|
|
1295
|
+
return this;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
once<E extends keyof ClientEvents>(
|
|
1299
|
+
event: E,
|
|
1300
|
+
fn: ClientEvents[E],
|
|
1301
|
+
context?: unknown,
|
|
1302
|
+
): this {
|
|
1303
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- EventEmitter requires a generic listener type; the generic constraint on E guarantees type safety
|
|
1304
|
+
this.emitter.once(event, fn as (...args: unknown[]) => void, context);
|
|
1305
|
+
return this;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Registers a new account on the server.
|
|
1310
|
+
* @param username The username to register. Must be unique.
|
|
1311
|
+
*
|
|
1312
|
+
* @returns The error, or the user object.
|
|
1313
|
+
*
|
|
1314
|
+
* @example [user, err] = await client.register("MyUsername");
|
|
1315
|
+
*/
|
|
1316
|
+
public async register(
|
|
1317
|
+
username: string,
|
|
1318
|
+
password: string,
|
|
1319
|
+
): Promise<[null | User, Error | null]> {
|
|
1320
|
+
while (!this.xKeyRing) {
|
|
1321
|
+
await sleep(100);
|
|
1322
|
+
}
|
|
1323
|
+
const regKey = await this.getToken("register");
|
|
1324
|
+
if (regKey) {
|
|
1325
|
+
const signKey = XUtils.encodeHex(this.signKeys.publicKey);
|
|
1326
|
+
const signed = XUtils.encodeHex(
|
|
1327
|
+
xSign(
|
|
1328
|
+
Uint8Array.from(uuid.parse(regKey.key)),
|
|
1329
|
+
this.signKeys.secretKey,
|
|
1330
|
+
),
|
|
1331
|
+
);
|
|
1332
|
+
const preKeyIndex = this.xKeyRing.preKeys.index;
|
|
1333
|
+
const regMsg: RegistrationPayload = {
|
|
1334
|
+
deviceName: this.options?.deviceName ?? "unknown",
|
|
1335
|
+
password,
|
|
1336
|
+
preKey: XUtils.encodeHex(
|
|
1337
|
+
this.xKeyRing.preKeys.keyPair.publicKey,
|
|
1338
|
+
),
|
|
1339
|
+
preKeyIndex,
|
|
1340
|
+
preKeySignature: XUtils.encodeHex(
|
|
1341
|
+
this.xKeyRing.preKeys.signature,
|
|
1342
|
+
),
|
|
1343
|
+
signed,
|
|
1344
|
+
signKey,
|
|
1345
|
+
username,
|
|
1346
|
+
};
|
|
1347
|
+
try {
|
|
1348
|
+
const res = await this.http.post(
|
|
1349
|
+
this.getHost() + "/register",
|
|
1350
|
+
msgpack.encode(regMsg),
|
|
1351
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
1352
|
+
);
|
|
1353
|
+
this.setUser(decodeAxios(UserCodec, res.data));
|
|
1354
|
+
return [this.getUser(), null];
|
|
1355
|
+
} catch (err: unknown) {
|
|
1356
|
+
if (isAxiosError(err) && err.response) {
|
|
1357
|
+
return [null, new Error(String(err.response.data))];
|
|
1358
|
+
}
|
|
1359
|
+
return [
|
|
1360
|
+
null,
|
|
1361
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
1362
|
+
];
|
|
1363
|
+
}
|
|
1364
|
+
} else {
|
|
1365
|
+
return [null, new Error("Couldn't get regkey from server.")];
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
removeAllListeners(event?: keyof ClientEvents): this {
|
|
1370
|
+
this.emitter.removeAllListeners(event);
|
|
1371
|
+
return this;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Returns a compact `<username><deviceID>` debug label.
|
|
1376
|
+
*/
|
|
1377
|
+
public toString(): string {
|
|
1378
|
+
return (
|
|
1379
|
+
(this.user?.username ?? "") +
|
|
1380
|
+
"<" +
|
|
1381
|
+
(this.device?.deviceID ?? "") +
|
|
1382
|
+
">"
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Returns details about the currently authenticated session.
|
|
1388
|
+
*
|
|
1389
|
+
* @returns The authenticated user, token expiry, and active token.
|
|
1390
|
+
*
|
|
1391
|
+
* @example
|
|
1392
|
+
* ```ts
|
|
1393
|
+
* const auth = await client.whoami();
|
|
1394
|
+
* console.log(auth.user.username, new Date(auth.exp));
|
|
1395
|
+
* ```
|
|
1396
|
+
*/
|
|
1397
|
+
public async whoami(): Promise<{
|
|
1398
|
+
exp: number;
|
|
1399
|
+
token: string;
|
|
1400
|
+
user: User;
|
|
1401
|
+
}> {
|
|
1402
|
+
const res = await this.http.post(this.getHost() + "/whoami");
|
|
1403
|
+
|
|
1404
|
+
const whoami = decodeAxios(WhoamiCodec, res.data);
|
|
1405
|
+
return whoami;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
|
|
1409
|
+
if (!preKey.index) {
|
|
1410
|
+
throw new Error("Key index is required.");
|
|
1411
|
+
}
|
|
1412
|
+
return {
|
|
1413
|
+
deviceID: this.getDevice().deviceID,
|
|
1414
|
+
index: preKey.index,
|
|
1415
|
+
publicKey: XUtils.decodeHex(preKey.publicKey),
|
|
1416
|
+
signature: XUtils.decodeHex(preKey.signature),
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
private async createChannel(
|
|
1421
|
+
name: string,
|
|
1422
|
+
serverID: string,
|
|
1423
|
+
): Promise<Channel> {
|
|
1424
|
+
const body = { name };
|
|
1425
|
+
const res = await this.http.post(
|
|
1426
|
+
this.getHost() + "/server/" + serverID + "/channels",
|
|
1427
|
+
msgpack.encode(body),
|
|
1428
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
1429
|
+
);
|
|
1430
|
+
return decodeAxios(ChannelCodec, res.data);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// returns the file details and the encryption key
|
|
1434
|
+
private async createFile(file: Uint8Array): Promise<[FileSQL, string]> {
|
|
1435
|
+
this.log.info("Creating file, size: " + formatBytes(file.byteLength));
|
|
1436
|
+
|
|
1437
|
+
const nonce = xMakeNonce();
|
|
1438
|
+
const key = xBoxKeyPair();
|
|
1439
|
+
const box = xSecretbox(Uint8Array.from(file), nonce, key.secretKey);
|
|
1440
|
+
|
|
1441
|
+
this.log.info("Encrypted size: " + formatBytes(box.byteLength));
|
|
1442
|
+
|
|
1443
|
+
if (typeof FormData !== "undefined") {
|
|
1444
|
+
const fpayload = new FormData();
|
|
1445
|
+
fpayload.set("owner", this.getDevice().deviceID);
|
|
1446
|
+
fpayload.set("nonce", XUtils.encodeHex(nonce));
|
|
1447
|
+
fpayload.set("file", new Blob([new Uint8Array(box)]));
|
|
1448
|
+
|
|
1449
|
+
const fres = await this.http.post(
|
|
1450
|
+
this.getHost() + "/file",
|
|
1451
|
+
fpayload,
|
|
1452
|
+
{
|
|
1453
|
+
headers: { "Content-Type": "multipart/form-data" },
|
|
1454
|
+
onUploadProgress: (progressEvent) => {
|
|
1455
|
+
const percentCompleted = Math.round(
|
|
1456
|
+
(progressEvent.loaded * 100) /
|
|
1457
|
+
(progressEvent.total ?? 1),
|
|
1458
|
+
);
|
|
1459
|
+
const { loaded, total = 0 } = progressEvent;
|
|
1460
|
+
const progress: FileProgress = {
|
|
1461
|
+
direction: "upload",
|
|
1462
|
+
loaded,
|
|
1463
|
+
progress: percentCompleted,
|
|
1464
|
+
token: XUtils.encodeHex(nonce),
|
|
1465
|
+
total,
|
|
1466
|
+
};
|
|
1467
|
+
this.emitter.emit("fileProgress", progress);
|
|
1468
|
+
},
|
|
1469
|
+
},
|
|
1470
|
+
);
|
|
1471
|
+
const fcreatedFile = decodeAxios(FileSQLCodec, fres.data);
|
|
1472
|
+
|
|
1473
|
+
return [fcreatedFile, XUtils.encodeHex(key.secretKey)];
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const payload: {
|
|
1477
|
+
file: string;
|
|
1478
|
+
nonce: string;
|
|
1479
|
+
owner: string;
|
|
1480
|
+
} = {
|
|
1481
|
+
file: XUtils.encodeBase64(box),
|
|
1482
|
+
nonce: XUtils.encodeHex(nonce),
|
|
1483
|
+
owner: this.getDevice().deviceID,
|
|
1484
|
+
};
|
|
1485
|
+
const res = await this.http.post(
|
|
1486
|
+
this.getHost() + "/file/json",
|
|
1487
|
+
msgpack.encode(payload),
|
|
1488
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
1489
|
+
);
|
|
1490
|
+
const createdFile = decodeAxios(FileSQLCodec, res.data);
|
|
1491
|
+
|
|
1492
|
+
return [createdFile, XUtils.encodeHex(key.secretKey)];
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
private async createInvite(serverID: string, duration: string) {
|
|
1496
|
+
const payload = {
|
|
1497
|
+
duration,
|
|
1498
|
+
serverID,
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
const res = await this.http.post(
|
|
1502
|
+
this.getHost() + "/server/" + serverID + "/invites",
|
|
1503
|
+
payload,
|
|
1504
|
+
);
|
|
1505
|
+
|
|
1506
|
+
return decodeAxios(InviteCodec, res.data);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
private createPreKey(): UnsavedPreKey {
|
|
1510
|
+
const preKeyPair = xBoxKeyPair();
|
|
1511
|
+
return {
|
|
1512
|
+
keyPair: preKeyPair,
|
|
1513
|
+
signature: xSign(
|
|
1514
|
+
xEncode(xConstants.CURVE, preKeyPair.publicKey),
|
|
1515
|
+
this.signKeys.secretKey,
|
|
1516
|
+
),
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
private async createServer(name: string): Promise<Server> {
|
|
1521
|
+
const res = await this.http.post(
|
|
1522
|
+
this.getHost() + "/server/" + globalThis.btoa(name),
|
|
1523
|
+
);
|
|
1524
|
+
return decodeAxios(ServerCodec, res.data);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
private async createSession(
|
|
1528
|
+
device: Device,
|
|
1529
|
+
user: User,
|
|
1530
|
+
message: Uint8Array,
|
|
1531
|
+
group: null | Uint8Array,
|
|
1532
|
+
/* this is passed through if the first message is
|
|
1533
|
+
part of a group message */
|
|
1534
|
+
mailID: null | string,
|
|
1535
|
+
forward: boolean,
|
|
1536
|
+
): Promise<void> {
|
|
1537
|
+
let keyBundle: KeyBundle;
|
|
1538
|
+
|
|
1539
|
+
this.log.info(
|
|
1540
|
+
"Requesting key bundle for device: " +
|
|
1541
|
+
JSON.stringify(device, null, 4),
|
|
1542
|
+
);
|
|
1543
|
+
try {
|
|
1544
|
+
keyBundle = await this.retrieveKeyBundle(device.deviceID);
|
|
1545
|
+
} catch (err: unknown) {
|
|
1546
|
+
this.log.warn(
|
|
1547
|
+
"Couldn't get key bundle:",
|
|
1548
|
+
err instanceof Error ? err.message : String(err),
|
|
1549
|
+
);
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
this.log.warn(
|
|
1554
|
+
this.toString() +
|
|
1555
|
+
" retrieved keybundle #" +
|
|
1556
|
+
String(keyBundle.otk?.index ?? "none") +
|
|
1557
|
+
" for " +
|
|
1558
|
+
device.deviceID,
|
|
1559
|
+
);
|
|
1560
|
+
|
|
1561
|
+
if (!this.xKeyRing) {
|
|
1562
|
+
throw new Error("Key ring not initialized.");
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// my keys
|
|
1566
|
+
const IK_A = this.xKeyRing.identityKeys.secretKey;
|
|
1567
|
+
const IK_AP = this.xKeyRing.identityKeys.publicKey;
|
|
1568
|
+
const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
|
|
1569
|
+
|
|
1570
|
+
// their keys
|
|
1571
|
+
const IK_B_raw = XKeyConvert.convertPublicKey(
|
|
1572
|
+
new Uint8Array(keyBundle.signKey),
|
|
1573
|
+
);
|
|
1574
|
+
if (!IK_B_raw) {
|
|
1575
|
+
throw new Error("Could not convert sign key to X25519.");
|
|
1576
|
+
}
|
|
1577
|
+
const IK_B = IK_B_raw;
|
|
1578
|
+
const SPK_B = new Uint8Array(keyBundle.preKey.publicKey);
|
|
1579
|
+
const OPK_B = keyBundle.otk
|
|
1580
|
+
? new Uint8Array(keyBundle.otk.publicKey)
|
|
1581
|
+
: null;
|
|
1582
|
+
|
|
1583
|
+
// diffie hellman functions
|
|
1584
|
+
const DH1 = xDH(new Uint8Array(IK_A), SPK_B);
|
|
1585
|
+
const DH2 = xDH(new Uint8Array(EK_A), IK_B);
|
|
1586
|
+
const DH3 = xDH(new Uint8Array(EK_A), SPK_B);
|
|
1587
|
+
const DH4 = OPK_B ? xDH(new Uint8Array(EK_A), OPK_B) : null;
|
|
1588
|
+
|
|
1589
|
+
// initial key material
|
|
1590
|
+
const IKM = DH4 ? xConcat(DH1, DH2, DH3, DH4) : xConcat(DH1, DH2, DH3);
|
|
1591
|
+
|
|
1592
|
+
// one time key index
|
|
1593
|
+
const IDX = keyBundle.otk
|
|
1594
|
+
? XUtils.numberToUint8Arr(keyBundle.otk.index ?? 0)
|
|
1595
|
+
: XUtils.numberToUint8Arr(0);
|
|
1596
|
+
|
|
1597
|
+
// shared secret key
|
|
1598
|
+
const SK = xKDF(IKM);
|
|
1599
|
+
this.log.info("Obtained SK, " + XUtils.encodeHex(SK));
|
|
1600
|
+
|
|
1601
|
+
const PK = xBoxKeyPairFromSecret(SK).publicKey;
|
|
1602
|
+
this.log.info(
|
|
1603
|
+
this.toString() +
|
|
1604
|
+
" Obtained PK for " +
|
|
1605
|
+
device.deviceID +
|
|
1606
|
+
" " +
|
|
1607
|
+
XUtils.encodeHex(PK),
|
|
1608
|
+
);
|
|
1609
|
+
|
|
1610
|
+
const AD = xConcat(
|
|
1611
|
+
xEncode(xConstants.CURVE, IK_AP),
|
|
1612
|
+
xEncode(xConstants.CURVE, IK_B),
|
|
1613
|
+
);
|
|
1614
|
+
|
|
1615
|
+
const nonce = xMakeNonce();
|
|
1616
|
+
const cipher = xSecretbox(message, nonce, SK);
|
|
1617
|
+
|
|
1618
|
+
this.log.info("Encrypted ciphertext.");
|
|
1619
|
+
|
|
1620
|
+
/* 32 bytes for signkey, 32 bytes for ephemeral key,
|
|
1621
|
+
68 bytes for AD, 6 bytes for otk index (empty for no otk) */
|
|
1622
|
+
const extra = xConcat(
|
|
1623
|
+
this.signKeys.publicKey,
|
|
1624
|
+
this.xKeyRing.ephemeralKeys.publicKey,
|
|
1625
|
+
PK,
|
|
1626
|
+
AD,
|
|
1627
|
+
IDX,
|
|
1628
|
+
);
|
|
1629
|
+
|
|
1630
|
+
const mail: MailWS = {
|
|
1631
|
+
authorID: this.getUser().userID,
|
|
1632
|
+
cipher,
|
|
1633
|
+
extra,
|
|
1634
|
+
forward,
|
|
1635
|
+
group,
|
|
1636
|
+
mailID: mailID || uuid.v4(),
|
|
1637
|
+
mailType: MailType.initial,
|
|
1638
|
+
nonce,
|
|
1639
|
+
readerID: user.userID,
|
|
1640
|
+
recipient: device.deviceID,
|
|
1641
|
+
sender: this.getDevice().deviceID,
|
|
1642
|
+
};
|
|
1643
|
+
|
|
1644
|
+
const hmac = xHMAC(mail, SK);
|
|
1645
|
+
this.log.info("Mail hash: " + JSON.stringify(mail));
|
|
1646
|
+
this.log.info("Generated hmac: " + XUtils.encodeHex(hmac));
|
|
1647
|
+
|
|
1648
|
+
const msg: ResourceMsg = {
|
|
1649
|
+
action: "CREATE",
|
|
1650
|
+
data: mail,
|
|
1651
|
+
resourceType: "mail",
|
|
1652
|
+
transmissionID: uuid.v4(),
|
|
1653
|
+
type: "resource",
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
// discard the ephemeral keys
|
|
1657
|
+
this.newEphemeralKeys();
|
|
1658
|
+
|
|
1659
|
+
// save the encryption session
|
|
1660
|
+
this.log.info("Saving new session.");
|
|
1661
|
+
const sessionEntry: SessionSQL = {
|
|
1662
|
+
deviceID: device.deviceID,
|
|
1663
|
+
fingerprint: XUtils.encodeHex(AD),
|
|
1664
|
+
lastUsed: new Date().toISOString(),
|
|
1665
|
+
mode: "initiator",
|
|
1666
|
+
publicKey: XUtils.encodeHex(PK),
|
|
1667
|
+
sessionID: uuid.v4(),
|
|
1668
|
+
SK: XUtils.encodeHex(SK),
|
|
1669
|
+
userID: user.userID,
|
|
1670
|
+
verified: false,
|
|
1671
|
+
};
|
|
1672
|
+
|
|
1673
|
+
await this.database.saveSession(sessionEntry);
|
|
1674
|
+
|
|
1675
|
+
this.emitter.emit("session", sessionEntry, user);
|
|
1676
|
+
|
|
1677
|
+
// emit the message
|
|
1678
|
+
const forwardedMsg = forward
|
|
1679
|
+
? messageSchema.parse(msgpack.decode(message))
|
|
1680
|
+
: null;
|
|
1681
|
+
const emitMsg: Message = forwardedMsg
|
|
1682
|
+
? { ...forwardedMsg, forward: true }
|
|
1683
|
+
: {
|
|
1684
|
+
authorID: mail.authorID,
|
|
1685
|
+
decrypted: true,
|
|
1686
|
+
direction: "outgoing",
|
|
1687
|
+
forward: mail.forward,
|
|
1688
|
+
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
1689
|
+
mailID: mail.mailID,
|
|
1690
|
+
message: XUtils.encodeUTF8(message),
|
|
1691
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
1692
|
+
readerID: mail.readerID,
|
|
1693
|
+
recipient: mail.recipient,
|
|
1694
|
+
sender: mail.sender,
|
|
1695
|
+
timestamp: new Date().toISOString(),
|
|
1696
|
+
};
|
|
1697
|
+
this.emitter.emit("message", emitMsg);
|
|
1698
|
+
|
|
1699
|
+
// send mail and wait for response
|
|
1700
|
+
await new Promise((res, rej) => {
|
|
1701
|
+
const callback = (packedMsg: Uint8Array) => {
|
|
1702
|
+
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
1703
|
+
if (receivedMsg.transmissionID === msg.transmissionID) {
|
|
1704
|
+
this.socket.off("message", callback);
|
|
1705
|
+
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
1706
|
+
if (parsed.success && parsed.data.type === "success") {
|
|
1707
|
+
res(parsed.data.data);
|
|
1708
|
+
} else {
|
|
1709
|
+
rej(
|
|
1710
|
+
new Error(
|
|
1711
|
+
"Mail delivery failed: " +
|
|
1712
|
+
JSON.stringify(receivedMsg),
|
|
1713
|
+
),
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
this.socket.on("message", callback);
|
|
1719
|
+
void this.send(msg, hmac);
|
|
1720
|
+
this.log.info("Mail sent.");
|
|
1721
|
+
});
|
|
1722
|
+
this.sending.delete(device.deviceID);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
private async deleteChannel(channelID: string): Promise<void> {
|
|
1726
|
+
await this.http.delete(this.getHost() + "/channel/" + channelID);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
private async deleteDevice(deviceID: string): Promise<void> {
|
|
1730
|
+
if (deviceID === this.getDevice().deviceID) {
|
|
1731
|
+
throw new Error("You can't delete the device you're logged in to.");
|
|
1732
|
+
}
|
|
1733
|
+
await this.http.delete(
|
|
1734
|
+
this.prefixes.HTTP +
|
|
1735
|
+
this.host +
|
|
1736
|
+
"/user/" +
|
|
1737
|
+
this.getUser().userID +
|
|
1738
|
+
"/devices/" +
|
|
1739
|
+
deviceID,
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
private async deleteHistory(channelOrUserID: string): Promise<void> {
|
|
1744
|
+
await this.database.deleteHistory(channelOrUserID);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
private async deletePermission(permissionID: string): Promise<void> {
|
|
1748
|
+
await this.http.delete(this.getHost() + "/permission/" + permissionID);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
private async deleteServer(serverID: string): Promise<void> {
|
|
1752
|
+
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Gets a list of permissions for a server.
|
|
1756
|
+
*
|
|
1757
|
+
* @returns - The list of Permissions objects.
|
|
1758
|
+
*/
|
|
1759
|
+
private async fetchPermissionList(serverID: string): Promise<Permission[]> {
|
|
1760
|
+
const res = await this.http.get(
|
|
1761
|
+
this.prefixes.HTTP +
|
|
1762
|
+
this.host +
|
|
1763
|
+
"/server/" +
|
|
1764
|
+
serverID +
|
|
1765
|
+
"/permissions",
|
|
1766
|
+
);
|
|
1767
|
+
return decodeAxios(PermissionArrayCodec, res.data);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
private async fetchUser(
|
|
1771
|
+
userIdentifier: string,
|
|
1772
|
+
): Promise<[null | User, AxiosError | null]> {
|
|
1773
|
+
// Positive cache
|
|
1774
|
+
if (userIdentifier in this.userRecords) {
|
|
1775
|
+
return [this.userRecords[userIdentifier] ?? null, null];
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// Negative cache — skip users we know don't exist (TTL-based)
|
|
1779
|
+
const notFoundAt = this.notFoundUsers.get(userIdentifier);
|
|
1780
|
+
if (notFoundAt && Date.now() - notFoundAt < Client.NOT_FOUND_TTL) {
|
|
1781
|
+
return [null, null];
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
try {
|
|
1785
|
+
const res = await this.http.get(
|
|
1786
|
+
this.getHost() + "/user/" + userIdentifier,
|
|
1787
|
+
);
|
|
1788
|
+
const userRecord = decodeAxios(UserCodec, res.data);
|
|
1789
|
+
this.userRecords[userIdentifier] = userRecord;
|
|
1790
|
+
this.notFoundUsers.delete(userIdentifier);
|
|
1791
|
+
return [userRecord, null];
|
|
1792
|
+
} catch (err: unknown) {
|
|
1793
|
+
if (isAxiosError(err) && err.response?.status === 404) {
|
|
1794
|
+
// Definitive: user doesn't exist — cache and don't retry
|
|
1795
|
+
this.notFoundUsers.set(userIdentifier, Date.now());
|
|
1796
|
+
return [null, err];
|
|
1797
|
+
}
|
|
1798
|
+
// Transient (5xx, network error) — don't cache, caller can retry
|
|
1799
|
+
return [null, isAxiosError(err) ? err : null];
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
private async forward(message: Message) {
|
|
1804
|
+
const copy = { ...message };
|
|
1805
|
+
|
|
1806
|
+
if (this.forwarded.has(copy.mailID)) {
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
this.forwarded.add(copy.mailID);
|
|
1810
|
+
if (this.forwarded.size > 1000) {
|
|
1811
|
+
// Remove oldest entry
|
|
1812
|
+
const first = this.forwarded.values().next().value;
|
|
1813
|
+
if (first !== undefined) this.forwarded.delete(first);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
const msgBytes = Uint8Array.from(msgpack.encode(copy));
|
|
1817
|
+
|
|
1818
|
+
const devices = await this.getUserDeviceList(this.getUser().userID);
|
|
1819
|
+
this.log.info(
|
|
1820
|
+
"Forwarding to my other devices, deviceList length is " +
|
|
1821
|
+
String(devices?.length ?? 0),
|
|
1822
|
+
);
|
|
1823
|
+
|
|
1824
|
+
if (!devices) {
|
|
1825
|
+
throw new Error("Couldn't get own devices.");
|
|
1826
|
+
}
|
|
1827
|
+
const promises = [];
|
|
1828
|
+
for (const device of devices) {
|
|
1829
|
+
if (device.deviceID !== this.getDevice().deviceID) {
|
|
1830
|
+
promises.push(
|
|
1831
|
+
this.sendMail(
|
|
1832
|
+
device,
|
|
1833
|
+
this.getUser(),
|
|
1834
|
+
msgBytes,
|
|
1835
|
+
null,
|
|
1836
|
+
copy.mailID,
|
|
1837
|
+
true,
|
|
1838
|
+
),
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
void Promise.allSettled(promises).then((results) => {
|
|
1843
|
+
for (const result of results) {
|
|
1844
|
+
const { status } = result;
|
|
1845
|
+
if (status === "rejected") {
|
|
1846
|
+
this.log.warn("Message failed.");
|
|
1847
|
+
this.log.warn(JSON.stringify(result));
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
private async getChannelByID(channelID: string): Promise<Channel | null> {
|
|
1854
|
+
try {
|
|
1855
|
+
const res = await this.http.get(
|
|
1856
|
+
this.getHost() + "/channel/" + channelID,
|
|
1857
|
+
);
|
|
1858
|
+
return decodeAxios(ChannelCodec, res.data);
|
|
1859
|
+
} catch (_err: unknown) {
|
|
1860
|
+
return null;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
private async getChannelList(serverID: string): Promise<Channel[]> {
|
|
1865
|
+
const res = await this.http.get(
|
|
1866
|
+
this.getHost() + "/server/" + serverID + "/channels",
|
|
1867
|
+
);
|
|
1868
|
+
return decodeAxios(ChannelArrayCodec, res.data);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
private getDevice(): Device {
|
|
1872
|
+
if (!this.device) {
|
|
1873
|
+
throw new Error(
|
|
1874
|
+
"You must wait until the auth event is emitted before fetching device details.",
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
return this.device;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
private async getDeviceByID(deviceID: string): Promise<Device | null> {
|
|
1881
|
+
if (deviceID in this.deviceRecords) {
|
|
1882
|
+
this.log.info("Found device in local cache.");
|
|
1883
|
+
return this.deviceRecords[deviceID] ?? null;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
const device = await this.database.getDevice(deviceID);
|
|
1887
|
+
if (device) {
|
|
1888
|
+
this.log.info("Found device in local db.");
|
|
1889
|
+
this.deviceRecords[deviceID] = device;
|
|
1890
|
+
return device;
|
|
1891
|
+
}
|
|
1892
|
+
try {
|
|
1893
|
+
const res = await this.http.get(
|
|
1894
|
+
this.getHost() + "/device/" + deviceID,
|
|
1895
|
+
);
|
|
1896
|
+
this.log.info("Retrieved device from server.");
|
|
1897
|
+
const fetchedDevice = decodeAxios(DeviceCodec, res.data);
|
|
1898
|
+
this.deviceRecords[deviceID] = fetchedDevice;
|
|
1899
|
+
await this.database.saveDevice(fetchedDevice);
|
|
1900
|
+
return fetchedDevice;
|
|
1901
|
+
} catch (_err: unknown) {
|
|
1902
|
+
return null;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
/* Retrieves the current list of users you have sessions with. */
|
|
1907
|
+
private async getFamiliars(): Promise<User[]> {
|
|
1908
|
+
const sessions = await this.database.getAllSessions();
|
|
1909
|
+
const familiars: User[] = [];
|
|
1910
|
+
|
|
1911
|
+
for (const session of sessions) {
|
|
1912
|
+
const [user, _err] = await this.fetchUser(session.userID);
|
|
1913
|
+
if (user) {
|
|
1914
|
+
familiars.push(user);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
return familiars;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
private async getGroupHistory(channelID: string): Promise<Message[]> {
|
|
1922
|
+
const messages: Message[] =
|
|
1923
|
+
await this.database.getGroupHistory(channelID);
|
|
1924
|
+
|
|
1925
|
+
return messages;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
private async getMail(): Promise<void> {
|
|
1929
|
+
while (this.fetchingMail) {
|
|
1930
|
+
await sleep(500);
|
|
1931
|
+
}
|
|
1932
|
+
this.fetchingMail = true;
|
|
1933
|
+
let firstFetch = false;
|
|
1934
|
+
if (this.firstMailFetch) {
|
|
1935
|
+
firstFetch = true;
|
|
1936
|
+
this.firstMailFetch = false;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
if (firstFetch) {
|
|
1940
|
+
this.emitter.emit("decryptingMail");
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
this.log.info("fetching mail for device " + this.getDevice().deviceID);
|
|
1944
|
+
try {
|
|
1945
|
+
const res = await this.http.post<ArrayBuffer>(
|
|
1946
|
+
this.getHost() +
|
|
1947
|
+
"/device/" +
|
|
1948
|
+
this.getDevice().deviceID +
|
|
1949
|
+
"/mail",
|
|
1950
|
+
);
|
|
1951
|
+
const mailBuffer = new Uint8Array(res.data);
|
|
1952
|
+
const rawInbox = z
|
|
1953
|
+
.array(mailInboxEntry)
|
|
1954
|
+
.parse(msgpack.decode(mailBuffer));
|
|
1955
|
+
const inbox = rawInbox.sort((a, b) => b[2].localeCompare(a[2]));
|
|
1956
|
+
|
|
1957
|
+
for (const mailDetails of inbox) {
|
|
1958
|
+
const [mailHeader, mailBody, timestamp] = mailDetails;
|
|
1959
|
+
try {
|
|
1960
|
+
await this.readMail(mailHeader, mailBody, timestamp);
|
|
1961
|
+
} catch (err: unknown) {
|
|
1962
|
+
console.warn(String(err));
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
} catch (err: unknown) {
|
|
1966
|
+
console.warn(String(err));
|
|
1967
|
+
}
|
|
1968
|
+
this.fetchingMail = false;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
private async getMessageHistory(userID: string): Promise<Message[]> {
|
|
1972
|
+
const messages: Message[] =
|
|
1973
|
+
await this.database.getMessageHistory(userID);
|
|
1974
|
+
|
|
1975
|
+
return messages;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
private async getMultiUserDeviceList(userIDs: string[]): Promise<Device[]> {
|
|
1979
|
+
try {
|
|
1980
|
+
const res = await this.http.post(
|
|
1981
|
+
this.getHost() + "/deviceList",
|
|
1982
|
+
msgpack.encode(userIDs),
|
|
1983
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
1984
|
+
);
|
|
1985
|
+
const devices = decodeAxios(DeviceArrayCodec, res.data);
|
|
1986
|
+
for (const device of devices) {
|
|
1987
|
+
this.deviceRecords[device.deviceID] = device;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
return devices;
|
|
1991
|
+
} catch (_err: unknown) {
|
|
1992
|
+
return [];
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
private async getOTKCount(): Promise<number> {
|
|
1997
|
+
const res = await this.http.get(
|
|
1998
|
+
this.getHost() +
|
|
1999
|
+
"/device/" +
|
|
2000
|
+
this.getDevice().deviceID +
|
|
2001
|
+
"/otk/count",
|
|
2002
|
+
);
|
|
2003
|
+
return decodeAxios(OtkCountCodec, res.data).count;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
/**
|
|
2007
|
+
* Gets all permissions for the logged in user.
|
|
2008
|
+
*
|
|
2009
|
+
* @returns - The list of Permissions objects.
|
|
2010
|
+
*/
|
|
2011
|
+
private async getPermissions(): Promise<Permission[]> {
|
|
2012
|
+
const res = await this.http.get(
|
|
2013
|
+
this.getHost() + "/user/" + this.getUser().userID + "/permissions",
|
|
2014
|
+
);
|
|
2015
|
+
return decodeAxios(PermissionArrayCodec, res.data);
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
private async getServerByID(serverID: string): Promise<null | Server> {
|
|
2019
|
+
try {
|
|
2020
|
+
const res = await this.http.get(
|
|
2021
|
+
this.getHost() + "/server/" + serverID,
|
|
2022
|
+
);
|
|
2023
|
+
return decodeAxios(ServerCodec, res.data);
|
|
2024
|
+
} catch (_err: unknown) {
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
private async getServerList(): Promise<Server[]> {
|
|
2030
|
+
const res = await this.http.get(
|
|
2031
|
+
this.getHost() + "/user/" + this.getUser().userID + "/servers",
|
|
2032
|
+
);
|
|
2033
|
+
return decodeAxios(ServerArrayCodec, res.data);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
private async getSessionByPubkey(publicKey: Uint8Array) {
|
|
2037
|
+
const strPubKey = XUtils.encodeHex(publicKey);
|
|
2038
|
+
if (strPubKey in this.sessionRecords) {
|
|
2039
|
+
return this.sessionRecords[strPubKey];
|
|
2040
|
+
}
|
|
2041
|
+
const session = await this.database.getSessionByPublicKey(publicKey);
|
|
2042
|
+
if (session) {
|
|
2043
|
+
this.sessionRecords[strPubKey] = session;
|
|
2044
|
+
}
|
|
2045
|
+
return session;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
private async getSessionList() {
|
|
2049
|
+
return this.database.getAllSessions();
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
private async getToken(
|
|
2053
|
+
type:
|
|
2054
|
+
| "avatar"
|
|
2055
|
+
| "connect"
|
|
2056
|
+
| "device"
|
|
2057
|
+
| "emoji"
|
|
2058
|
+
| "file"
|
|
2059
|
+
| "invite"
|
|
2060
|
+
| "register",
|
|
2061
|
+
): Promise<ActionToken | null> {
|
|
2062
|
+
try {
|
|
2063
|
+
const res = await this.http.get(this.getHost() + "/token/" + type, {
|
|
2064
|
+
responseType: "arraybuffer",
|
|
2065
|
+
});
|
|
2066
|
+
return decodeAxios(ActionTokenCodec, res.data);
|
|
2067
|
+
} catch (err: unknown) {
|
|
2068
|
+
this.log.warn(String(err));
|
|
2069
|
+
return null;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/* Get the currently logged in user. You cannot call this until
|
|
2074
|
+
after the auth event is emitted. */
|
|
2075
|
+
private getUser(): User {
|
|
2076
|
+
if (!this.user) {
|
|
2077
|
+
throw new Error(
|
|
2078
|
+
"You must wait until the auth event is emitted before fetching user details.",
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
return this.user;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
private async getUserDeviceList(userID: string): Promise<Device[] | null> {
|
|
2085
|
+
try {
|
|
2086
|
+
const res = await this.http.get(
|
|
2087
|
+
this.getHost() + "/user/" + userID + "/devices",
|
|
2088
|
+
);
|
|
2089
|
+
const devices = decodeAxios(DeviceArrayCodec, res.data);
|
|
2090
|
+
for (const device of devices) {
|
|
2091
|
+
this.deviceRecords[device.deviceID] = device;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
return devices;
|
|
2095
|
+
} catch (_err: unknown) {
|
|
2096
|
+
return null;
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
private async getUserList(channelID: string): Promise<User[]> {
|
|
2101
|
+
const res = await this.http.post(
|
|
2102
|
+
this.getHost() + "/userList/" + channelID,
|
|
2103
|
+
);
|
|
2104
|
+
return decodeAxios(UserArrayCodec, res.data);
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
private async handleNotify(msg: NotifyMsg) {
|
|
2108
|
+
switch (msg.event) {
|
|
2109
|
+
case "mail":
|
|
2110
|
+
this.log.info("Server has informed us of new mail.");
|
|
2111
|
+
await this.getMail();
|
|
2112
|
+
this.fetchingMail = false;
|
|
2113
|
+
break;
|
|
2114
|
+
case "permission":
|
|
2115
|
+
this.emitter.emit(
|
|
2116
|
+
"permission",
|
|
2117
|
+
PermissionSchema.parse(msg.data),
|
|
2118
|
+
);
|
|
2119
|
+
break;
|
|
2120
|
+
case "retryRequest":
|
|
2121
|
+
// msg.data is the messageID for retry
|
|
2122
|
+
break;
|
|
2123
|
+
default:
|
|
2124
|
+
this.log.info("Unsupported notification event " + msg.event);
|
|
2125
|
+
break;
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/**
|
|
2130
|
+
* Initializes the keyring. This must be called before anything else.
|
|
2131
|
+
*/
|
|
2132
|
+
private async init(): Promise<void> {
|
|
2133
|
+
if (this.hasInit) {
|
|
2134
|
+
throw new Error("You should only call init() once.");
|
|
2135
|
+
}
|
|
2136
|
+
this.hasInit = true;
|
|
2137
|
+
|
|
2138
|
+
await this.populateKeyRing();
|
|
2139
|
+
this.emitter.on("message", (message) => {
|
|
2140
|
+
if (message.direction === "outgoing" && !message.forward) {
|
|
2141
|
+
void this.forward(message);
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
if (
|
|
2145
|
+
message.direction === "incoming" &&
|
|
2146
|
+
message.recipient === message.sender
|
|
2147
|
+
) {
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
void this.database.saveMessage(message);
|
|
2151
|
+
});
|
|
2152
|
+
this.emitter.emit("ready");
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
private initSocket() {
|
|
2156
|
+
try {
|
|
2157
|
+
if (!this.token) {
|
|
2158
|
+
throw new Error("No token found, did you call login()?");
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
const wsUrl = this.prefixes.WS + this.host + "/socket";
|
|
2162
|
+
// Auth sent as first message after open
|
|
2163
|
+
this.socket = new WebSocketAdapter(wsUrl);
|
|
2164
|
+
this.socket.on("open", () => {
|
|
2165
|
+
this.log.info("Connection opened.");
|
|
2166
|
+
// Send auth as first message (encoded to bytes — protocol is binary).
|
|
2167
|
+
const authMsg = JSON.stringify({
|
|
2168
|
+
token: this.token,
|
|
2169
|
+
type: "auth",
|
|
2170
|
+
});
|
|
2171
|
+
this.socket.send(new TextEncoder().encode(authMsg));
|
|
2172
|
+
this.pingInterval = setInterval(this.ping.bind(this), 15000);
|
|
2173
|
+
});
|
|
2174
|
+
|
|
2175
|
+
this.socket.on("close", () => {
|
|
2176
|
+
this.log.info("Connection closed.");
|
|
2177
|
+
if (this.pingInterval) {
|
|
2178
|
+
clearInterval(this.pingInterval);
|
|
2179
|
+
this.pingInterval = null;
|
|
2180
|
+
}
|
|
2181
|
+
if (!this.manuallyClosing) {
|
|
2182
|
+
this.emitter.emit("disconnect");
|
|
2183
|
+
}
|
|
2184
|
+
});
|
|
2185
|
+
|
|
2186
|
+
this.socket.on("error", (error: Error) => {
|
|
2187
|
+
throw error;
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
this.socket.on("message", (message: Uint8Array) => {
|
|
2191
|
+
const [header, raw] = XUtils.unpackMessage(message);
|
|
2192
|
+
|
|
2193
|
+
this.log.debug("INH " + XUtils.encodeHex(header));
|
|
2194
|
+
this.log.debug("IN " + JSON.stringify(raw, null, 4));
|
|
2195
|
+
|
|
2196
|
+
const parseResult = WSMessageSchema.safeParse(raw);
|
|
2197
|
+
if (!parseResult.success) {
|
|
2198
|
+
this.log.warn("Unknown WS message: " + JSON.stringify(raw));
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
const msg = parseResult.data;
|
|
2202
|
+
|
|
2203
|
+
switch (msg.type) {
|
|
2204
|
+
case "challenge":
|
|
2205
|
+
this.log.info("Received challenge from server.");
|
|
2206
|
+
this.respond(msg);
|
|
2207
|
+
break;
|
|
2208
|
+
case "error":
|
|
2209
|
+
this.log.warn(JSON.stringify(msg));
|
|
2210
|
+
break;
|
|
2211
|
+
case "notify":
|
|
2212
|
+
void this.handleNotify(msg);
|
|
2213
|
+
break;
|
|
2214
|
+
case "ping":
|
|
2215
|
+
this.pong(msg.transmissionID);
|
|
2216
|
+
break;
|
|
2217
|
+
case "pong":
|
|
2218
|
+
this.setAlive(true);
|
|
2219
|
+
break;
|
|
2220
|
+
case "success":
|
|
2221
|
+
break;
|
|
2222
|
+
case "unauthorized":
|
|
2223
|
+
throw new Error(
|
|
2224
|
+
"Received unauthorized message from server.",
|
|
2225
|
+
);
|
|
2226
|
+
case "authorized":
|
|
2227
|
+
this.log.info(
|
|
2228
|
+
"Authenticated with userID " +
|
|
2229
|
+
(this.user?.userID ?? "unknown"),
|
|
2230
|
+
);
|
|
2231
|
+
this.emitter.emit("connected");
|
|
2232
|
+
void this.postAuth();
|
|
2233
|
+
break;
|
|
2234
|
+
default:
|
|
2235
|
+
this.log.info("Unsupported message " + msg.type);
|
|
2236
|
+
break;
|
|
2237
|
+
}
|
|
2238
|
+
});
|
|
2239
|
+
} catch (err: unknown) {
|
|
2240
|
+
throw new Error(
|
|
2241
|
+
"Error initiating websocket connection " + String(err),
|
|
2242
|
+
);
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
private async kickUser(userID: string, serverID: string): Promise<void> {
|
|
2247
|
+
const permissionList = await this.fetchPermissionList(serverID);
|
|
2248
|
+
for (const permission of permissionList) {
|
|
2249
|
+
if (userID === permission.userID) {
|
|
2250
|
+
await this.deletePermission(permission.permissionID);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
throw new Error("Couldn't kick user.");
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
private async leaveServer(serverID: string): Promise<void> {
|
|
2258
|
+
const permissionList = await this.permissions.retrieve();
|
|
2259
|
+
for (const permission of permissionList) {
|
|
2260
|
+
if (permission.resourceID === serverID) {
|
|
2261
|
+
await this.deletePermission(permission.permissionID);
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
private async markSessionVerified(sessionID: string) {
|
|
2267
|
+
return this.database.markSessionVerified(sessionID);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
private async negotiateOTK() {
|
|
2271
|
+
const otkCount = await this.getOTKCount();
|
|
2272
|
+
this.log.info("Server reported OTK: " + otkCount.toString());
|
|
2273
|
+
const needs = xConstants.MIN_OTK_SUPPLY - otkCount;
|
|
2274
|
+
if (needs === 0) {
|
|
2275
|
+
this.log.info("Server otk supply full.");
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
await this.submitOTK(needs);
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
private newEphemeralKeys() {
|
|
2283
|
+
if (!this.xKeyRing) {
|
|
2284
|
+
throw new Error("Key ring not initialized.");
|
|
2285
|
+
}
|
|
2286
|
+
this.xKeyRing.ephemeralKeys = xBoxKeyPair();
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
private ping() {
|
|
2290
|
+
if (!this.isAlive) {
|
|
2291
|
+
this.log.warn("Ping failed.");
|
|
2292
|
+
}
|
|
2293
|
+
this.setAlive(false);
|
|
2294
|
+
void this.send({ transmissionID: uuid.v4(), type: "ping" });
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
private pong(transmissionID: string) {
|
|
2298
|
+
void this.send({ transmissionID, type: "pong" });
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
private async populateKeyRing() {
|
|
2302
|
+
// we've checked in the constructor that these exist
|
|
2303
|
+
if (!this.idKeys) {
|
|
2304
|
+
throw new Error("Identity keys are missing.");
|
|
2305
|
+
}
|
|
2306
|
+
const identityKeys = this.idKeys;
|
|
2307
|
+
|
|
2308
|
+
const existingPreKeys = await this.database.getPreKeys();
|
|
2309
|
+
const preKeys: PreKeysCrypto =
|
|
2310
|
+
existingPreKeys ??
|
|
2311
|
+
(await (async () => {
|
|
2312
|
+
this.log.warn(
|
|
2313
|
+
"No prekeys found in database, creating a new one.",
|
|
2314
|
+
);
|
|
2315
|
+
const unsaved = this.createPreKey();
|
|
2316
|
+
const [saved] = await this.database.savePreKeys(
|
|
2317
|
+
[unsaved],
|
|
2318
|
+
false,
|
|
2319
|
+
);
|
|
2320
|
+
if (!saved || saved.index == null)
|
|
2321
|
+
throw new Error(
|
|
2322
|
+
"Failed to save prekey — no index returned.",
|
|
2323
|
+
);
|
|
2324
|
+
return { ...unsaved, index: saved.index };
|
|
2325
|
+
})());
|
|
2326
|
+
|
|
2327
|
+
const sessions = await this.database.getAllSessions();
|
|
2328
|
+
for (const session of sessions) {
|
|
2329
|
+
this.sessionRecords[session.publicKey] =
|
|
2330
|
+
sqlSessionToCrypto(session);
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
const ephemeralKeys = xBoxKeyPair();
|
|
2334
|
+
|
|
2335
|
+
this.xKeyRing = {
|
|
2336
|
+
ephemeralKeys,
|
|
2337
|
+
identityKeys,
|
|
2338
|
+
preKeys,
|
|
2339
|
+
};
|
|
2340
|
+
|
|
2341
|
+
this.log.info(
|
|
2342
|
+
"Keyring populated:\n" +
|
|
2343
|
+
JSON.stringify(
|
|
2344
|
+
{
|
|
2345
|
+
ephemeralKey: XUtils.encodeHex(ephemeralKeys.publicKey),
|
|
2346
|
+
preKey: XUtils.encodeHex(preKeys.keyPair.publicKey),
|
|
2347
|
+
signKey: XUtils.encodeHex(this.signKeys.publicKey),
|
|
2348
|
+
},
|
|
2349
|
+
null,
|
|
2350
|
+
4,
|
|
2351
|
+
),
|
|
2352
|
+
);
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
private async postAuth() {
|
|
2356
|
+
let count = 0;
|
|
2357
|
+
for (;;) {
|
|
2358
|
+
try {
|
|
2359
|
+
await this.getMail();
|
|
2360
|
+
count++;
|
|
2361
|
+
this.fetchingMail = false;
|
|
2362
|
+
|
|
2363
|
+
if (count > 10) {
|
|
2364
|
+
void this.negotiateOTK();
|
|
2365
|
+
count = 0;
|
|
2366
|
+
}
|
|
2367
|
+
} catch (err: unknown) {
|
|
2368
|
+
this.log.warn("Problem fetching mail" + String(err));
|
|
2369
|
+
}
|
|
2370
|
+
await sleep(1000 * 60);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
private async purgeHistory(): Promise<void> {
|
|
2375
|
+
await this.database.purgeHistory();
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
private async readMail(
|
|
2379
|
+
header: Uint8Array,
|
|
2380
|
+
mail: MailWS,
|
|
2381
|
+
timestamp: string,
|
|
2382
|
+
) {
|
|
2383
|
+
this.sendReceipt(new Uint8Array(mail.nonce));
|
|
2384
|
+
let timeout = 1;
|
|
2385
|
+
while (this.reading) {
|
|
2386
|
+
await sleep(timeout);
|
|
2387
|
+
timeout *= 2;
|
|
2388
|
+
}
|
|
2389
|
+
this.reading = true;
|
|
2390
|
+
|
|
2391
|
+
try {
|
|
2392
|
+
const healSession = async () => {
|
|
2393
|
+
this.log.info("Requesting retry of " + mail.mailID);
|
|
2394
|
+
const deviceEntry = await this.getDeviceByID(mail.sender);
|
|
2395
|
+
const [user, _err] = await this.fetchUser(mail.authorID);
|
|
2396
|
+
if (deviceEntry && user) {
|
|
2397
|
+
void this.createSession(
|
|
2398
|
+
deviceEntry,
|
|
2399
|
+
user,
|
|
2400
|
+
XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`),
|
|
2401
|
+
mail.group,
|
|
2402
|
+
uuid.v4(),
|
|
2403
|
+
false,
|
|
2404
|
+
);
|
|
2405
|
+
}
|
|
2406
|
+
};
|
|
2407
|
+
|
|
2408
|
+
this.log.info("Received mail from " + mail.sender);
|
|
2409
|
+
switch (mail.mailType) {
|
|
2410
|
+
case MailType.initial:
|
|
2411
|
+
this.log.info("Initiating new session.");
|
|
2412
|
+
const extraParts = Client.deserializeExtra(
|
|
2413
|
+
MailType.initial,
|
|
2414
|
+
new Uint8Array(mail.extra),
|
|
2415
|
+
);
|
|
2416
|
+
const signKey = extraParts[0];
|
|
2417
|
+
const ephKey = extraParts[1];
|
|
2418
|
+
const indexBytes = extraParts[3];
|
|
2419
|
+
if (!signKey || !ephKey || !indexBytes) {
|
|
2420
|
+
throw new Error(
|
|
2421
|
+
"Malformed initial mail extra: missing signKey, ephKey, or indexBytes",
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
|
|
2426
|
+
|
|
2427
|
+
this.log.info(
|
|
2428
|
+
this.toString() +
|
|
2429
|
+
" otk #" +
|
|
2430
|
+
String(preKeyIndex) +
|
|
2431
|
+
" indicated",
|
|
2432
|
+
);
|
|
2433
|
+
|
|
2434
|
+
const otk =
|
|
2435
|
+
preKeyIndex === 0
|
|
2436
|
+
? null
|
|
2437
|
+
: await this.database.getOneTimeKey(preKeyIndex);
|
|
2438
|
+
|
|
2439
|
+
if (otk) {
|
|
2440
|
+
this.log.info(
|
|
2441
|
+
"otk #" +
|
|
2442
|
+
JSON.stringify(otk.index) +
|
|
2443
|
+
" retrieved from database.",
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
this.log.info("signKey: " + XUtils.encodeHex(signKey));
|
|
2448
|
+
this.log.info("preKey: " + XUtils.encodeHex(ephKey));
|
|
2449
|
+
if (otk) {
|
|
2450
|
+
this.log.info(
|
|
2451
|
+
"OTK: " + XUtils.encodeHex(otk.keyPair.publicKey),
|
|
2452
|
+
);
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
|
|
2456
|
+
this.log.warn(
|
|
2457
|
+
"OTK index mismatch, received " +
|
|
2458
|
+
JSON.stringify(otk?.index) +
|
|
2459
|
+
", expected " +
|
|
2460
|
+
preKeyIndex.toString(),
|
|
2461
|
+
);
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// their public keys
|
|
2466
|
+
const IK_A_raw = XKeyConvert.convertPublicKey(signKey);
|
|
2467
|
+
if (!IK_A_raw) {
|
|
2468
|
+
this.log.warn("Could not convert sign key to X25519.");
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
const IK_A = IK_A_raw;
|
|
2472
|
+
const EK_A = ephKey;
|
|
2473
|
+
|
|
2474
|
+
if (!this.xKeyRing) {
|
|
2475
|
+
throw new Error("Key ring not initialized.");
|
|
2476
|
+
}
|
|
2477
|
+
// my private keys
|
|
2478
|
+
const IK_B = this.xKeyRing.identityKeys.secretKey;
|
|
2479
|
+
const IK_BP = this.xKeyRing.identityKeys.publicKey;
|
|
2480
|
+
const SPK_B = this.xKeyRing.preKeys.keyPair.secretKey;
|
|
2481
|
+
const OPK_B = otk ? otk.keyPair.secretKey : null;
|
|
2482
|
+
|
|
2483
|
+
// diffie hellman functions
|
|
2484
|
+
const DH1 = xDH(SPK_B, IK_A);
|
|
2485
|
+
const DH2 = xDH(IK_B, EK_A);
|
|
2486
|
+
const DH3 = xDH(SPK_B, EK_A);
|
|
2487
|
+
const DH4 = OPK_B ? xDH(OPK_B, EK_A) : null;
|
|
2488
|
+
|
|
2489
|
+
// initial key material
|
|
2490
|
+
const IKM = DH4
|
|
2491
|
+
? xConcat(DH1, DH2, DH3, DH4)
|
|
2492
|
+
: xConcat(DH1, DH2, DH3);
|
|
2493
|
+
|
|
2494
|
+
// shared secret key
|
|
2495
|
+
const SK = xKDF(IKM);
|
|
2496
|
+
this.log.info(
|
|
2497
|
+
"Obtained SK for " +
|
|
2498
|
+
mail.sender +
|
|
2499
|
+
", " +
|
|
2500
|
+
XUtils.encodeHex(SK),
|
|
2501
|
+
);
|
|
2502
|
+
|
|
2503
|
+
// shared public key
|
|
2504
|
+
const PK = xBoxKeyPairFromSecret(SK).publicKey;
|
|
2505
|
+
this.log.info(
|
|
2506
|
+
this.toString() +
|
|
2507
|
+
"Obtained PK for " +
|
|
2508
|
+
mail.sender +
|
|
2509
|
+
" " +
|
|
2510
|
+
XUtils.encodeHex(PK),
|
|
2511
|
+
);
|
|
2512
|
+
|
|
2513
|
+
const hmac = xHMAC(mail, SK);
|
|
2514
|
+
this.log.info("Mail hash: " + JSON.stringify(mail));
|
|
2515
|
+
this.log.info("Calculated hmac: " + XUtils.encodeHex(hmac));
|
|
2516
|
+
|
|
2517
|
+
// associated data
|
|
2518
|
+
const AD = xConcat(
|
|
2519
|
+
xEncode(xConstants.CURVE, IK_A),
|
|
2520
|
+
xEncode(xConstants.CURVE, IK_BP),
|
|
2521
|
+
);
|
|
2522
|
+
|
|
2523
|
+
if (!XUtils.bytesEqual(hmac, header)) {
|
|
2524
|
+
console.warn(
|
|
2525
|
+
"Mail authentication failed (HMAC did not match).",
|
|
2526
|
+
);
|
|
2527
|
+
console.warn(mail);
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
this.log.info("Mail authenticated successfully.");
|
|
2531
|
+
|
|
2532
|
+
const unsealed = xSecretboxOpen(
|
|
2533
|
+
new Uint8Array(mail.cipher),
|
|
2534
|
+
new Uint8Array(mail.nonce),
|
|
2535
|
+
SK,
|
|
2536
|
+
);
|
|
2537
|
+
if (unsealed) {
|
|
2538
|
+
this.log.info("Decryption successful.");
|
|
2539
|
+
|
|
2540
|
+
let plaintext = "";
|
|
2541
|
+
if (!mail.forward) {
|
|
2542
|
+
plaintext = XUtils.encodeUTF8(unsealed);
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// emit the message
|
|
2546
|
+
const fwdMsg1 = mail.forward
|
|
2547
|
+
? messageSchema.parse(msgpack.decode(unsealed))
|
|
2548
|
+
: null;
|
|
2549
|
+
const message: Message = fwdMsg1
|
|
2550
|
+
? { ...fwdMsg1, forward: true }
|
|
2551
|
+
: {
|
|
2552
|
+
authorID: mail.authorID,
|
|
2553
|
+
decrypted: true,
|
|
2554
|
+
direction: "incoming",
|
|
2555
|
+
forward: mail.forward,
|
|
2556
|
+
group: mail.group
|
|
2557
|
+
? uuid.stringify(mail.group)
|
|
2558
|
+
: null,
|
|
2559
|
+
mailID: mail.mailID,
|
|
2560
|
+
message: plaintext,
|
|
2561
|
+
nonce: XUtils.encodeHex(
|
|
2562
|
+
new Uint8Array(mail.nonce),
|
|
2563
|
+
),
|
|
2564
|
+
readerID: mail.readerID,
|
|
2565
|
+
recipient: mail.recipient,
|
|
2566
|
+
sender: mail.sender,
|
|
2567
|
+
timestamp: timestamp,
|
|
2568
|
+
};
|
|
2569
|
+
|
|
2570
|
+
this.emitter.emit("message", message);
|
|
2571
|
+
|
|
2572
|
+
// discard onetimekey
|
|
2573
|
+
await this.database.deleteOneTimeKey(preKeyIndex);
|
|
2574
|
+
|
|
2575
|
+
const deviceEntry = await this.getDeviceByID(
|
|
2576
|
+
mail.sender,
|
|
2577
|
+
);
|
|
2578
|
+
if (!deviceEntry) {
|
|
2579
|
+
throw new Error("Couldn't get device entry.");
|
|
2580
|
+
}
|
|
2581
|
+
const [userEntry, _userErr] = await this.fetchUser(
|
|
2582
|
+
deviceEntry.owner,
|
|
2583
|
+
);
|
|
2584
|
+
if (!userEntry) {
|
|
2585
|
+
throw new Error("Couldn't get user entry.");
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
this.userRecords[userEntry.userID] = userEntry;
|
|
2589
|
+
this.deviceRecords[deviceEntry.deviceID] = deviceEntry;
|
|
2590
|
+
|
|
2591
|
+
// save session
|
|
2592
|
+
const newSession: SessionSQL = {
|
|
2593
|
+
deviceID: mail.sender,
|
|
2594
|
+
fingerprint: XUtils.encodeHex(AD),
|
|
2595
|
+
lastUsed: new Date().toISOString(),
|
|
2596
|
+
mode: "receiver",
|
|
2597
|
+
publicKey: XUtils.encodeHex(PK),
|
|
2598
|
+
sessionID: uuid.v4(),
|
|
2599
|
+
SK: XUtils.encodeHex(SK),
|
|
2600
|
+
userID: userEntry.userID,
|
|
2601
|
+
verified: false,
|
|
2602
|
+
};
|
|
2603
|
+
await this.database.saveSession(newSession);
|
|
2604
|
+
|
|
2605
|
+
const [user] = await this.fetchUser(newSession.userID);
|
|
2606
|
+
|
|
2607
|
+
if (user) {
|
|
2608
|
+
this.emitter.emit("session", newSession, user);
|
|
2609
|
+
} else {
|
|
2610
|
+
this.log.warn(
|
|
2611
|
+
"Couldn't retrieve user " + newSession.userID,
|
|
2612
|
+
);
|
|
2613
|
+
}
|
|
2614
|
+
} else {
|
|
2615
|
+
this.log.warn("Mail decryption failed.");
|
|
2616
|
+
}
|
|
2617
|
+
break;
|
|
2618
|
+
case MailType.subsequent:
|
|
2619
|
+
const publicKey = Client.deserializeExtra(
|
|
2620
|
+
mail.mailType,
|
|
2621
|
+
new Uint8Array(mail.extra),
|
|
2622
|
+
)[0];
|
|
2623
|
+
if (!publicKey) {
|
|
2624
|
+
throw new Error(
|
|
2625
|
+
"Malformed subsequent mail extra: missing publicKey",
|
|
2626
|
+
);
|
|
2627
|
+
}
|
|
2628
|
+
let session = await this.getSessionByPubkey(publicKey);
|
|
2629
|
+
let retries = 0;
|
|
2630
|
+
while (!session) {
|
|
2631
|
+
if (retries > 3) {
|
|
2632
|
+
break;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
session = await this.getSessionByPubkey(publicKey);
|
|
2636
|
+
retries++;
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
if (!session) {
|
|
2641
|
+
this.log.warn(
|
|
2642
|
+
"Couldn't find session public key " +
|
|
2643
|
+
XUtils.encodeHex(publicKey),
|
|
2644
|
+
);
|
|
2645
|
+
void healSession();
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2648
|
+
this.log.info("Session found for " + mail.sender);
|
|
2649
|
+
this.log.info(
|
|
2650
|
+
"Mail nonce " +
|
|
2651
|
+
XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
2652
|
+
);
|
|
2653
|
+
|
|
2654
|
+
const HMAC = xHMAC(mail, session.SK);
|
|
2655
|
+
this.log.info("Mail hash: " + JSON.stringify(mail));
|
|
2656
|
+
this.log.info("Calculated hmac: " + XUtils.encodeHex(HMAC));
|
|
2657
|
+
|
|
2658
|
+
if (!XUtils.bytesEqual(HMAC, header)) {
|
|
2659
|
+
this.log.warn(
|
|
2660
|
+
"Message authentication failed (HMAC does not match).",
|
|
2661
|
+
);
|
|
2662
|
+
void healSession();
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
const decrypted = xSecretboxOpen(
|
|
2667
|
+
new Uint8Array(mail.cipher),
|
|
2668
|
+
new Uint8Array(mail.nonce),
|
|
2669
|
+
session.SK,
|
|
2670
|
+
);
|
|
2671
|
+
|
|
2672
|
+
if (decrypted) {
|
|
2673
|
+
this.log.info("Decryption successful.");
|
|
2674
|
+
// emit the message
|
|
2675
|
+
const fwdMsg2 = mail.forward
|
|
2676
|
+
? messageSchema.parse(msgpack.decode(decrypted))
|
|
2677
|
+
: null;
|
|
2678
|
+
const message: Message = fwdMsg2
|
|
2679
|
+
? {
|
|
2680
|
+
...fwdMsg2,
|
|
2681
|
+
forward: true,
|
|
2682
|
+
}
|
|
2683
|
+
: {
|
|
2684
|
+
authorID: mail.authorID,
|
|
2685
|
+
decrypted: true,
|
|
2686
|
+
direction: "incoming",
|
|
2687
|
+
forward: mail.forward,
|
|
2688
|
+
group: mail.group
|
|
2689
|
+
? uuid.stringify(mail.group)
|
|
2690
|
+
: null,
|
|
2691
|
+
mailID: mail.mailID,
|
|
2692
|
+
message: XUtils.encodeUTF8(decrypted),
|
|
2693
|
+
nonce: XUtils.encodeHex(
|
|
2694
|
+
new Uint8Array(mail.nonce),
|
|
2695
|
+
),
|
|
2696
|
+
readerID: mail.readerID,
|
|
2697
|
+
recipient: mail.recipient,
|
|
2698
|
+
sender: mail.sender,
|
|
2699
|
+
timestamp: timestamp,
|
|
2700
|
+
};
|
|
2701
|
+
this.emitter.emit("message", message);
|
|
2702
|
+
|
|
2703
|
+
void this.database.markSessionUsed(session.sessionID);
|
|
2704
|
+
} else {
|
|
2705
|
+
this.log.info("Decryption failed.");
|
|
2706
|
+
void healSession();
|
|
2707
|
+
|
|
2708
|
+
// emit the message
|
|
2709
|
+
const message: Message = {
|
|
2710
|
+
authorID: mail.authorID,
|
|
2711
|
+
decrypted: false,
|
|
2712
|
+
direction: "incoming",
|
|
2713
|
+
forward: mail.forward,
|
|
2714
|
+
group: mail.group
|
|
2715
|
+
? uuid.stringify(mail.group)
|
|
2716
|
+
: null,
|
|
2717
|
+
mailID: mail.mailID,
|
|
2718
|
+
message: "",
|
|
2719
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
2720
|
+
readerID: mail.readerID,
|
|
2721
|
+
recipient: mail.recipient,
|
|
2722
|
+
sender: mail.sender,
|
|
2723
|
+
timestamp: timestamp,
|
|
2724
|
+
};
|
|
2725
|
+
this.emitter.emit("message", message);
|
|
2726
|
+
}
|
|
2727
|
+
break;
|
|
2728
|
+
default:
|
|
2729
|
+
this.log.warn("Unsupported MailType:", mail.mailType);
|
|
2730
|
+
break;
|
|
2731
|
+
}
|
|
2732
|
+
} finally {
|
|
2733
|
+
this.reading = false;
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
private async redeemInvite(inviteID: string): Promise<Permission> {
|
|
2738
|
+
const res = await this.http.patch(
|
|
2739
|
+
this.getHost() + "/invite/" + inviteID,
|
|
2740
|
+
);
|
|
2741
|
+
return decodeAxios(PermissionCodec, res.data);
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
private async registerDevice(): Promise<Device | null> {
|
|
2745
|
+
while (!this.xKeyRing) {
|
|
2746
|
+
await sleep(100);
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
const token = await this.getToken("device");
|
|
2750
|
+
|
|
2751
|
+
const username = this.user?.username;
|
|
2752
|
+
if (!username) {
|
|
2753
|
+
throw new Error("No user set — log in first.");
|
|
2754
|
+
}
|
|
2755
|
+
const [userDetails, err] = await this.fetchUser(username);
|
|
2756
|
+
if (!userDetails) {
|
|
2757
|
+
throw new Error("Username not found " + username);
|
|
2758
|
+
}
|
|
2759
|
+
if (err) {
|
|
2760
|
+
throw err;
|
|
2761
|
+
}
|
|
2762
|
+
if (!token) {
|
|
2763
|
+
throw new Error("Couldn't fetch token.");
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
const signKey = this.getKeys().public;
|
|
2767
|
+
const signed = XUtils.encodeHex(
|
|
2768
|
+
xSign(
|
|
2769
|
+
Uint8Array.from(uuid.parse(token.key)),
|
|
2770
|
+
this.signKeys.secretKey,
|
|
2771
|
+
),
|
|
2772
|
+
);
|
|
2773
|
+
|
|
2774
|
+
const devPreKeyIndex = this.xKeyRing.preKeys.index;
|
|
2775
|
+
const devMsg: DevicePayload = {
|
|
2776
|
+
deviceName: this.options?.deviceName ?? "unknown",
|
|
2777
|
+
preKey: XUtils.encodeHex(this.xKeyRing.preKeys.keyPair.publicKey),
|
|
2778
|
+
preKeyIndex: devPreKeyIndex,
|
|
2779
|
+
preKeySignature: XUtils.encodeHex(this.xKeyRing.preKeys.signature),
|
|
2780
|
+
signed,
|
|
2781
|
+
signKey,
|
|
2782
|
+
username: userDetails.username,
|
|
2783
|
+
};
|
|
2784
|
+
|
|
2785
|
+
const res = await this.http.post(
|
|
2786
|
+
this.prefixes.HTTP +
|
|
2787
|
+
this.host +
|
|
2788
|
+
"/user/" +
|
|
2789
|
+
userDetails.userID +
|
|
2790
|
+
"/devices",
|
|
2791
|
+
msgpack.encode(devMsg),
|
|
2792
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
2793
|
+
);
|
|
2794
|
+
return decodeAxios(DeviceCodec, res.data);
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
private respond(msg: ChallMsg) {
|
|
2798
|
+
const response: RespMsg = {
|
|
2799
|
+
signed: xSign(
|
|
2800
|
+
new Uint8Array(msg.challenge),
|
|
2801
|
+
this.signKeys.secretKey,
|
|
2802
|
+
),
|
|
2803
|
+
transmissionID: msg.transmissionID,
|
|
2804
|
+
type: "response",
|
|
2805
|
+
};
|
|
2806
|
+
void this.send(response);
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
private async retrieveEmojiByID(emojiID: string): Promise<Emoji | null> {
|
|
2810
|
+
const res = await this.http.get(
|
|
2811
|
+
this.getHost() + "/emoji/" + emojiID + "/details",
|
|
2812
|
+
);
|
|
2813
|
+
if (!res.data) {
|
|
2814
|
+
return null;
|
|
2815
|
+
}
|
|
2816
|
+
return decodeAxios(EmojiCodec, res.data);
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
private async retrieveEmojiList(serverID: string): Promise<Emoji[]> {
|
|
2820
|
+
const res = await this.http.get(
|
|
2821
|
+
this.getHost() + "/server/" + serverID + "/emoji",
|
|
2822
|
+
);
|
|
2823
|
+
return decodeAxios(EmojiArrayCodec, res.data);
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
private async retrieveFile(
|
|
2827
|
+
fileID: string,
|
|
2828
|
+
key: string,
|
|
2829
|
+
): Promise<FileResponse | null> {
|
|
2830
|
+
const detailsRes = await this.http.get(
|
|
2831
|
+
this.getHost() + "/file/" + fileID + "/details",
|
|
2832
|
+
);
|
|
2833
|
+
const details = decodeAxios(FileSQLCodec, detailsRes.data);
|
|
2834
|
+
|
|
2835
|
+
const res = await this.http.get<ArrayBuffer>(
|
|
2836
|
+
this.getHost() + "/file/" + fileID,
|
|
2837
|
+
{
|
|
2838
|
+
onDownloadProgress: (progressEvent) => {
|
|
2839
|
+
const percentCompleted = Math.round(
|
|
2840
|
+
(progressEvent.loaded * 100) /
|
|
2841
|
+
(progressEvent.total ?? 1),
|
|
2842
|
+
);
|
|
2843
|
+
const { loaded, total = 0 } = progressEvent;
|
|
2844
|
+
const progress: FileProgress = {
|
|
2845
|
+
direction: "download",
|
|
2846
|
+
loaded,
|
|
2847
|
+
progress: percentCompleted,
|
|
2848
|
+
token: fileID,
|
|
2849
|
+
total,
|
|
2850
|
+
};
|
|
2851
|
+
this.emitter.emit("fileProgress", progress);
|
|
2852
|
+
},
|
|
2853
|
+
},
|
|
2854
|
+
);
|
|
2855
|
+
const fileData = res.data;
|
|
2856
|
+
|
|
2857
|
+
const decrypted = xSecretboxOpen(
|
|
2858
|
+
new Uint8Array(fileData),
|
|
2859
|
+
XUtils.decodeHex(details.nonce),
|
|
2860
|
+
XUtils.decodeHex(key),
|
|
2861
|
+
);
|
|
2862
|
+
|
|
2863
|
+
if (decrypted) {
|
|
2864
|
+
return {
|
|
2865
|
+
data: new Uint8Array(decrypted),
|
|
2866
|
+
details,
|
|
2867
|
+
};
|
|
2868
|
+
}
|
|
2869
|
+
throw new Error("Decryption failed.");
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
private async retrieveInvites(serverID: string): Promise<Invite[]> {
|
|
2873
|
+
const res = await this.http.get(
|
|
2874
|
+
this.getHost() + "/server/" + serverID + "/invites",
|
|
2875
|
+
);
|
|
2876
|
+
return decodeAxios(InviteArrayCodec, res.data);
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
private async retrieveKeyBundle(deviceID: string): Promise<KeyBundle> {
|
|
2880
|
+
const res = await this.http.post(
|
|
2881
|
+
this.getHost() + "/device/" + deviceID + "/keyBundle",
|
|
2882
|
+
);
|
|
2883
|
+
return decodeAxios(KeyBundleCodec, res.data);
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
private async retrieveOrCreateDevice(): Promise<Device> {
|
|
2887
|
+
let device: Device;
|
|
2888
|
+
try {
|
|
2889
|
+
const res = await this.http.get(
|
|
2890
|
+
this.prefixes.HTTP +
|
|
2891
|
+
this.host +
|
|
2892
|
+
"/device/" +
|
|
2893
|
+
XUtils.encodeHex(this.signKeys.publicKey),
|
|
2894
|
+
);
|
|
2895
|
+
device = decodeAxios(DeviceCodec, res.data);
|
|
2896
|
+
} catch (err: unknown) {
|
|
2897
|
+
this.log.error(err instanceof Error ? err.message : String(err));
|
|
2898
|
+
if (isAxiosError(err) && err.response?.status === 404) {
|
|
2899
|
+
// just in case
|
|
2900
|
+
await this.database.purgeKeyData();
|
|
2901
|
+
await this.populateKeyRing();
|
|
2902
|
+
|
|
2903
|
+
this.log.info("Attempting to register device.");
|
|
2904
|
+
|
|
2905
|
+
const newDevice = await this.registerDevice();
|
|
2906
|
+
if (newDevice) {
|
|
2907
|
+
device = newDevice;
|
|
2908
|
+
} else {
|
|
2909
|
+
throw new Error("Error registering device.");
|
|
2910
|
+
}
|
|
2911
|
+
} else {
|
|
2912
|
+
throw err;
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
this.log.info("Got device " + JSON.stringify(device, null, 4));
|
|
2916
|
+
return device;
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
/* header is 32 bytes and is either empty
|
|
2920
|
+
or contains an HMAC of the message with
|
|
2921
|
+
a derived SK */
|
|
2922
|
+
private async send(msg: ClientMessage, header?: Uint8Array) {
|
|
2923
|
+
let i = 0;
|
|
2924
|
+
while (this.socket.readyState !== 1) {
|
|
2925
|
+
await sleep(i);
|
|
2926
|
+
i *= 2;
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
this.log.debug(
|
|
2930
|
+
"OUTH " + XUtils.encodeHex(header || XUtils.emptyHeader()),
|
|
2931
|
+
);
|
|
2932
|
+
this.log.debug("OUT " + JSON.stringify(msg, null, 4));
|
|
2933
|
+
|
|
2934
|
+
this.socket.send(XUtils.packMessage(msg, header));
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
private async sendGroupMessage(
|
|
2938
|
+
channelID: string,
|
|
2939
|
+
message: string,
|
|
2940
|
+
): Promise<void> {
|
|
2941
|
+
const userList = await this.getUserList(channelID);
|
|
2942
|
+
for (const user of userList) {
|
|
2943
|
+
this.userRecords[user.userID] = user;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
this.log.info(
|
|
2947
|
+
"Sending to userlist:\n" + JSON.stringify(userList, null, 4),
|
|
2948
|
+
);
|
|
2949
|
+
|
|
2950
|
+
const mailID = uuid.v4();
|
|
2951
|
+
const promises: Array<Promise<void>> = [];
|
|
2952
|
+
|
|
2953
|
+
const userIDs = [...new Set(userList.map((user) => user.userID))];
|
|
2954
|
+
const devices = await this.getMultiUserDeviceList(userIDs);
|
|
2955
|
+
|
|
2956
|
+
this.log.info(
|
|
2957
|
+
"Retrieved devicelist:\n" + JSON.stringify(devices, null, 4),
|
|
2958
|
+
);
|
|
2959
|
+
|
|
2960
|
+
for (const device of devices) {
|
|
2961
|
+
const ownerRecord = this.userRecords[device.owner];
|
|
2962
|
+
if (!ownerRecord) {
|
|
2963
|
+
this.log.warn(
|
|
2964
|
+
"Skipping device " +
|
|
2965
|
+
device.deviceID +
|
|
2966
|
+
": no user record for owner " +
|
|
2967
|
+
device.owner,
|
|
2968
|
+
);
|
|
2969
|
+
continue;
|
|
2970
|
+
}
|
|
2971
|
+
promises.push(
|
|
2972
|
+
this.sendMail(
|
|
2973
|
+
device,
|
|
2974
|
+
ownerRecord,
|
|
2975
|
+
XUtils.decodeUTF8(message),
|
|
2976
|
+
uuidToUint8(channelID),
|
|
2977
|
+
mailID,
|
|
2978
|
+
false,
|
|
2979
|
+
),
|
|
2980
|
+
);
|
|
2981
|
+
}
|
|
2982
|
+
void Promise.allSettled(promises).then((results) => {
|
|
2983
|
+
for (const result of results) {
|
|
2984
|
+
const { status } = result;
|
|
2985
|
+
if (status === "rejected") {
|
|
2986
|
+
this.log.warn("Message failed.");
|
|
2987
|
+
this.log.warn(JSON.stringify(result));
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
});
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
/* Sends encrypted mail to a user. */
|
|
2994
|
+
private async sendMail(
|
|
2995
|
+
device: Device,
|
|
2996
|
+
user: User,
|
|
2997
|
+
msg: Uint8Array,
|
|
2998
|
+
group: null | Uint8Array,
|
|
2999
|
+
mailID: null | string,
|
|
3000
|
+
forward: boolean,
|
|
3001
|
+
retry = false,
|
|
3002
|
+
): Promise<void> {
|
|
3003
|
+
while (this.sending.has(device.deviceID)) {
|
|
3004
|
+
this.log.warn(
|
|
3005
|
+
"Sending in progress to device ID " +
|
|
3006
|
+
device.deviceID +
|
|
3007
|
+
", waiting.",
|
|
3008
|
+
);
|
|
3009
|
+
await sleep(100);
|
|
3010
|
+
}
|
|
3011
|
+
this.log.info(
|
|
3012
|
+
"Sending mail to user: \n" + JSON.stringify(user, null, 4),
|
|
3013
|
+
);
|
|
3014
|
+
this.log.info(
|
|
3015
|
+
"Sending mail to device:\n " +
|
|
3016
|
+
JSON.stringify(device.deviceID, null, 4),
|
|
3017
|
+
);
|
|
3018
|
+
this.sending.set(device.deviceID, device);
|
|
3019
|
+
|
|
3020
|
+
const session = await this.database.getSessionByDeviceID(
|
|
3021
|
+
device.deviceID,
|
|
3022
|
+
);
|
|
3023
|
+
|
|
3024
|
+
if (!session || retry) {
|
|
3025
|
+
this.log.info("Creating new session for " + device.deviceID);
|
|
3026
|
+
await this.createSession(device, user, msg, group, mailID, forward);
|
|
3027
|
+
return;
|
|
3028
|
+
} else {
|
|
3029
|
+
this.log.info("Found existing session for " + device.deviceID);
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
const nonce = xMakeNonce();
|
|
3033
|
+
const cipher = xSecretbox(msg, nonce, session.SK);
|
|
3034
|
+
const extra = session.publicKey;
|
|
3035
|
+
|
|
3036
|
+
const mail: MailWS = {
|
|
3037
|
+
authorID: this.getUser().userID,
|
|
3038
|
+
cipher,
|
|
3039
|
+
extra,
|
|
3040
|
+
forward,
|
|
3041
|
+
group,
|
|
3042
|
+
mailID: mailID || uuid.v4(),
|
|
3043
|
+
mailType: MailType.subsequent,
|
|
3044
|
+
nonce,
|
|
3045
|
+
readerID: session.userID,
|
|
3046
|
+
recipient: device.deviceID,
|
|
3047
|
+
sender: this.getDevice().deviceID,
|
|
3048
|
+
};
|
|
3049
|
+
|
|
3050
|
+
const msgb: ResourceMsg = {
|
|
3051
|
+
action: "CREATE",
|
|
3052
|
+
data: mail,
|
|
3053
|
+
resourceType: "mail",
|
|
3054
|
+
transmissionID: uuid.v4(),
|
|
3055
|
+
type: "resource",
|
|
3056
|
+
};
|
|
3057
|
+
|
|
3058
|
+
const hmac = xHMAC(mail, session.SK);
|
|
3059
|
+
this.log.info("Mail hash: " + JSON.stringify(mail));
|
|
3060
|
+
this.log.info("Calculated hmac: " + XUtils.encodeHex(hmac));
|
|
3061
|
+
|
|
3062
|
+
const fwdOut = forward
|
|
3063
|
+
? messageSchema.parse(msgpack.decode(msg))
|
|
3064
|
+
: null;
|
|
3065
|
+
const outMsg: Message = fwdOut
|
|
3066
|
+
? { ...fwdOut, forward: true }
|
|
3067
|
+
: {
|
|
3068
|
+
authorID: mail.authorID,
|
|
3069
|
+
decrypted: true,
|
|
3070
|
+
direction: "outgoing",
|
|
3071
|
+
forward: mail.forward,
|
|
3072
|
+
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
3073
|
+
mailID: mail.mailID,
|
|
3074
|
+
message: XUtils.encodeUTF8(msg),
|
|
3075
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
3076
|
+
readerID: mail.readerID,
|
|
3077
|
+
recipient: mail.recipient,
|
|
3078
|
+
sender: mail.sender,
|
|
3079
|
+
timestamp: new Date().toISOString(),
|
|
3080
|
+
};
|
|
3081
|
+
this.emitter.emit("message", outMsg);
|
|
3082
|
+
|
|
3083
|
+
await new Promise((res, rej) => {
|
|
3084
|
+
const callback = (packedMsg: Uint8Array) => {
|
|
3085
|
+
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
3086
|
+
if (receivedMsg.transmissionID === msgb.transmissionID) {
|
|
3087
|
+
this.socket.off("message", callback);
|
|
3088
|
+
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
3089
|
+
if (parsed.success && parsed.data.type === "success") {
|
|
3090
|
+
res(parsed.data.data);
|
|
3091
|
+
} else {
|
|
3092
|
+
rej(
|
|
3093
|
+
new Error(
|
|
3094
|
+
"Mail delivery failed: " +
|
|
3095
|
+
JSON.stringify(receivedMsg),
|
|
3096
|
+
),
|
|
3097
|
+
);
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
};
|
|
3101
|
+
this.socket.on("message", callback);
|
|
3102
|
+
void this.send(msgb, hmac);
|
|
3103
|
+
});
|
|
3104
|
+
this.sending.delete(device.deviceID);
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
private async sendMessage(userID: string, message: string): Promise<void> {
|
|
3108
|
+
try {
|
|
3109
|
+
const [userEntry, err] = await this.fetchUser(userID);
|
|
3110
|
+
if (err) {
|
|
3111
|
+
throw err;
|
|
3112
|
+
}
|
|
3113
|
+
if (!userEntry) {
|
|
3114
|
+
throw new Error("Couldn't get user entry.");
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
let deviceList = await this.getUserDeviceList(userID);
|
|
3118
|
+
if (!deviceList) {
|
|
3119
|
+
let retries = 0;
|
|
3120
|
+
while (!deviceList) {
|
|
3121
|
+
deviceList = await this.getUserDeviceList(userID);
|
|
3122
|
+
retries++;
|
|
3123
|
+
if (retries > 3) {
|
|
3124
|
+
throw new Error("Couldn't get device list.");
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
const mailID = uuid.v4();
|
|
3129
|
+
const promises: Array<Promise<void>> = [];
|
|
3130
|
+
for (const device of deviceList) {
|
|
3131
|
+
promises.push(
|
|
3132
|
+
this.sendMail(
|
|
3133
|
+
device,
|
|
3134
|
+
userEntry,
|
|
3135
|
+
XUtils.decodeUTF8(message),
|
|
3136
|
+
null,
|
|
3137
|
+
mailID,
|
|
3138
|
+
false,
|
|
3139
|
+
),
|
|
3140
|
+
);
|
|
3141
|
+
}
|
|
3142
|
+
void Promise.allSettled(promises).then((results) => {
|
|
3143
|
+
for (const result of results) {
|
|
3144
|
+
const { status } = result;
|
|
3145
|
+
if (status === "rejected") {
|
|
3146
|
+
this.log.warn("Message failed.");
|
|
3147
|
+
this.log.warn(JSON.stringify(result));
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
});
|
|
3151
|
+
} catch (err: unknown) {
|
|
3152
|
+
this.log.error("Message threw exception.");
|
|
3153
|
+
this.log.error(err instanceof Error ? err.message : String(err));
|
|
3154
|
+
throw err;
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
private sendReceipt(nonce: Uint8Array) {
|
|
3159
|
+
const receipt: ReceiptMsg = {
|
|
3160
|
+
nonce,
|
|
3161
|
+
transmissionID: uuid.v4(),
|
|
3162
|
+
type: "receipt",
|
|
3163
|
+
};
|
|
3164
|
+
void this.send(receipt);
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
private setAlive(status: boolean) {
|
|
3168
|
+
this.isAlive = status;
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
private setUser(user: User): void {
|
|
3172
|
+
this.user = user;
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
private async submitOTK(amount: number) {
|
|
3176
|
+
const otks: UnsavedPreKey[] = [];
|
|
3177
|
+
|
|
3178
|
+
const t0 = performance.now();
|
|
3179
|
+
for (let i = 0; i < amount; i++) {
|
|
3180
|
+
otks[i] = this.createPreKey();
|
|
3181
|
+
}
|
|
3182
|
+
const t1 = performance.now();
|
|
3183
|
+
|
|
3184
|
+
this.log.info(
|
|
3185
|
+
"Generated " +
|
|
3186
|
+
String(amount) +
|
|
3187
|
+
" one time keys in " +
|
|
3188
|
+
String(t1 - t0) +
|
|
3189
|
+
" ms.",
|
|
3190
|
+
);
|
|
3191
|
+
|
|
3192
|
+
const savedKeys = await this.database.savePreKeys(otks, true);
|
|
3193
|
+
|
|
3194
|
+
await this.http.post(
|
|
3195
|
+
this.getHost() + "/device/" + this.getDevice().deviceID + "/otk",
|
|
3196
|
+
msgpack.encode(savedKeys.map((key) => this.censorPreKey(key))),
|
|
3197
|
+
{
|
|
3198
|
+
headers: { "Content-Type": "application/msgpack" },
|
|
3199
|
+
},
|
|
3200
|
+
);
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
private async uploadAvatar(avatar: Uint8Array): Promise<void> {
|
|
3204
|
+
if (typeof FormData !== "undefined") {
|
|
3205
|
+
const fpayload = new FormData();
|
|
3206
|
+
fpayload.set("avatar", new Blob([new Uint8Array(avatar)]));
|
|
3207
|
+
|
|
3208
|
+
await this.http.post(
|
|
3209
|
+
this.prefixes.HTTP +
|
|
3210
|
+
this.host +
|
|
3211
|
+
"/avatar/" +
|
|
3212
|
+
this.me.user().userID,
|
|
3213
|
+
fpayload,
|
|
3214
|
+
{
|
|
3215
|
+
headers: { "Content-Type": "multipart/form-data" },
|
|
3216
|
+
onUploadProgress: (progressEvent) => {
|
|
3217
|
+
const percentCompleted = Math.round(
|
|
3218
|
+
(progressEvent.loaded * 100) /
|
|
3219
|
+
(progressEvent.total ?? 1),
|
|
3220
|
+
);
|
|
3221
|
+
const { loaded, total = 0 } = progressEvent;
|
|
3222
|
+
const progress: FileProgress = {
|
|
3223
|
+
direction: "upload",
|
|
3224
|
+
loaded,
|
|
3225
|
+
progress: percentCompleted,
|
|
3226
|
+
token: this.getUser().userID,
|
|
3227
|
+
total,
|
|
3228
|
+
};
|
|
3229
|
+
this.emitter.emit("fileProgress", progress);
|
|
3230
|
+
},
|
|
3231
|
+
},
|
|
3232
|
+
);
|
|
3233
|
+
return;
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
const payload: { file: string } = {
|
|
3237
|
+
file: XUtils.encodeBase64(avatar),
|
|
3238
|
+
};
|
|
3239
|
+
await this.http.post(
|
|
3240
|
+
this.prefixes.HTTP +
|
|
3241
|
+
this.host +
|
|
3242
|
+
"/avatar/" +
|
|
3243
|
+
this.me.user().userID +
|
|
3244
|
+
"/json",
|
|
3245
|
+
msgpack.encode(payload),
|
|
3246
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
3247
|
+
);
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
private async uploadEmoji(
|
|
3251
|
+
emoji: Uint8Array,
|
|
3252
|
+
name: string,
|
|
3253
|
+
serverID: string,
|
|
3254
|
+
): Promise<Emoji | null> {
|
|
3255
|
+
if (typeof FormData !== "undefined") {
|
|
3256
|
+
const fpayload = new FormData();
|
|
3257
|
+
fpayload.set("emoji", new Blob([new Uint8Array(emoji)]));
|
|
3258
|
+
fpayload.set("name", name);
|
|
3259
|
+
|
|
3260
|
+
try {
|
|
3261
|
+
const res = await this.http.post(
|
|
3262
|
+
this.getHost() + "/emoji/" + serverID,
|
|
3263
|
+
fpayload,
|
|
3264
|
+
{
|
|
3265
|
+
headers: { "Content-Type": "multipart/form-data" },
|
|
3266
|
+
onUploadProgress: (progressEvent) => {
|
|
3267
|
+
const percentCompleted = Math.round(
|
|
3268
|
+
(progressEvent.loaded * 100) /
|
|
3269
|
+
(progressEvent.total ?? 1),
|
|
3270
|
+
);
|
|
3271
|
+
const { loaded, total = 0 } = progressEvent;
|
|
3272
|
+
const progress: FileProgress = {
|
|
3273
|
+
direction: "upload",
|
|
3274
|
+
loaded,
|
|
3275
|
+
progress: percentCompleted,
|
|
3276
|
+
token: name,
|
|
3277
|
+
total,
|
|
3278
|
+
};
|
|
3279
|
+
this.emitter.emit("fileProgress", progress);
|
|
3280
|
+
},
|
|
3281
|
+
},
|
|
3282
|
+
);
|
|
3283
|
+
return decodeAxios(EmojiCodec, res.data);
|
|
3284
|
+
} catch (_err: unknown) {
|
|
3285
|
+
return null;
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
const payload: { file: string; name: string } = {
|
|
3290
|
+
file: XUtils.encodeBase64(emoji),
|
|
3291
|
+
name,
|
|
3292
|
+
};
|
|
3293
|
+
try {
|
|
3294
|
+
const res = await this.http.post(
|
|
3295
|
+
this.getHost() + "/emoji/" + serverID + "/json",
|
|
3296
|
+
msgpack.encode(payload),
|
|
3297
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
3298
|
+
);
|
|
3299
|
+
return decodeAxios(EmojiCodec, res.data);
|
|
3300
|
+
} catch (_err: unknown) {
|
|
3301
|
+
return null;
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
}
|