@vex-chat/libvex 1.1.0 → 2.0.0

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