@vex-chat/libvex 8.0.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Client.js CHANGED
@@ -3,15 +3,15 @@
3
3
  * Licensed under AGPL-3.0. See LICENSE for details.
4
4
  * Commercial licenses available at vex.wtf
5
5
  */
6
- import { enterCryptoProfileScope, getCryptoProfile, leaveCryptoProfileScope, setCryptoProfile, xBoxKeyPairAsync, xBoxKeyPairFromSecretAsync, xConcat, xConstants, xDHAsync, xEcdhKeyPairFromEcdsaKeyPairAsync, xEncode, xHMAC, xKDF, XKeyConvert, xMakeNonce, xMnemonic, xRandomBytes, xSecretboxAsync, xSecretboxOpenAsync, xSignAsync, xSignKeyPair, xSignKeyPairAsync, xSignKeyPairFromSecret, xSignKeyPairFromSecretAsync, xSignOpenAsync, XUtils, } from "@vex-chat/crypto";
7
- import { CallEventSchema, CallSessionSchema, IceServerConfigSchema, MailType, MailWSSchema, PermissionSchema, WSMessageSchema, } from "@vex-chat/types";
6
+ import { enterCryptoProfileScope, getCryptoProfile, leaveCryptoProfileScope, setCryptoProfile, xBoxKeyPairAsync, xBoxKeyPairFromSecretAsync, xConcat, xConstants, xDHAsync, xEcdhKeyPairFromEcdsaKeyPairAsync, xEncode, xHMAC, xKDF, XKeyConvert, xMakeNonce, xMessageKeySubkeys, xMnemonic, xPreKeySignaturePayload, xRandomBytes, xSecretboxAsync, xSecretboxOpenAsync, xSignAsync, xSignKeyPair, xSignKeyPairAsync, xSignKeyPairFromSecret, xSignKeyPairFromSecretAsync, xSignOpenAsync, XUtils, } from "@vex-chat/crypto";
7
+ import { ACCOUNT_PASSWORD_MAX_LENGTH, ACCOUNT_PASSWORD_MIN_LENGTH, CallEventSchema, CallSessionSchema, IceServerConfigSchema, MailType, MailWSSchema, PermissionSchema, WSMessageSchema, } from "@vex-chat/types";
8
8
  import { EventEmitter } from "eventemitter3";
9
9
  import * as uuid from "uuid";
10
10
  import { z } from "zod/v4";
11
11
  import { createFetchHttpClient, isHttpError, } from "./http.js";
12
12
  import { clampLocalMessageRetentionDays, formatVexRetentionEnvelope, stripVexRetentionEnvelope, } from "./retention.js";
13
13
  import { WebSocketAdapter, WebSocketNotOpenError, } from "./transport/websocket.js";
14
- import { decodeFipsInitialExtraV1, encodeFipsInitialExtraV1, fipsP256AdFromIdentityPubs, fipsP256PreKeySignPayload, isFipsInitialExtraV1, } from "./utils/fipsMailExtra.js";
14
+ import { decodeFipsInitialExtraV1, encodeFipsInitialExtraV1, fipsP256AdFromIdentityPubs, isFipsInitialExtraV1, } from "./utils/fipsMailExtra.js";
15
15
  import { decodeRatchetHeader, deriveBootstrapSendChain, encodeRatchetHeader, hasRemoteDhChanged, initRatchetSession, ratchetStepReceive, ratchetStepSend, sessionToSqlPatch, takeReceiveMessageKey, takeSendMessageKey, } from "./utils/ratchet.js";
16
16
  import { verifyKeyBundleSignatures } from "./utils/verifyKeyBundle.js";
17
17
  /**
@@ -148,6 +148,16 @@ function libvexDebugLevel() {
148
148
  return "debug";
149
149
  }
150
150
  }
151
+ function registrationPasswordError(password) {
152
+ if (password.trim().length === 0 ||
153
+ password.length < ACCOUNT_PASSWORD_MIN_LENGTH) {
154
+ return new Error(`Password must be at least ${String(ACCOUNT_PASSWORD_MIN_LENGTH)} characters.`);
155
+ }
156
+ if (password.length > ACCOUNT_PASSWORD_MAX_LENGTH) {
157
+ return new Error(`Password must be at most ${String(ACCOUNT_PASSWORD_MAX_LENGTH)} characters.`);
158
+ }
159
+ return null;
160
+ }
151
161
  function sleep(ms) {
152
162
  return new Promise((resolve) => setTimeout(resolve, ms));
153
163
  }
@@ -373,6 +383,7 @@ const retryRequestNotifyData = z.union([
373
383
  mailID: z.string(),
374
384
  }),
375
385
  ]);
386
+ const serverChangeNotifyData = z.string().min(1).max(128);
376
387
  export class Client {
377
388
  /**
378
389
  * Decrypts a secret key from encrypted data produced by encryptKeyData().
@@ -454,6 +465,8 @@ export class Client {
454
465
  * @returns The Channel object, or null.
455
466
  */
456
467
  retrieveByID: this.getChannelByID.bind(this),
468
+ /** Changes a channel's display name. */
469
+ update: this.updateChannel.bind(this),
457
470
  /**
458
471
  * Retrieves a channel's userlist.
459
472
  * @param channelID - The channel to retrieve the userlist for.
@@ -529,6 +542,7 @@ export class Client {
529
542
  * Helpers for information/actions related to the currently authenticated account.
530
543
  */
