@vex-chat/libvex 1.0.2 → 2.0.0

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