531
544
  me = {
545
+ changePassword: this.changePassword.bind(this),
532
546
  /**
533
547
  * Retrieves current device details.
534
548
  *
@@ -590,6 +604,7 @@ export class Client {
590
604
  moderation = {
591
605
  fetchPermissionList: this.fetchPermissionList.bind(this),
592
606
  kick: this.kickUser.bind(this),
607
+ setRole: this.setServerMemberRole.bind(this),
593
608
  };
594
609
  /**
595
610
  * Passkey ("recovery credential") methods.
@@ -614,6 +629,7 @@ export class Client {
614
629
  listDevices: this.passkeyListDevices.bind(this),
615
630
  recoverDeviceRequest: this.passkeyRecoverDeviceRequest.bind(this),
616
631
  rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
632
+ resetPassword: this.resetPasswordWithPasskey.bind(this),
617
633
  };
618
634
  /**
619
635
  * Permission-management methods for the current user.
@@ -645,7 +661,9 @@ export class Client {
645
661
  * @param serverID - The server to delete.
646
662
  */
647
663
  delete: this.deleteServer.bind(this),
664
+ iconURL: this.getServerIconURL.bind(this),
648
665
  leave: this.leaveServer.bind(this),
666
+ removeIcon: this.removeServerIcon.bind(this),
649
667
  /**
650
668
  * Retrieves all servers the logged in user has access to.
651
669
  *
@@ -659,6 +677,8 @@ export class Client {
659
677
  */
660
678
  retrieveByID: this.getServerByID.bind(this),
661
679
  retrieveWithChannels: this.getServerChannelBootstrap.bind(this),
680
+ setIcon: this.uploadServerIcon.bind(this),
681
+ update: this.updateServer.bind(this),
662
682
  };
663
683
  /**
664
684
  * Encryption-session helpers.
@@ -849,14 +869,23 @@ export class Client {
849
869
  const atRestAes = XUtils.deriveLocalAtRestAesKey(idKeys.secretKey, profile);
850
870
  let resolvedStorage = storage;
851
871
  if (!resolvedStorage) {
852
- const { createNodeStorage } = await import("./storage/node.js");
872
+ const nodeStorageModule = "./storage/node.js";
873
+ const isNodeStorageModule = (value) => typeof value === "object" &&
874
+ value !== null &&
875
+ "createNodeStorage" in value &&
876
+ typeof value.createNodeStorage === "function";
877
+ const nodeStorage = await import(
878
+ /* @vite-ignore */ nodeStorageModule);
879
+ if (!isNodeStorageModule(nodeStorage)) {
880
+ throw new Error("Node storage adapter is unavailable.");
881
+ }
853
882
  const dbFileName = options?.inMemoryDb
854
883
  ? ":memory:"
855
884
  : XUtils.encodeHex(signKeys.publicKey) + ".sqlite";
856
885
  const dbPath = options?.dbFolder
857
886
  ? options.dbFolder + "/" + dbFileName
858
887
  : dbFileName;
859
- resolvedStorage = createNodeStorage(dbPath, atRestAes);
888
+ resolvedStorage = nodeStorage.createNodeStorage(dbPath, atRestAes);
860
889
  }
861
890
  await resolvedStorage.init();
862
891
  const client = new Client({
@@ -1147,10 +1176,37 @@ export class Client {
1147
1176
  return null;
1148
1177
  }
1149
1178
  /**
1150
- * Logs out the current authenticated session from the server.
1179
+ * Ends the local authenticated session and disconnects realtime transport.
1180
+ * Spire access tokens are stateless, so local credential removal is the
1181
+ * authoritative logout boundary.
1151
1182
  */
1152
1183
  async logout() {
1153
- await this.http.post(this.getHost() + "/goodbye");
1184
+ try {
1185
+ if (this.token) {
1186
+ await this.http.post(this.getHost() + "/goodbye");
1187
+ }
1188
+ }
1189
+ catch {
1190
+ // Logout must still complete locally when the server is unreachable.
1191
+ }
1192
+ finally {
1193
+ this.token = null;
1194
+ this.user = undefined;
1195
+ this.device = undefined;
1196
+ delete this.http.defaults.headers.common.Authorization;
1197
+ delete this.http.defaults.headers.common["X-Device-Token"];
1198
+ this.autoReconnectEnabled = false;
1199
+ this.postAuthVersion++;
1200
+ if (this.reconnectTimer) {
1201
+ clearTimeout(this.reconnectTimer);
1202
+ this.reconnectTimer = null;
1203
+ }
1204
+ if (this.pingInterval) {
1205
+ clearInterval(this.pingInterval);
1206
+ this.pingInterval = null;
1207
+ }
1208
+ this.socket.close();
1209
+ }
1154
1210
  }
1155
1211
  /** Removes an event listener. See {@link ClientEvents} for available events. */
1156
1212
  off(event, fn, context) {
@@ -1202,8 +1258,8 @@ export class Client {
1202
1258
  /**
1203
1259
  * Registers a new account on the server.
1204
1260
  *
1205
- * @param username - Optional username to register (must be unique when provided).
1206
- * @param password - Password for new accounts. Existing-account device approval requests may omit it.
1261
+ * @param username - Username to register.
1262
+ * @param password - Password for the account and device approval request.
1207
1263
  * @returns `[user, null]` on success, `[null, error]` on failure.
1208
1264
  *
1209
1265
  * @example
@@ -1212,113 +1268,35 @@ export class Client {
1212
1268
  * ```
1213
1269
  */
1214
1270
  async register(username, password) {
1215
- while (!this.xKeyRing) {
1216
- await sleep(100);
1217
- }
1218
- const regKey = await this.getToken("register");
1219
- if (regKey) {
1220
- // Usernames are case-insensitive at the protocol level;
1221
- // lowercase before sending so the local SDK view matches
1222
- // what the server canonicalizes and persists.
1223
- const resolvedUsername = username?.trim().length !== 0 && username !== undefined
1224
- ? username.trim().toLowerCase()
1225
- : Client.randomUsername();
1226
- const resolvedPassword = password?.trim().length !== 0 && password !== undefined
1227
- ? password
1228
- : undefined;
1229
- const signKey = XUtils.encodeHex(this.signKeys.publicKey);
1230
- const signed = XUtils.encodeHex(await xSignAsync(Uint8Array.from(uuid.parse(regKey.key)), this.signKeys.secretKey));
1231
- const preKeyIndex = this.xKeyRing.preKeys.index;
1232
- const regMsg = {
1233
- deviceName: this.options?.deviceName ?? "unknown",
1234
- preKey: XUtils.encodeHex(this.xKeyRing.preKeys.keyPair.publicKey),
1235
- preKeyIndex,
1236
- preKeySignature: XUtils.encodeHex(this.xKeyRing.preKeys.signature),
1237
- signed,
1238
- signKey,
1239
- username: resolvedUsername,
1240
- };
1241
- if (resolvedPassword !== undefined) {
1242
- regMsg.password = resolvedPassword;
1243
- }
1244
- try {
1245
- const res = await this.http.post(this.getHost() + "/register", msgpack.encode(regMsg), { headers: { "Content-Type": "application/msgpack" } });
1246
- // New key-cluster server response: { device, token, user }.
1247
- // Legacy response (still deployed in some environments): user only.
1248
- let didDecodeRegisterResponse = false;
1249
- let pendingApproval = null;
1250
- try {
1251
- const { device, token, user } = decodeHttpResponse(RegisterResponseCodec, res.data);
1252
- this.device = device;
1253
- this.setUser(user);
1254
- this.token = token;
1255
- this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
1256
- didDecodeRegisterResponse = true;
1257
- }
1258
- catch {
1259
- // fall through to legacy decode path
1260
- }
1261
- if (!didDecodeRegisterResponse) {
1262
- try {
1263
- pendingApproval = decodeHttpResponse(RegisterPendingApprovalCodec, res.data);
1264
- }
1265
- catch {
1266
- // fall through to legacy decode path
1267
- }
1268
- }
1269
- if (!didDecodeRegisterResponse) {
1270
- if (pendingApproval !== null) {
1271
- return [
1272
- null,
1273
- new DeviceApprovalRequiredError({
1274
- challenge: pendingApproval.challenge,
1275
- expiresAt: pendingApproval.expiresAt,
1276
- requestID: pendingApproval.requestID,
1277
- userID: pendingApproval.userID ?? null,
1278
- }),
1279
- ];
1280
- }
1281
- const legacyUser = decodeHttpResponse(UserCodec, res.data);
1282
- this.setUser(legacyUser);
1283
- // Legacy servers require /auth after /register to get a JWT.
1284
- if (resolvedPassword === undefined) {
1285
- return [
1286
- null,
1287
- new Error("Legacy register succeeded without a password, so the SDK could not complete login."),
1288
- ];
1289
- }
1290
- const loginResult = await this.login(resolvedUsername, resolvedPassword);
1291
- if (!loginResult.ok) {
1292
- return [
1293
- null,
1294
- new Error(loginResult.error ??
1295
- "Legacy register succeeded but login failed."),
1296
- ];
1297
- }
1298
- }
1299
- return [this.getUser(), null];
1300
- }
1301
- catch (err) {
1302
- if (isHttpError(err) && err.response) {
1303
- return [
1304
- null,
1305
- new Error(spireErrorBodyMessage(err.response.data)),
1306
- ];
1307
- }
1308
- return [
1309
- null,
1310
- err instanceof Error ? err : new Error(String(err)),
1311
- ];
1312
- }
1313
- }
1314
- else {
1315
- return [null, new Error("Couldn't get regkey from server.")];
1271
+ const passwordError = registrationPasswordError(password);
1272
+ if (passwordError) {
1273
+ return [null, passwordError];
1316
1274
  }
1275
+ return this.registerAccountOrEnrollment(username, "create-account", password);
1317
1276
  }
1318
1277
  removeAllListeners(event) {
1319
1278
  this.emitter.removeAllListeners(event);
1320
1279
  return this;
1321
1280
  }
1281
+ /**
1282
+ * Requests approval for a new device using the account password. This
1283
+ * enrollment flow never creates an account when the username is unknown.
1284
+ */
1285
+ async requestDeviceEnrollment(username, password) {
1286
+ const passwordError = registrationPasswordError(password);
1287
+ if (passwordError) {
1288
+ return [null, passwordError];
1289
+ }
1290
+ return this.registerAccountOrEnrollment(username, "enroll-device", password);
1291
+ }
1292
+ /**
1293
+ * Requests enrollment for this device after a successful passkey ceremony.
1294
+ * This is not account registration: the passkey bearer must identify an
1295
+ * existing account, and the server returns a pending device request.
1296
+ */
1297
+ async requestDeviceEnrollmentWithPasskey(username) {
1298
+ return this.registerAccountOrEnrollment(username, "enroll-device");
1299
+ }
1322
1300
  /**
1323
1301
  * Updates the local retention cap (1–30 days) and prunes immediately.
1324
1302
  * Does not affect server-side storage.
@@ -1456,6 +1434,16 @@ export class Client {
1456
1434
  signature: XUtils.decodeHex(preKey.signature),
1457
1435
  };
1458
1436
  }
1437
+ async changePassword(currentPassword, newPassword) {
1438
+ const passwordError = registrationPasswordError(newPassword);
1439
+ if (passwordError)
1440
+ throw passwordError;
1441
+ if (currentPassword.length === 0 ||
1442
+ currentPassword.length > ACCOUNT_PASSWORD_MAX_LENGTH) {
1443
+ throw new Error("Current password is invalid.");
1444
+ }
1445
+ await this.replaceAccountPassword({ currentPassword, newPassword });
1446
+ }
1459
1447
  async createChannel(name, serverID) {
1460
1448
  const body = { name };
1461
1449
  const res = await this.http.post(this.getHost() + "/server/" + serverID + "/channels", msgpack.encode(body), { headers: { "Content-Type": "application/msgpack" } });
@@ -1523,12 +1511,10 @@ export class Client {
1523
1511
  const res = await this.http.post(this.getHost() + "/server/" + serverID + "/invites", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
1524
1512
  return decodeHttpResponse(InviteCodec, res.data);
1525
1513
  }
1526
- async createPreKey() {
1514
+ async createPreKey(kind) {
1527
1515
  return this.runWithThisCryptoProfile(async () => {
1528
1516
  const preKeyPair = await xBoxKeyPairAsync();
1529
- const toSign = this.cryptoProfile === "fips"
1530
- ? fipsP256PreKeySignPayload(preKeyPair.publicKey)
1531
- : xEncode(xConstants.CURVE, preKeyPair.publicKey);
1517
+ const toSign = xPreKeySignaturePayload(preKeyPair.publicKey, kind, this.cryptoProfile);
1532
1518
  return {
1533
1519
  keyPair: preKeyPair,
1534
1520
  signature: await xSignAsync(toSign, this.signKeys.secretKey),
@@ -1536,7 +1522,7 @@ export class Client {
1536
1522
  });
1537
1523
  }
1538
1524
  async createServer(name) {
1539
- const res = await this.http.post(this.getHost() + "/server/" + globalThis.btoa(name));
1525
+ const res = await this.http.post(this.getHost() + "/servers", msgpack.encode({ name }), { headers: { "Content-Type": "application/msgpack" } });
1540
1526
  return decodeHttpResponse(ServerCodec, res.data);
1541
1527
  }
1542
1528
  async createSession(device, user, message, group,
@@ -1604,12 +1590,13 @@ export class Client {
1604
1590
  : XUtils.numberToUint8Arr(0);
1605
1591
  // shared secret key
1606
1592
  const SK = xKDF(IKM);
1593
+ const initialSubkeys = xMessageKeySubkeys(SK);
1607
1594
  const PK = (await xBoxKeyPairFromSecretAsync(SK)).publicKey;
1608
1595
  const AD = fips
1609
1596
  ? fipsP256AdFromIdentityPubs(IK_AP, new Uint8Array(keyBundle.signKey))
1610
1597
  : xConcat(xEncode(xConstants.CURVE, IK_AP), xEncode(xConstants.CURVE, IK_B));
1611
1598
  const nonce = xMakeNonce();
1612
- const cipher = await xSecretboxAsync(message, nonce, SK);
1599
+ const cipher = await xSecretboxAsync(message, nonce, initialSubkeys.encryptionKey);
1613
1600
  const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
1614
1601
  const ephKeyWire = ephemeralKeys.publicKey;
1615
1602
  const extra = fips
@@ -1628,7 +1615,7 @@ export class Client {
1628
1615
  recipient: device.deviceID,
1629
1616
  sender: this.getDevice().deviceID,
1630
1617
  };
1631
- const hmac = xHMAC(mail, SK);
1618
+ const hmac = xHMAC(mail, initialSubkeys.authenticationKey);
1632
1619
  const msg = {
1633
1620
  action: "CREATE",
1634
1621
  data: mail,
@@ -2122,6 +2109,9 @@ export class Client {
2122
2109
  "/device/" +
2123
2110
  this.getDevice().deviceID +
2124
2111
  "/mail");
2112
+ if (!this.token || this.isManualCloseInFlight()) {
2113
+ return;
2114
+ }
2125
2115
  const mailBuffer = new Uint8Array(res.data);
2126
2116
  const rawInbox = z
2127
2117
  .array(mailInboxEntry)
@@ -2220,6 +2210,9 @@ export class Client {
2220
2210
  "/servers/bootstrap");
2221
2211
  return decodeHttpResponse(ServerChannelBootstrapCodec, res.data);
2222
2212
  }
2213
+ getServerIconURL(iconID) {
2214
+ return this.getHost() + "/server-icon/" + encodeURIComponent(iconID);
2215
+ }
2223
2216
  async getServerList() {
2224
2217
  const res = await this.http.get(this.getHost() + "/user/" + this.getUser().userID + "/servers");
2225
2218
  return decodeHttpResponse(ServerArrayCodec, res.data);
@@ -2312,6 +2305,13 @@ export class Client {
2312
2305
  }
2313
2306
  }
2314
2307
  break;
2308
+ case "serverChange": {
2309
+ const parsed = serverChangeNotifyData.safeParse(msg.data);
2310
+ if (parsed.success) {
2311
+ this.emitter.emit("serverChange", parsed.data);
2312
+ }
2313
+ break;
2314
+ }
2315
2315
  default:
2316
2316
  break;
2317
2317
  }
@@ -2453,9 +2453,7 @@ export class Client {
2453
2453
  }
2454
2454
  async isPreKeySignedByCurrentDevice(preKey) {
2455
2455
  return this.runWithThisCryptoProfile(async () => {
2456
- const payload = this.cryptoProfile === "fips"
2457
- ? fipsP256PreKeySignPayload(preKey.keyPair.publicKey)
2458
- : xEncode(xConstants.CURVE, preKey.keyPair.publicKey);
2456
+ const payload = xPreKeySignaturePayload(preKey.keyPair.publicKey, "signed", this.cryptoProfile);
2459
2457
  const opened = await xSignOpenAsync(preKey.signature, this.signKeys.publicKey);
2460
2458
  return Boolean(opened && XUtils.bytesEqual(opened, payload));
2461
2459
  });
@@ -2627,7 +2625,7 @@ export class Client {
2627
2625
  let preKeys = await this.database.getPreKeys();
2628
2626
  if (!preKeys ||
2629
2627
  !(await this.isPreKeySignedByCurrentDevice(preKeys))) {
2630
- const unsaved = await this.createPreKey();
2628
+ const unsaved = await this.createPreKey("signed");
2631
2629
  const [saved] = await this.database.savePreKeys([unsaved], false);
2632
2630
  if (!saved || saved.index == null)
2633
2631
  throw new Error("Failed to save prekey — no index returned.");
@@ -2897,9 +2895,10 @@ export class Client {
2897
2895
  : xConcat(DH1, DH2, DH3);
2898
2896
  // shared secret key
2899
2897
  const SK = xKDF(IKM);
2898
+ const initialSubkeys = xMessageKeySubkeys(SK);
2900
2899
  const PK = (await xBoxKeyPairFromSecretAsync(SK))
2901
2900
  .publicKey;
2902
- const hmac = xHMAC(mail, SK);
2901
+ const hmac = xHMAC(mail, initialSubkeys.authenticationKey);
2903
2902
  // associated data
2904
2903
  const AD = fipsRead
2905
2904
  ? fipsP256AdFromIdentityPubs(IK_A, IK_BP)
@@ -2930,7 +2929,7 @@ export class Client {
2930
2929
  this.acknowledgeRepeatedDecryptFailure(mail, failureCount, timestamp);
2931
2930
  return;
2932
2931
  }
2933
- const unsealed = await xSecretboxOpenAsync(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), SK);
2932
+ const unsealed = await xSecretboxOpenAsync(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), initialSubkeys.encryptionKey);
2934
2933
  if (unsealed) {
2935
2934
  let plaintext = "";
2936
2935
  if (!mail.forward) {
@@ -3084,15 +3083,17 @@ export class Client {
3084
3083
  else if (hasRemoteDhChanged(candidateSession.DHr, ratchetHeader.dhPub)) {
3085
3084
  await ratchetStepReceive(candidateSession, ratchetHeader.dhPub, ratchetHeader.pn);
3086
3085
  }
3087
- let messageKey = takeReceiveMessageKey(candidateSession, ratchetHeader.dhPub, ratchetHeader.n);
3088
- let HMAC = xHMAC(mail, messageKey);
3086
+ const messageKey = takeReceiveMessageKey(candidateSession, ratchetHeader.dhPub, ratchetHeader.n);
3087
+ let messageSubkeys = xMessageKeySubkeys(messageKey);
3088
+ let HMAC = xHMAC(mail, messageSubkeys.authenticationKey);
3089
3089
  if (!XUtils.bytesEqual(HMAC, header) &&
3090
3090
  firstInboundFromSubsequent &&
3091
3091
  !originalSession.CKr) {
3092
3092
  const ratchetedCandidate = cloneSessionCrypto(originalSession);
3093
3093
  await ratchetStepReceive(ratchetedCandidate, ratchetHeader.dhPub, ratchetHeader.pn);
3094
3094
  const ratchetedMessageKey = takeReceiveMessageKey(ratchetedCandidate, ratchetHeader.dhPub, ratchetHeader.n);
3095
- const ratchetedHMAC = xHMAC(mail, ratchetedMessageKey);
3095
+ const ratchetedSubkeys = xMessageKeySubkeys(ratchetedMessageKey);
3096
+ const ratchetedHMAC = xHMAC(mail, ratchetedSubkeys.authenticationKey);
3096
3097
  if (XUtils.bytesEqual(ratchetedHMAC, header)) {
3097
3098
  if (libvexDebugDmEnabled()) {
3098
3099
  debugLibvexDm("readMail subsequent: first inbound used DH-ratchet fallback", {
@@ -3101,7 +3102,7 @@ export class Client {
3101
3102
  });
3102
3103
  }
3103
3104
  candidateSession = ratchetedCandidate;
3104
- messageKey = ratchetedMessageKey;
3105
+ messageSubkeys = ratchetedSubkeys;
3105
3106
  HMAC = ratchetedHMAC;
3106
3107
  }
3107
3108
  }
@@ -3128,7 +3129,7 @@ export class Client {
3128
3129
  return;
3129
3130
  }
3130
3131
  session = candidateSession;
3131
- const decrypted = await xSecretboxOpenAsync(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), messageKey);
3132
+ const decrypted = await xSecretboxOpenAsync(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), messageSubkeys.encryptionKey);
3132
3133
  if (decrypted) {
3133
3134
  const fwdMsg2 = mail.forward
3134
3135
  ? messageSchema.parse(msgpack.decode(decrypted))
@@ -3251,6 +3252,75 @@ export class Client {
3251
3252
  const res = await this.http.patch(this.getHost() + "/invite/" + inviteID);
3252
3253
  return decodeHttpResponse(PermissionCodec, res.data);
3253
3254
  }
3255
+ async registerAccountOrEnrollment(username, intent, password) {
3256
+ const resolvedUsername = username.trim().toLowerCase();
3257
+ if (!/^\w{3,19}$/.test(resolvedUsername)) {
3258
+ return [
3259
+ null,
3260
+ new Error("Username must be between three and nineteen letters, digits, or underscores."),
3261
+ ];
3262
+ }
3263
+ while (!this.xKeyRing) {
3264
+ await sleep(100);
3265
+ }
3266
+ const regKey = await this.getToken("register");
3267
+ if (regKey) {
3268
+ const signKey = XUtils.encodeHex(this.signKeys.publicKey);
3269
+ const signed = XUtils.encodeHex(await xSignAsync(Uint8Array.from(uuid.parse(regKey.key)), this.signKeys.secretKey));
3270
+ const preKeyIndex = this.xKeyRing.preKeys.index;
3271
+ const regMsg = {
3272
+ deviceName: this.options?.deviceName ?? "unknown",
3273
+ intent,
3274
+ preKey: XUtils.encodeHex(this.xKeyRing.preKeys.keyPair.publicKey),
3275
+ preKeyIndex,
3276
+ preKeySignature: XUtils.encodeHex(this.xKeyRing.preKeys.signature),
3277
+ signed,
3278
+ signKey,
3279
+ username: resolvedUsername,
3280
+ };
3281
+ if (password !== undefined) {
3282
+ regMsg.password = password;
3283
+ }
3284
+ try {
3285
+ const res = await this.http.post(this.getHost() + "/register", msgpack.encode(regMsg), { headers: { "Content-Type": "application/msgpack" } });
3286
+ try {
3287
+ const { device, token, user } = decodeHttpResponse(RegisterResponseCodec, res.data);
3288
+ this.device = device;
3289
+ this.setUser(user);
3290
+ this.token = token;
3291
+ this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
3292
+ }
3293
+ catch {
3294
+ const pendingApproval = decodeHttpResponse(RegisterPendingApprovalCodec, res.data);
3295
+ return [
3296
+ null,
3297
+ new DeviceApprovalRequiredError({
3298
+ challenge: pendingApproval.challenge,
3299
+ expiresAt: pendingApproval.expiresAt,
3300
+ requestID: pendingApproval.requestID,
3301
+ userID: pendingApproval.userID,
3302
+ }),
3303
+ ];
3304
+ }
3305
+ return [this.getUser(), null];
3306
+ }
3307
+ catch (err) {
3308
+ if (isHttpError(err) && err.response) {
3309
+ return [
3310
+ null,
3311
+ new Error(spireErrorBodyMessage(err.response.data)),
3312
+ ];
3313
+ }
3314
+ return [
3315
+ null,
3316
+ err instanceof Error ? err : new Error(String(err)),
3317
+ ];
3318
+ }
3319
+ }
3320
+ else {
3321
+ return [null, new Error("Couldn't get regkey from server.")];
3322
+ }
3323
+ }
3254
3324
  registerDecryptFailure(mail) {
3255
3325
  const count = (this.decryptFailureCounts.get(mail.mailID) ?? 0) + 1;
3256
3326
  this.decryptFailureCounts.set(mail.mailID, count);
@@ -3299,6 +3369,19 @@ export class Client {
3299
3369
  requestID +
3300
3370
  "/reject");
3301
3371
  }
3372
+ async removeServerIcon(serverID) {
3373
+ const res = await this.http.delete(this.getHost() + "/server-icon/" + serverID);
3374
+ return decodeHttpResponse(ServerCodec, res.data);
3375
+ }
3376
+ async replaceAccountPassword(payload) {
3377
+ await this.http.patch(this.getHost() + "/user/" + this.getUser().userID + "/password", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
3378
+ }
3379
+ async resetPasswordWithPasskey(newPassword) {
3380
+ const passwordError = registrationPasswordError(newPassword);
3381
+ if (passwordError)
3382
+ throw passwordError;
3383
+ await this.replaceAccountPassword({ newPassword });
3384
+ }
3302
3385
  async respond(msg) {
3303
3386
  const response = {
3304
3387
  signed: await xSignAsync(new Uint8Array(msg.challenge), this.signKeys.secretKey),
@@ -3378,7 +3461,7 @@ export class Client {
3378
3461
  challenge: newDevice.challenge,
3379
3462
  expiresAt: newDevice.expiresAt,
3380
3463
  requestID: newDevice.requestID,
3381
- userID: newDevice.userID ?? null,
3464
+ userID: newDevice.userID,
3382
3465
  });
3383
3466
  }
3384
3467
  else {
@@ -3606,8 +3689,8 @@ export class Client {
3606
3689
  return;
3607
3690
  }
3608
3691
  const stableDevices = [...targetDevices.values()].sort((a, b) => a.deviceID.localeCompare(b.deviceID, "en"));
3609
- let failCount = 0;
3610
- let lastErr;
3692
+ const successfulOwnerIDs = new Set();
3693
+ const lastErrorByOwnerID = new Map();
3611
3694
  for (let index = 0; index < stableDevices.length; index += MAIL_FANOUT_CONCURRENCY) {
3612
3695
  const batch = stableDevices.slice(index, index + MAIL_FANOUT_CONCURRENCY);
3613
3696
  const results = await Promise.all(batch.map(async (device) => {
@@ -3615,36 +3698,40 @@ export class Client {
3615
3698
  ? this.getUser()
3616
3699
  : this.userRecords[device.owner];
3617
3700
  if (!ownerRecord) {
3618
- return new Error(`Missing owner record for device ${device.deviceID}.`);
3701
+ return {
3702
+ device,
3703
+ error: new Error(`Missing owner record for device ${device.deviceID}.`),
3704
+ ok: false,
3705
+ };
3619
3706
  }
3620
3707
  try {
3621
3708
  await this.sendMailWithRecovery(device, ownerRecord, msgBytes, uuidToUint8(channelID), mailID, false);
3622
- return undefined;
3709
+ return { device, ok: true };
3623
3710
  }
3624
- catch (e) {
3625
- return e;
3711
+ catch (error) {
3712
+ return { device, error, ok: false };
3626
3713
  }
3627
3714
  }));
3628
3715
  for (const result of results) {
3629
- if (result !== undefined) {
3630
- lastErr = result;
3631
- failCount += 1;
3716
+ if (result.ok) {
3717
+ successfulOwnerIDs.add(result.device.owner);
3718
+ }
3719
+ else {
3720
+ lastErrorByOwnerID.set(result.device.owner, result.error);
3632
3721
  }
3633
3722
  }
3634
- if (failCount === stableDevices.length) {
3635
- break;
3636
- }
3637
- }
3638
- if (failCount === stableDevices.length) {
3639
- throw lastErr instanceof Error
3640
- ? lastErr
3641
- : new Error(String(lastErr));
3642
3723
  }
3643
- if (failCount > 0) {
3644
- const partial = new Error(`Group message failed to reach ${String(failCount)} of ` +
3645
- `${String(stableDevices.length)} peer device(s).`);
3646
- partial.cause = lastErr;
3647
- throw partial;
3724
+ const requiredOwnerIDs = peerUserIDs.length > 0 ? peerUserIDs : [myUserID];
3725
+ const unreachableOwnerIDs = requiredOwnerIDs.filter((ownerID) => !successfulOwnerIDs.has(ownerID));
3726
+ if (unreachableOwnerIDs.length > 0) {
3727
+ const lastError = lastErrorByOwnerID.get(unreachableOwnerIDs.at(-1) ?? "");
3728
+ if (unreachableOwnerIDs.length === requiredOwnerIDs.length &&
3729
+ lastError instanceof Error) {
3730
+ throw lastError;
3731
+ }
3732
+ const deliveryError = new Error(`Message could not be delivered to ${String(unreachableOwnerIDs.length)} channel member(s).`);
3733
+ deliveryError.cause = lastError;
3734
+ throw deliveryError;
3648
3735
  }
3649
3736
  }
3650
3737
  /* Sends encrypted mail to a user. */
@@ -3680,6 +3767,7 @@ export class Client {
3680
3767
  await ratchetStepSend(session);
3681
3768
  }
3682
3769
  const { messageKey, n } = takeSendMessageKey(session);
3770
+ const messageSubkeys = xMessageKeySubkeys(messageKey);
3683
3771
  const ratchetHeader = {
3684
3772
  dhPub: session.DHsPublic,
3685
3773
  n,
@@ -3687,7 +3775,7 @@ export class Client {
3687
3775
  version: 1,
3688
3776
  };
3689
3777
  const nonce = xMakeNonce();
3690
- const cipher = await xSecretboxAsync(msg, nonce, messageKey);
3778
+ const cipher = await xSecretboxAsync(msg, nonce, messageSubkeys.encryptionKey);
3691
3779
  const extra = encodeRatchetHeader(ratchetHeader);
3692
3780
  const mail = {
3693
3781
  authorID: this.getUser().userID,
@@ -3709,7 +3797,7 @@ export class Client {
3709
3797
  transmissionID: uuid.v4(),
3710
3798
  type: "resource",
3711
3799
  };
3712
- const hmac = xHMAC(mail, messageKey);
3800
+ const hmac = xHMAC(mail, messageSubkeys.authenticationKey);
3713
3801
  const fwdOut = forward
3714
3802
  ? messageSchema.parse(msgpack.decode(msg))
3715
3803
  : null;
@@ -3822,6 +3910,7 @@ export class Client {
3822
3910
  }
3823
3911
  let lastErr;
3824
3912
  let failCount = 0;
3913
+ let successCount = 0;
3825
3914
  // One logical DM fan-outs to multiple recipient devices. Reuse a
3826
3915
  // single mailID so local/UI dedupe treats it as one message.
3827
3916
  const messageMailID = uuid.v4();
@@ -3865,24 +3954,19 @@ export class Client {
3865
3954
  lastErr = result;
3866
3955
  failCount += 1;
3867
3956
  }
3957
+ else {
3958
+ successCount += 1;
3959
+ }
3868
3960
  }
3869
3961
  if (failCount === deviceList.length) {
3870
3962
  break;
3871
3963
  }
3872
3964
  }
3873
- if (failCount > 0) {
3965
+ if (successCount === 0) {
3874
3966
  const base = lastErr instanceof Error
3875
3967
  ? lastErr
3876
3968
  : new Error(String(lastErr));
3877
- if (failCount === deviceList.length) {
3878
- throw base;
3879
- }
3880
- // Multi-device: do not “succeed” when only one device of several got mail —
3881
- // callers and tests have no per-device result and the other copy times out.
3882
- const partial = new Error(`Direct message failed to reach ${String(failCount)} of ` +
3883
- `${String(deviceList.length)} peer device(s) (X3DH/post).`);
3884
- partial.cause = base;
3885
- throw partial;
3969
+ throw base;
3886
3970
  }
3887
3971
  if (userID !== this.getUser().userID &&
3888
3972
  firstOutgoingMessage.current !== null) {
@@ -3917,6 +4001,10 @@ export class Client {
3917
4001
  });
3918
4002
  return decodeHttpResponse(AccountEntitlementsCodec, res.data);
3919
4003
  }
4004
+ async setServerMemberRole(permissionID, powerLevel) {
4005
+ const res = await this.http.patch(this.getHost() + "/permission/" + permissionID, msgpack.encode({ powerLevel }), { headers: { "Content-Type": "application/msgpack" } });
4006
+ return decodeHttpResponse(PermissionCodec, res.data);
4007
+ }
3920
4008
  setUser(user) {
3921
4009
  this.user = user;
3922
4010
  // Fresh identity / token: drop stale 404 negative-cache entries so a
@@ -3957,13 +4045,21 @@ export class Client {
3957
4045
  async submitOTK(amount) {
3958
4046
  const otks = [];
3959
4047
  for (let i = 0; i < amount; i++) {
3960
- otks.push(await this.createPreKey());
4048
+ otks.push(await this.createPreKey("one-time"));
3961
4049
  }
3962
4050
  const savedKeys = await this.database.savePreKeys(otks, true);
3963
4051
  await this.http.post(this.getHost() + "/device/" + this.getDevice().deviceID + "/otk", msgpack.encode(savedKeys.map((key) => this.censorPreKey(key))), {
3964
4052
  headers: { "Content-Type": "application/msgpack" },
3965
4053
  });
3966
4054
  }
4055
+ async updateChannel(channelID, name) {
4056
+ const res = await this.http.patch(this.getHost() + "/channel/" + channelID, msgpack.encode({ name }), { headers: { "Content-Type": "application/msgpack" } });
4057
+ return decodeHttpResponse(ChannelCodec, res.data);
4058
+ }
4059
+ async updateServer(serverID, name) {
4060
+ const res = await this.http.patch(this.getHost() + "/server/" + serverID, msgpack.encode({ name }), { headers: { "Content-Type": "application/msgpack" } });
4061
+ return decodeHttpResponse(ServerCodec, res.data);
4062
+ }
3967
4063
  async uploadAvatar(avatar) {
3968
4064
  const canUseMultipart = typeof FormData !== "undefined" &&
3969
4065
  (() => {
@@ -4051,5 +4147,37 @@ export class Client {
4051
4147
  return null;
4052
4148
  }
4053
4149
  }
4150
+ async uploadServerIcon(serverID, icon) {
4151
+ const canUseMultipart = typeof FormData !== "undefined" &&
4152
+ (() => {
4153
+ try {
4154
+ void new Blob([new Uint8Array([1, 2, 3])]);
4155
+ return true;
4156
+ }
4157
+ catch {
4158
+ return false;
4159
+ }
4160
+ })();
4161
+ if (canUseMultipart) {
4162
+ const payload = new FormData();
4163
+ payload.set("icon", new Blob([new Uint8Array(icon)]));
4164
+ const res = await this.http.post(this.getHost() + "/server-icon/" + serverID, payload, {
4165
+ headers: { "Content-Type": "multipart/form-data" },
4166
+ onUploadProgress: (progressEvent) => {
4167
+ const { loaded, total = 0 } = progressEvent;
4168
+ this.emitter.emit("fileProgress", {
4169
+ direction: "upload",
4170
+ loaded,
4171
+ progress: Math.round((loaded * 100) / (progressEvent.total ?? 1)),
4172
+ token: serverID,
4173
+ total,
4174
+ });
4175
+ },
4176
+ });
4177
+ return decodeHttpResponse(ServerCodec, res.data);
4178
+ }
4179
+ const res = await this.http.post(this.getHost() + "/server-icon/" + serverID + "/json", msgpack.encode({ file: XUtils.encodeBase64(icon) }), { headers: { "Content-Type": "application/msgpack" } });
4180
+ return decodeHttpResponse(ServerCodec, res.data);
4181
+ }
4054
4182
  }
4055
4183
  //# sourceMappingURL=Client.js.map