@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/src/Client.ts CHANGED
@@ -68,7 +68,9 @@ import {
68
68
  xKDF,
69
69
  XKeyConvert,
70
70
  xMakeNonce,
71
+ xMessageKeySubkeys,
71
72
  xMnemonic,
73
+ xPreKeySignaturePayload,
72
74
  xRandomBytes,
73
75
  xSecretboxAsync,
74
76
  xSecretboxOpenAsync,
@@ -81,6 +83,8 @@ import {
81
83
  XUtils,
82
84
  } from "@vex-chat/crypto";
83
85
  import {
86
+ ACCOUNT_PASSWORD_MAX_LENGTH,
87
+ ACCOUNT_PASSWORD_MIN_LENGTH,
84
88
  CallEventSchema,
85
89
  CallSessionSchema,
86
90
  IceServerConfigSchema,
@@ -113,7 +117,6 @@ import {
113
117
  decodeFipsInitialExtraV1,
114
118
  encodeFipsInitialExtraV1,
115
119
  fipsP256AdFromIdentityPubs,
116
- fipsP256PreKeySignPayload,
117
120
  isFipsInitialExtraV1,
118
121
  } from "./utils/fipsMailExtra.js";
119
122
  import {
@@ -283,6 +286,23 @@ function libvexDebugLevel(): "debug" | "trace" {
283
286
  }
284
287
  }
285
288
 
289
+ function registrationPasswordError(password: string): Error | null {
290
+ if (
291
+ password.trim().length === 0 ||
292
+ password.length < ACCOUNT_PASSWORD_MIN_LENGTH
293
+ ) {
294
+ return new Error(
295
+ `Password must be at least ${String(ACCOUNT_PASSWORD_MIN_LENGTH)} characters.`,
296
+ );
297
+ }
298
+ if (password.length > ACCOUNT_PASSWORD_MAX_LENGTH) {
299
+ return new Error(
300
+ `Password must be at most ${String(ACCOUNT_PASSWORD_MAX_LENGTH)} characters.`,
301
+ );
302
+ }
303
+ return null;
304
+ }
305
+
286
306
  function sleep(ms: number): Promise<void> {
287
307
  return new Promise((resolve) => setTimeout(resolve, ms));
288
308
  }
@@ -435,6 +455,8 @@ export interface Channels {
435
455
  retrieve: (serverID: string) => Promise<Channel[]>;
436
456
  /** Gets one channel by ID. */
437
457
  retrieveByID: (channelID: string) => Promise<Channel | null>;
458
+ /** Changes a channel's display name. */
459
+ update: (channelID: string, name: string) => Promise<Channel>;
438
460
  /** Lists users currently visible in a channel. */
439
461
  userList: (channelID: string) => Promise<User[]>;
440
462
  }
@@ -504,9 +526,8 @@ export interface Devices {
504
526
  }) => Promise<void>;
505
527
  /**
506
528
  * Approves a pending device registration request as the current device.
507
- * Servers with required passkeys expect the current bearer token to be a
508
- * fresh passkey session while the current device token identifies the
509
- * approving device.
529
+ * The server verifies a signature from this approved device before
530
+ * admitting the pending device into the account cluster.
510
531
  */
511
532
  approveRequest: (requestID: string) => Promise<Device>;
512
533
  /**
@@ -547,10 +568,8 @@ export interface Devices {
547
568
  * the request's private signing key by signing the challenge issued by
548
569
  * the server in the original 202 response.
549
570
  *
550
- * @param args.requestID - The requestID returned by `/register` (or thrown
551
- * inside {@link DeviceApprovalRequiredError}).
552
- * @param args.challenge - The hex challenge issued in the same 202
553
- * response (or {@link DeviceApprovalRequiredError.challenge}).
571
+ * @param args - The request ID and hex challenge returned by `/register`
572
+ * (or carried by {@link DeviceApprovalRequiredError}).
554
573
  * @returns The current {@link PendingDeviceRequest} or `null` if the
555
574
  * server no longer has a record of it.
556
575
  */
@@ -702,6 +721,11 @@ export interface Keys {
702
721
  * @ignore
703
722
  */
704
723
  export interface Me {
724
+ /** Replace the account password after proving the current password. */
725
+ changePassword: (
726
+ currentPassword: string,
727
+ newPassword: string,
728
+ ) => Promise<void>;
705
729
  /** Returns metadata for the currently authenticated device. */
706
730
  device: () => Device;
707
731
  /** Uploads and sets a new avatar image for the current user. */
@@ -831,7 +855,7 @@ export interface Passkeys {
831
855
  options: any;
832
856
  requestID: string;
833
857
  }>;
834
- /** Remove a passkey from the account. */
858
+ /** Remove a passkey using the account's approved-device session. */
835
859
  delete: (passkeyID: string) => Promise<void>;
836
860
  /** Delete one of the account's devices using the passkey session. */
837
861
  deleteDevice: (deviceID: string) => Promise<void>;
@@ -868,6 +892,8 @@ export interface Passkeys {
868
892
  recoverDeviceRequest: (requestID: string) => Promise<Device>;
869
893
  /** Reject a pending device-enrollment request using the passkey session. */
870
894
  rejectDeviceRequest: (requestID: string) => Promise<void>;
895
+ /** Replace a forgotten password using this fresh passkey session. */
896
+ resetPassword: (newPassword: string) => Promise<void>;
871
897
  }
872
898
 
873
899
  export type PendingDeviceApprovalStatus =
@@ -881,13 +907,8 @@ export interface PendingDeviceRegistration {
881
907
  expiresAt: string;
882
908
  requestID: string;
883
909
  status: "pending_approval";
884
- /**
885
- * Existing user's ID. Optional for backward compat with older
886
- * servers that don't include it; when present, the new device can
887
- * fetch the public avatar from `/avatar/:userID` (no auth required)
888
- * to power an "is this you?" confirmation.
889
- */
890
- userID?: string | undefined;
910
+ /** Existing user's ID used to render the account confirmation step. */
911
+ userID: string;
891
912
  }
892
913
 
893
914
  export interface PendingDeviceRequest {
@@ -1110,6 +1131,7 @@ const retryRequestNotifyData = z.union([
1110
1131
  mailID: z.string(),
1111
1132
  }),
1112
1133
  ]);
1134
+ const serverChangeNotifyData = z.string().min(1).max(128);
1113
1135
 
1114
1136
  /**
1115
1137
  * Event signatures emitted by {@link Client}.
@@ -1146,6 +1168,8 @@ export interface ClientEvents {
1146
1168
  ready: () => void;
1147
1169
  /** Session healing requested a retry for a specific mail ID. */
1148
1170
  retryRequest: (retry: RetryRequest) => void;
1171
+ /** Server metadata, channels, membership, or permissions changed. */
1172
+ serverChange: (serverID: string) => void;
1149
1173
  /** A new encryption session was established with a peer device. */
1150
1174
  session: (session: Session, user: User) => void;
1151
1175
  }
@@ -1191,6 +1215,11 @@ export interface Moderation {
1191
1215
  fetchPermissionList: (serverID: string) => Promise<Permission[]>;
1192
1216
  /** Removes a user from a server by revoking their server permission(s). */
1193
1217
  kick: (userID: string, serverID: string) => Promise<void>;
1218
+ /** Changes a server member's role. */
1219
+ setRole: (
1220
+ permissionID: string,
1221
+ powerLevel: 0 | 50 | 100,
1222
+ ) => Promise<Permission>;
1194
1223
  }
1195
1224
 
1196
1225
  /**
@@ -1211,14 +1240,22 @@ export interface Servers {
1211
1240
  create: (name: string) => Promise<Server>;
1212
1241
  /** Deletes a server. */
1213
1242
  delete: (serverID: string) => Promise<void>;
1243
+ /** Returns the public URL for an immutable server icon ID. */
1244
+ iconURL: (iconID: string) => string;
1214
1245
  /** Leaves a server by removing the user's permission entry. */
1215
1246
  leave: (serverID: string) => Promise<void>;
1247
+ /** Removes a server's icon. */
1248
+ removeIcon: (serverID: string) => Promise<Server>;
1216
1249
  /** Lists servers available to the authenticated user. */
1217
1250
  retrieve: () => Promise<Server[]>;
1218
1251
  /** Gets one server by ID. */
1219
1252
  retrieveByID: (serverID: string) => Promise<null | Server>;
1220
1253
  /** Fetches servers and channels in one request for fast bootstraps. */
1221
1254
  retrieveWithChannels: () => Promise<ServerChannelBootstrap>;
1255
+ /** Uploads and sets a server icon. */
1256
+ setIcon: (serverID: string, icon: Uint8Array) => Promise<Server>;
1257
+ /** Changes a server's display name. */
1258
+ update: (serverID: string, name: string) => Promise<Server>;
1222
1259
  }
1223
1260
 
1224
1261
  /**
@@ -1439,6 +1476,8 @@ export class Client {
1439
1476
  * @returns The Channel object, or null.
1440
1477
  */
1441
1478
  retrieveByID: this.getChannelByID.bind(this),
1479
+ /** Changes a channel's display name. */
1480
+ update: this.updateChannel.bind(this),
1442
1481
  /**
1443
1482
  * Retrieves a channel's userlist.
1444
1483
  * @param channelID - The channel to retrieve the userlist for.
@@ -1526,6 +1565,7 @@ export class Client {
1526
1565
  * Helpers for information/actions related to the currently authenticated account.
1527
1566
  */
1528
1567
  public me: Me = {
1568
+ changePassword: this.changePassword.bind(this),
1529
1569
  /**
1530
1570
  * Retrieves current device details.
1531
1571
  *
@@ -1593,6 +1633,7 @@ export class Client {
1593
1633
  public moderation: Moderation = {
1594
1634
  fetchPermissionList: this.fetchPermissionList.bind(this),
1595
1635
  kick: this.kickUser.bind(this),
1636
+ setRole: this.setServerMemberRole.bind(this),
1596
1637
  };
1597
1638
 
1598
1639
  /**
@@ -1618,6 +1659,7 @@ export class Client {
1618
1659
  listDevices: this.passkeyListDevices.bind(this),
1619
1660
  recoverDeviceRequest: this.passkeyRecoverDeviceRequest.bind(this),
1620
1661
  rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
1662
+ resetPassword: this.resetPasswordWithPasskey.bind(this),
1621
1663
  };
1622
1664
 
1623
1665
  /**
@@ -1652,7 +1694,9 @@ export class Client {
1652
1694
  * @param serverID - The server to delete.
1653
1695
  */
1654
1696
  delete: this.deleteServer.bind(this),
1697
+ iconURL: this.getServerIconURL.bind(this),
1655
1698
  leave: this.leaveServer.bind(this),
1699
+ removeIcon: this.removeServerIcon.bind(this),
1656
1700
  /**
1657
1701
  * Retrieves all servers the logged in user has access to.
1658
1702
  *
@@ -1666,6 +1710,8 @@ export class Client {
1666
1710
  */
1667
1711
  retrieveByID: this.getServerByID.bind(this),
1668
1712
  retrieveWithChannels: this.getServerChannelBootstrap.bind(this),
1713
+ setIcon: this.uploadServerIcon.bind(this),
1714
+ update: this.updateServer.bind(this),
1669
1715
  };
1670
1716
 
1671
1717
  /**
@@ -1730,7 +1776,7 @@ export class Client {
1730
1776
 
1731
1777
  private readonly decryptFailureCounts = new Map<string, number>();
1732
1778
 
1733
- private device?: Device;
1779
+ private device: Device | undefined;
1734
1780
 
1735
1781
  private deviceRecords: Record<string, Device> = {};
1736
1782
  // ── Event subscription (composition over inheritance) ───────────────
@@ -1787,7 +1833,7 @@ export class Client {
1787
1833
  private socket: WebSocketLike;
1788
1834
  private token: null | string = null;
1789
1835
 
1790
- private user?: User;
1836
+ private user: undefined | User;
1791
1837
 
1792
1838
  private userRecords: Record<string, User> = {};
1793
1839
  private xKeyRing?: XKeyRing;
@@ -1914,14 +1960,32 @@ export class Client {
1914
1960
 
1915
1961
  let resolvedStorage = storage;
1916
1962
  if (!resolvedStorage) {
1917
- const { createNodeStorage } = await import("./storage/node.js");
1963
+ const nodeStorageModule = "./storage/node.js";
1964
+ const isNodeStorageModule = (
1965
+ value: unknown,
1966
+ ): value is {
1967
+ createNodeStorage: (
1968
+ dbPath: string,
1969
+ atRestAesKey: Uint8Array,
1970
+ ) => Storage;
1971
+ } =>
1972
+ typeof value === "object" &&
1973
+ value !== null &&
1974
+ "createNodeStorage" in value &&
1975
+ typeof value.createNodeStorage === "function";
1976
+ const nodeStorage: unknown = await import(
1977
+ /* @vite-ignore */ nodeStorageModule
1978
+ );
1979
+ if (!isNodeStorageModule(nodeStorage)) {
1980
+ throw new Error("Node storage adapter is unavailable.");
1981
+ }
1918
1982
  const dbFileName = options?.inMemoryDb
1919
1983
  ? ":memory:"
1920
1984
  : XUtils.encodeHex(signKeys.publicKey) + ".sqlite";
1921
1985
  const dbPath = options?.dbFolder
1922
1986
  ? options.dbFolder + "/" + dbFileName
1923
1987
  : dbFileName;
1924
- resolvedStorage = createNodeStorage(dbPath, atRestAes);
1988
+ resolvedStorage = nodeStorage.createNodeStorage(dbPath, atRestAes);
1925
1989
  }
1926
1990
 
1927
1991
  await resolvedStorage.init();
@@ -2292,10 +2356,35 @@ export class Client {
2292
2356
  }
2293
2357
 
2294
2358
  /**
2295
- * Logs out the current authenticated session from the server.
2359
+ * Ends the local authenticated session and disconnects realtime transport.
2360
+ * Spire access tokens are stateless, so local credential removal is the
2361
+ * authoritative logout boundary.
2296
2362
  */
2297
2363
  public async logout(): Promise<void> {
2298
- await this.http.post(this.getHost() + "/goodbye");
2364
+ try {
2365
+ if (this.token) {
2366
+ await this.http.post(this.getHost() + "/goodbye");
2367
+ }
2368
+ } catch {
2369
+ // Logout must still complete locally when the server is unreachable.
2370
+ } finally {
2371
+ this.token = null;
2372
+ this.user = undefined;
2373
+ this.device = undefined;
2374
+ delete this.http.defaults.headers.common.Authorization;
2375
+ delete this.http.defaults.headers.common["X-Device-Token"];
2376
+ this.autoReconnectEnabled = false;
2377
+ this.postAuthVersion++;
2378
+ if (this.reconnectTimer) {
2379
+ clearTimeout(this.reconnectTimer);
2380
+ this.reconnectTimer = null;
2381
+ }
2382
+ if (this.pingInterval) {
2383
+ clearInterval(this.pingInterval);
2384
+ this.pingInterval = null;
2385
+ }
2386
+ this.socket.close();
2387
+ }
2299
2388
  }
2300
2389
 
2301
2390
  /** Removes an event listener. See {@link ClientEvents} for available events. */
@@ -2367,8 +2456,8 @@ export class Client {
2367
2456
  /**
2368
2457
  * Registers a new account on the server.
2369
2458
  *
2370
- * @param username - Optional username to register (must be unique when provided).
2371
- * @param password - Password for new accounts. Existing-account device approval requests may omit it.
2459
+ * @param username - Username to register.
2460
+ * @param password - Password for the account and device approval request.
2372
2461
  * @returns `[user, null]` on success, `[null, error]` on failure.
2373
2462
  *
2374
2463
  * @example
@@ -2377,139 +2466,18 @@ export class Client {
2377
2466
  * ```
2378
2467
  */
2379
2468
  public async register(
2380
- username?: string,
2381
- password?: string,
2469
+ username: string,
2470
+ password: string,
2382
2471
  ): Promise<[null | User, Error | null]> {
2383
- while (!this.xKeyRing) {
2384
- await sleep(100);
2385
- }
2386
- const regKey = await this.getToken("register");
2387
- if (regKey) {
2388
- // Usernames are case-insensitive at the protocol level;
2389
- // lowercase before sending so the local SDK view matches
2390
- // what the server canonicalizes and persists.
2391
- const resolvedUsername =
2392
- username?.trim().length !== 0 && username !== undefined
2393
- ? username.trim().toLowerCase()
2394
- : Client.randomUsername();
2395
- const resolvedPassword =
2396
- password?.trim().length !== 0 && password !== undefined
2397
- ? password
2398
- : undefined;
2399
- const signKey = XUtils.encodeHex(this.signKeys.publicKey);
2400
- const signed = XUtils.encodeHex(
2401
- await xSignAsync(
2402
- Uint8Array.from(uuid.parse(regKey.key)),
2403
- this.signKeys.secretKey,
2404
- ),
2405
- );
2406
- const preKeyIndex = this.xKeyRing.preKeys.index;
2407
- const regMsg: RegistrationPayload = {
2408
- deviceName: this.options?.deviceName ?? "unknown",
2409
- preKey: XUtils.encodeHex(
2410
- this.xKeyRing.preKeys.keyPair.publicKey,
2411
- ),
2412
- preKeyIndex,
2413
- preKeySignature: XUtils.encodeHex(
2414
- this.xKeyRing.preKeys.signature,
2415
- ),
2416
- signed,
2417
- signKey,
2418
- username: resolvedUsername,
2419
- };
2420
- if (resolvedPassword !== undefined) {
2421
- regMsg.password = resolvedPassword;
2422
- }
2423
- try {
2424
- const res = await this.http.post(
2425
- this.getHost() + "/register",
2426
- msgpack.encode(regMsg),
2427
- { headers: { "Content-Type": "application/msgpack" } },
2428
- );
2429
-
2430
- // New key-cluster server response: { device, token, user }.
2431
- // Legacy response (still deployed in some environments): user only.
2432
- let didDecodeRegisterResponse = false;
2433
- let pendingApproval: null | PendingDeviceRegistration = null;
2434
- try {
2435
- const { device, token, user } = decodeHttpResponse(
2436
- RegisterResponseCodec,
2437
- res.data,
2438
- );
2439
- this.device = device;
2440
- this.setUser(user);
2441
- this.token = token;
2442
- this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
2443
- didDecodeRegisterResponse = true;
2444
- } catch {
2445
- // fall through to legacy decode path
2446
- }
2447
-
2448
- if (!didDecodeRegisterResponse) {
2449
- try {
2450
- pendingApproval = decodeHttpResponse(
2451
- RegisterPendingApprovalCodec,
2452
- res.data,
2453
- );
2454
- } catch {
2455
- // fall through to legacy decode path
2456
- }
2457
- }
2458
-
2459
- if (!didDecodeRegisterResponse) {
2460
- if (pendingApproval !== null) {
2461
- return [
2462
- null,
2463
- new DeviceApprovalRequiredError({
2464
- challenge: pendingApproval.challenge,
2465
- expiresAt: pendingApproval.expiresAt,
2466
- requestID: pendingApproval.requestID,
2467
- userID: pendingApproval.userID ?? null,
2468
- }),
2469
- ];
2470
- }
2471
- const legacyUser = decodeHttpResponse(UserCodec, res.data);
2472
- this.setUser(legacyUser);
2473
-
2474
- // Legacy servers require /auth after /register to get a JWT.
2475
- if (resolvedPassword === undefined) {
2476
- return [
2477
- null,
2478
- new Error(
2479
- "Legacy register succeeded without a password, so the SDK could not complete login.",
2480
- ),
2481
- ];
2482
- }
2483
- const loginResult = await this.login(
2484
- resolvedUsername,
2485
- resolvedPassword,
2486
- );
2487
- if (!loginResult.ok) {
2488
- return [
2489
- null,
2490
- new Error(
2491
- loginResult.error ??
2492
- "Legacy register succeeded but login failed.",
2493
- ),
2494
- ];
2495
- }
2496
- }
2497
- return [this.getUser(), null];
2498
- } catch (err: unknown) {
2499
- if (isHttpError(err) && err.response) {
2500
- return [
2501
- null,
2502
- new Error(spireErrorBodyMessage(err.response.data)),
2503
- ];
2504
- }
2505
- return [
2506
- null,
2507
- err instanceof Error ? err : new Error(String(err)),
2508
- ];
2509
- }
2510
- } else {
2511
- return [null, new Error("Couldn't get regkey from server.")];
2512
- }
2472
+ const passwordError = registrationPasswordError(password);
2473
+ if (passwordError) {
2474
+ return [null, passwordError];
2475
+ }
2476
+ return this.registerAccountOrEnrollment(
2477
+ username,
2478
+ "create-account",
2479
+ password,
2480
+ );
2513
2481
  }
2514
2482
 
2515
2483
  removeAllListeners(event?: keyof ClientEvents): this {
@@ -2517,6 +2485,36 @@ export class Client {
2517
2485
  return this;
2518
2486
  }
2519
2487
 
2488
+ /**
2489
+ * Requests approval for a new device using the account password. This
2490
+ * enrollment flow never creates an account when the username is unknown.
2491
+ */
2492
+ public async requestDeviceEnrollment(
2493
+ username: string,
2494
+ password: string,
2495
+ ): Promise<[null | User, Error | null]> {
2496
+ const passwordError = registrationPasswordError(password);
2497
+ if (passwordError) {
2498
+ return [null, passwordError];
2499
+ }
2500
+ return this.registerAccountOrEnrollment(
2501
+ username,
2502
+ "enroll-device",
2503
+ password,
2504
+ );
2505
+ }
2506
+
2507
+ /**
2508
+ * Requests enrollment for this device after a successful passkey ceremony.
2509
+ * This is not account registration: the passkey bearer must identify an
2510
+ * existing account, and the server returns a pending device request.
2511
+ */
2512
+ public async requestDeviceEnrollmentWithPasskey(
2513
+ username: string,
2514
+ ): Promise<[null | User, Error | null]> {
2515
+ return this.registerAccountOrEnrollment(username, "enroll-device");
2516
+ }
2517
+
2520
2518
  /**
2521
2519
  * Updates the local retention cap (1–30 days) and prunes immediately.
2522
2520
  * Does not affect server-side storage.
@@ -2737,6 +2735,21 @@ export class Client {
2737
2735
  };
2738
2736
  }
2739
2737
 
2738
+ private async changePassword(
2739
+ currentPassword: string,
2740
+ newPassword: string,
2741
+ ): Promise<void> {
2742
+ const passwordError = registrationPasswordError(newPassword);
2743
+ if (passwordError) throw passwordError;
2744
+ if (
2745
+ currentPassword.length === 0 ||
2746
+ currentPassword.length > ACCOUNT_PASSWORD_MAX_LENGTH
2747
+ ) {
2748
+ throw new Error("Current password is invalid.");
2749
+ }
2750
+ await this.replaceAccountPassword({ currentPassword, newPassword });
2751
+ }
2752
+
2740
2753
  private async createChannel(
2741
2754
  name: string,
2742
2755
  serverID: string,
@@ -2848,13 +2861,16 @@ export class Client {
2848
2861
  return decodeHttpResponse(InviteCodec, res.data);
2849
2862
  }
2850
2863
 
2851
- private async createPreKey(): Promise<UnsavedPreKey> {
2864
+ private async createPreKey(
2865
+ kind: "one-time" | "signed",
2866
+ ): Promise<UnsavedPreKey> {
2852
2867
  return this.runWithThisCryptoProfile(async () => {
2853
2868
  const preKeyPair = await xBoxKeyPairAsync();
2854
- const toSign =
2855
- this.cryptoProfile === "fips"
2856
- ? fipsP256PreKeySignPayload(preKeyPair.publicKey)
2857
- : xEncode(xConstants.CURVE, preKeyPair.publicKey);
2869
+ const toSign = xPreKeySignaturePayload(
2870
+ preKeyPair.publicKey,
2871
+ kind,
2872
+ this.cryptoProfile,
2873
+ );
2858
2874
  return {
2859
2875
  keyPair: preKeyPair,
2860
2876
  signature: await xSignAsync(toSign, this.signKeys.secretKey),
@@ -2864,7 +2880,9 @@ export class Client {
2864
2880
 
2865
2881
  private async createServer(name: string): Promise<Server> {
2866
2882
  const res = await this.http.post(
2867
- this.getHost() + "/server/" + globalThis.btoa(name),
2883
+ this.getHost() + "/servers",
2884
+ msgpack.encode({ name }),
2885
+ { headers: { "Content-Type": "application/msgpack" } },
2868
2886
  );
2869
2887
  return decodeHttpResponse(ServerCodec, res.data);
2870
2888
  }
@@ -2959,6 +2977,7 @@ export class Client {
2959
2977
 
2960
2978
  // shared secret key
2961
2979
  const SK = xKDF(IKM);
2980
+ const initialSubkeys = xMessageKeySubkeys(SK);
2962
2981
  const PK = (await xBoxKeyPairFromSecretAsync(SK)).publicKey;
2963
2982
 
2964
2983
  const AD = fips
@@ -2972,7 +2991,11 @@ export class Client {
2972
2991
  );
2973
2992
 
2974
2993
  const nonce = xMakeNonce();
2975
- const cipher = await xSecretboxAsync(message, nonce, SK);
2994
+ const cipher = await xSecretboxAsync(
2995
+ message,
2996
+ nonce,
2997
+ initialSubkeys.encryptionKey,
2998
+ );
2976
2999
 
2977
3000
  const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
2978
3001
  const ephKeyWire = ephemeralKeys.publicKey;
@@ -3001,7 +3024,7 @@ export class Client {
3001
3024
  sender: this.getDevice().deviceID,
3002
3025
  };
3003
3026
 
3004
- const hmac = xHMAC(mail, SK);
3027
+ const hmac = xHMAC(mail, initialSubkeys.authenticationKey);
3005
3028
 
3006
3029
  const msg: ResourceMsg = {
3007
3030
  action: "CREATE",
@@ -3190,6 +3213,7 @@ export class Client {
3190
3213
  return z.object({ calls: z.array(CallSessionSchema) }).parse(res.data)
3191
3214
  .calls;
3192
3215
  }
3216
+
3193
3217
  private async fetchIceServers(): Promise<IceServerConfig[]> {
3194
3218
  const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
3195
3219
  responseType: "json",
@@ -3338,7 +3362,6 @@ export class Client {
3338
3362
  this.http.defaults.headers.common.Authorization = `Bearer ${decoded.token}`;
3339
3363
  return decoded;
3340
3364
  }
3341
-
3342
3365
  private async finishPasskeyRegistration(args: {
3343
3366
  name: string;
3344
3367
  requestID: string;
@@ -3657,6 +3680,9 @@ export class Client {
3657
3680
  this.getDevice().deviceID +
3658
3681
  "/mail",
3659
3682
  );
3683
+ if (!this.token || this.isManualCloseInFlight()) {
3684
+ return;
3685
+ }
3660
3686
  const mailBuffer = new Uint8Array(res.data);
3661
3687
  const rawInbox = z
3662
3688
  .array(mailInboxEntry)
@@ -3779,6 +3805,10 @@ export class Client {
3779
3805
  return decodeHttpResponse(ServerChannelBootstrapCodec, res.data);
3780
3806
  }
3781
3807
 
3808
+ private getServerIconURL(iconID: string): string {
3809
+ return this.getHost() + "/server-icon/" + encodeURIComponent(iconID);
3810
+ }
3811
+
3782
3812
  private async getServerList(): Promise<Server[]> {
3783
3813
  const res = await this.http.get(
3784
3814
  this.getHost() + "/user/" + this.getUser().userID + "/servers",
@@ -3895,6 +3925,13 @@ export class Client {
3895
3925
  }
3896
3926
  }
3897
3927
  break;
3928
+ case "serverChange": {
3929
+ const parsed = serverChangeNotifyData.safeParse(msg.data);
3930
+ if (parsed.success) {
3931
+ this.emitter.emit("serverChange", parsed.data);
3932
+ }
3933
+ break;
3934
+ }
3898
3935
  default:
3899
3936
  break;
3900
3937
  }
@@ -4055,10 +4092,11 @@ export class Client {
4055
4092
  preKey: PreKeysCrypto,
4056
4093
  ): Promise<boolean> {
4057
4094
  return this.runWithThisCryptoProfile(async () => {
4058
- const payload =
4059
- this.cryptoProfile === "fips"
4060
- ? fipsP256PreKeySignPayload(preKey.keyPair.publicKey)
4061
- : xEncode(xConstants.CURVE, preKey.keyPair.publicKey);
4095
+ const payload = xPreKeySignaturePayload(
4096
+ preKey.keyPair.publicKey,
4097
+ "signed",
4098
+ this.cryptoProfile,
4099
+ );
4062
4100
  const opened = await xSignOpenAsync(
4063
4101
  preKey.signature,
4064
4102
  this.signKeys.publicKey,
@@ -4286,7 +4324,7 @@ export class Client {
4286
4324
  !preKeys ||
4287
4325
  !(await this.isPreKeySignedByCurrentDevice(preKeys))
4288
4326
  ) {
4289
- const unsaved = await this.createPreKey();
4327
+ const unsaved = await this.createPreKey("signed");
4290
4328
  const [saved] = await this.database.savePreKeys(
4291
4329
  [unsaved],
4292
4330
  false,
@@ -4648,10 +4686,14 @@ export class Client {
4648
4686
 
4649
4687
  // shared secret key
4650
4688
  const SK = xKDF(IKM);
4689
+ const initialSubkeys = xMessageKeySubkeys(SK);
4651
4690
  const PK = (await xBoxKeyPairFromSecretAsync(SK))
4652
4691
  .publicKey;
4653
4692
 
4654
- const hmac = xHMAC(mail, SK);
4693
+ const hmac = xHMAC(
4694
+ mail,
4695
+ initialSubkeys.authenticationKey,
4696
+ );
4655
4697
 
4656
4698
  // associated data
4657
4699
  const AD = fipsRead
@@ -4701,7 +4743,7 @@ export class Client {
4701
4743
  const unsealed = await xSecretboxOpenAsync(
4702
4744
  new Uint8Array(mail.cipher),
4703
4745
  new Uint8Array(mail.nonce),
4704
- SK,
4746
+ initialSubkeys.encryptionKey,
4705
4747
  );
4706
4748
  if (unsealed) {
4707
4749
  let plaintext = "";
@@ -4918,12 +4960,16 @@ export class Client {
4918
4960
  );
4919
4961
  }
4920
4962
 
4921
- let messageKey = takeReceiveMessageKey(
4963
+ const messageKey = takeReceiveMessageKey(
4922
4964
  candidateSession,
4923
4965
  ratchetHeader.dhPub,
4924
4966
  ratchetHeader.n,
4925
4967
  );
4926
- let HMAC = xHMAC(mail, messageKey);
4968
+ let messageSubkeys = xMessageKeySubkeys(messageKey);
4969
+ let HMAC = xHMAC(
4970
+ mail,
4971
+ messageSubkeys.authenticationKey,
4972
+ );
4927
4973
 
4928
4974
  if (
4929
4975
  !XUtils.bytesEqual(HMAC, header) &&
@@ -4942,9 +4988,11 @@ export class Client {
4942
4988
  ratchetHeader.dhPub,
4943
4989
  ratchetHeader.n,
4944
4990
  );
4991
+ const ratchetedSubkeys =
4992
+ xMessageKeySubkeys(ratchetedMessageKey);
4945
4993
  const ratchetedHMAC = xHMAC(
4946
4994
  mail,
4947
- ratchetedMessageKey,
4995
+ ratchetedSubkeys.authenticationKey,
4948
4996
  );
4949
4997
  if (XUtils.bytesEqual(ratchetedHMAC, header)) {
4950
4998
  if (libvexDebugDmEnabled()) {
@@ -4958,7 +5006,7 @@ export class Client {
4958
5006
  );
4959
5007
  }
4960
5008
  candidateSession = ratchetedCandidate;
4961
- messageKey = ratchetedMessageKey;
5009
+ messageSubkeys = ratchetedSubkeys;
4962
5010
  HMAC = ratchetedHMAC;
4963
5011
  }
4964
5012
  }
@@ -4999,7 +5047,7 @@ export class Client {
4999
5047
  const decrypted = await xSecretboxOpenAsync(
5000
5048
  new Uint8Array(mail.cipher),
5001
5049
  new Uint8Array(mail.nonce),
5002
- messageKey,
5050
+ messageSubkeys.encryptionKey,
5003
5051
  );
5004
5052
 
5005
5053
  if (decrypted) {
@@ -5144,6 +5192,99 @@ export class Client {
5144
5192
  return decodeHttpResponse(PermissionCodec, res.data);
5145
5193
  }
5146
5194
 
5195
+ private async registerAccountOrEnrollment(
5196
+ username: string,
5197
+ intent: RegistrationPayload["intent"],
5198
+ password?: string,
5199
+ ): Promise<[null | User, Error | null]> {
5200
+ const resolvedUsername = username.trim().toLowerCase();
5201
+ if (!/^\w{3,19}$/.test(resolvedUsername)) {
5202
+ return [
5203
+ null,
5204
+ new Error(
5205
+ "Username must be between three and nineteen letters, digits, or underscores.",
5206
+ ),
5207
+ ];
5208
+ }
5209
+ while (!this.xKeyRing) {
5210
+ await sleep(100);
5211
+ }
5212
+ const regKey = await this.getToken("register");
5213
+ if (regKey) {
5214
+ const signKey = XUtils.encodeHex(this.signKeys.publicKey);
5215
+ const signed = XUtils.encodeHex(
5216
+ await xSignAsync(
5217
+ Uint8Array.from(uuid.parse(regKey.key)),
5218
+ this.signKeys.secretKey,
5219
+ ),
5220
+ );
5221
+ const preKeyIndex = this.xKeyRing.preKeys.index;
5222
+ const regMsg: RegistrationPayload = {
5223
+ deviceName: this.options?.deviceName ?? "unknown",
5224
+ intent,
5225
+ preKey: XUtils.encodeHex(
5226
+ this.xKeyRing.preKeys.keyPair.publicKey,
5227
+ ),
5228
+ preKeyIndex,
5229
+ preKeySignature: XUtils.encodeHex(
5230
+ this.xKeyRing.preKeys.signature,
5231
+ ),
5232
+ signed,
5233
+ signKey,
5234
+ username: resolvedUsername,
5235
+ };
5236
+ if (password !== undefined) {
5237
+ regMsg.password = password;
5238
+ }
5239
+ try {
5240
+ const res = await this.http.post(
5241
+ this.getHost() + "/register",
5242
+ msgpack.encode(regMsg),
5243
+ { headers: { "Content-Type": "application/msgpack" } },
5244
+ );
5245
+
5246
+ try {
5247
+ const { device, token, user } = decodeHttpResponse(
5248
+ RegisterResponseCodec,
5249
+ res.data,
5250
+ );
5251
+ this.device = device;
5252
+ this.setUser(user);
5253
+ this.token = token;
5254
+ this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
5255
+ } catch {
5256
+ const pendingApproval = decodeHttpResponse(
5257
+ RegisterPendingApprovalCodec,
5258
+ res.data,
5259
+ );
5260
+ return [
5261
+ null,
5262
+ new DeviceApprovalRequiredError({
5263
+ challenge: pendingApproval.challenge,
5264
+ expiresAt: pendingApproval.expiresAt,
5265
+ requestID: pendingApproval.requestID,
5266
+ userID: pendingApproval.userID,
5267
+ }),
5268
+ ];
5269
+ }
5270
+ return [this.getUser(), null];
5271
+ } catch (err: unknown) {
5272
+ if (isHttpError(err) && err.response) {
5273
+ return [
5274
+ null,
5275
+ new Error(spireErrorBodyMessage(err.response.data)),
5276
+ ];
5277
+ }
5278
+ return [
5279
+ null,
5280
+ err instanceof Error ? err : new Error(String(err)),
5281
+ ];
5282
+ }
5283
+ } else {
5284
+ return [null, new Error("Couldn't get regkey from server.")];
5285
+ }
5286
+ }
5287
+
5147
5288
  private registerDecryptFailure(mail: MailWS): number {
5148
5289
  const count = (this.decryptFailureCounts.get(mail.mailID) ?? 0) + 1;
5149
5290
  this.decryptFailureCounts.set(mail.mailID, count);
@@ -5211,6 +5352,30 @@ export class Client {
5211
5352
  );
5212
5353
  }
5213
5354
 
5355
+ private async removeServerIcon(serverID: string): Promise<Server> {
5356
+ const res = await this.http.delete(
5357
+ this.getHost() + "/server-icon/" + serverID,
5358
+ );
5359
+ return decodeHttpResponse(ServerCodec, res.data);
5360
+ }
5361
+
5362
+ private async replaceAccountPassword(payload: {
5363
+ currentPassword?: string;
5364
+ newPassword: string;
5365
+ }): Promise<void> {
5366
+ await this.http.patch(
5367
+ this.getHost() + "/user/" + this.getUser().userID + "/password",
5368
+ msgpack.encode(payload),
5369
+ { headers: { "Content-Type": "application/msgpack" } },
5370
+ );
5371
+ }
5372
+
5373
+ private async resetPasswordWithPasskey(newPassword: string): Promise<void> {
5374
+ const passwordError = registrationPasswordError(newPassword);
5375
+ if (passwordError) throw passwordError;
5376
+ await this.replaceAccountPassword({ newPassword });
5377
+ }
5378
+
5214
5379
  private async respond(msg: ChallMsg) {
5215
5380
  const response: RespMsg = {
5216
5381
  signed: await xSignAsync(
@@ -5322,7 +5487,7 @@ export class Client {
5322
5487
  challenge: newDevice.challenge,
5323
5488
  expiresAt: newDevice.expiresAt,
5324
5489
  requestID: newDevice.requestID,
5325
- userID: newDevice.userID ?? null,
5490
+ userID: newDevice.userID,
5326
5491
  });
5327
5492
  } else {
5328
5493
  throw new Error("Error registering device.");
@@ -5600,8 +5765,8 @@ export class Client {
5600
5765
  a.deviceID.localeCompare(b.deviceID, "en"),
5601
5766
  );
5602
5767
 
5603
- let failCount = 0;
5604
- let lastErr: unknown;
5768
+ const successfulOwnerIDs = new Set<string>();
5769
+ const lastErrorByOwnerID = new Map<string, unknown>();
5605
5770
  for (
5606
5771
  let index = 0;
5607
5772
  index < stableDevices.length;
@@ -5612,15 +5777,19 @@ export class Client {
5612
5777
  index + MAIL_FANOUT_CONCURRENCY,
5613
5778
  );
5614
5779
  const results = await Promise.all(
5615
- batch.map(async (device): Promise<undefined | unknown> => {
5780
+ batch.map(async (device) => {
5616
5781
  const ownerRecord =
5617
5782
  device.owner === myUserID
5618
5783
  ? this.getUser()
5619
5784
  : this.userRecords[device.owner];
5620
5785
  if (!ownerRecord) {
5621
- return new Error(
5622
- `Missing owner record for device ${device.deviceID}.`,
5623
- );
5786
+ return {
5787
+ device,
5788
+ error: new Error(
5789
+ `Missing owner record for device ${device.deviceID}.`,
5790
+ ),
5791
+ ok: false as const,
5792
+ };
5624
5793
  }
5625
5794
  try {
5626
5795
  await this.sendMailWithRecovery(
@@ -5631,35 +5800,41 @@ export class Client {
5631
5800
  mailID,
5632
5801
  false,
5633
5802
  );
5634
- return undefined;
5635
- } catch (e) {
5636
- return e;
5803
+ return { device, ok: true as const };
5804
+ } catch (error: unknown) {
5805
+ return { device, error, ok: false as const };
5637
5806
  }
5638
5807
  }),
5639
5808
  );
5640
5809
  for (const result of results) {
5641
- if (result !== undefined) {
5642
- lastErr = result;
5643
- failCount += 1;
5810
+ if (result.ok) {
5811
+ successfulOwnerIDs.add(result.device.owner);
5812
+ } else {
5813
+ lastErrorByOwnerID.set(result.device.owner, result.error);
5644
5814
  }
5645
5815
  }
5646
- if (failCount === stableDevices.length) {
5647
- break;
5648
- }
5649
5816
  }
5650
5817
 
5651
- if (failCount === stableDevices.length) {
5652
- throw lastErr instanceof Error
5653
- ? lastErr
5654
- : new Error(String(lastErr));
5655
- }
5656
- if (failCount > 0) {
5657
- const partial = new Error(
5658
- `Group message failed to reach ${String(failCount)} of ` +
5659
- `${String(stableDevices.length)} peer device(s).`,
5818
+ const requiredOwnerIDs =
5819
+ peerUserIDs.length > 0 ? peerUserIDs : [myUserID];
5820
+ const unreachableOwnerIDs = requiredOwnerIDs.filter(
5821
+ (ownerID) => !successfulOwnerIDs.has(ownerID),
5822
+ );
5823
+ if (unreachableOwnerIDs.length > 0) {
5824
+ const lastError = lastErrorByOwnerID.get(
5825
+ unreachableOwnerIDs.at(-1) ?? "",
5826
+ );
5827
+ if (
5828
+ unreachableOwnerIDs.length === requiredOwnerIDs.length &&
5829
+ lastError instanceof Error
5830
+ ) {
5831
+ throw lastError;
5832
+ }
5833
+ const deliveryError = new Error(
5834
+ `Message could not be delivered to ${String(unreachableOwnerIDs.length)} channel member(s).`,
5660
5835
  );
5661
- partial.cause = lastErr;
5662
- throw partial;
5836
+ deliveryError.cause = lastError;
5837
+ throw deliveryError;
5663
5838
  }
5664
5839
  }
5665
5840
 
@@ -5717,6 +5892,7 @@ export class Client {
5717
5892
  await ratchetStepSend(session);
5718
5893
  }
5719
5894
  const { messageKey, n } = takeSendMessageKey(session);
5895
+ const messageSubkeys = xMessageKeySubkeys(messageKey);
5720
5896
  const ratchetHeader = {
5721
5897
  dhPub: session.DHsPublic,
5722
5898
  n,
@@ -5724,7 +5900,11 @@ export class Client {
5724
5900
  version: 1 as const,
5725
5901
  };
5726
5902
  const nonce = xMakeNonce();
5727
- const cipher = await xSecretboxAsync(msg, nonce, messageKey);
5903
+ const cipher = await xSecretboxAsync(
5904
+ msg,
5905
+ nonce,
5906
+ messageSubkeys.encryptionKey,
5907
+ );
5728
5908
  const extra = encodeRatchetHeader(ratchetHeader);
5729
5909
 
5730
5910
  const mail: MailWS = {
@@ -5749,7 +5929,7 @@ export class Client {
5749
5929
  type: "resource",
5750
5930
  };
5751
5931
 
5752
- const hmac = xHMAC(mail, messageKey);
5932
+ const hmac = xHMAC(mail, messageSubkeys.authenticationKey);
5753
5933
 
5754
5934
  const fwdOut = forward
5755
5935
  ? messageSchema.parse(msgpack.decode(msg))
@@ -5905,6 +6085,7 @@ export class Client {
5905
6085
  }
5906
6086
  let lastErr: unknown;
5907
6087
  let failCount = 0;
6088
+ let successCount = 0;
5908
6089
  // One logical DM fan-outs to multiple recipient devices. Reuse a
5909
6090
  // single mailID so local/UI dedupe treats it as one message.
5910
6091
  const messageMailID = uuid.v4();
@@ -5968,28 +6149,20 @@ export class Client {
5968
6149
  if (result !== undefined) {
5969
6150
  lastErr = result;
5970
6151
  failCount += 1;
6152
+ } else {
6153
+ successCount += 1;
5971
6154
  }
5972
6155
  }
5973
6156
  if (failCount === deviceList.length) {
5974
6157
  break;
5975
6158
  }
5976
6159
  }
5977
- if (failCount > 0) {
6160
+ if (successCount === 0) {
5978
6161
  const base =
5979
6162
  lastErr instanceof Error
5980
6163
  ? lastErr
5981
6164
  : new Error(String(lastErr));
5982
- if (failCount === deviceList.length) {
5983
- throw base;
5984
- }
5985
- // Multi-device: do not “succeed” when only one device of several got mail —
5986
- // callers and tests have no per-device result and the other copy times out.
5987
- const partial = new Error(
5988
- `Direct message failed to reach ${String(failCount)} of ` +
5989
- `${String(deviceList.length)} peer device(s) (X3DH/post).`,
5990
- );
5991
- partial.cause = base;
5992
- throw partial;
6165
+ throw base;
5993
6166
  }
5994
6167
  if (
5995
6168
  userID !== this.getUser().userID &&
@@ -6035,6 +6208,18 @@ export class Client {
6035
6208
  return decodeHttpResponse(AccountEntitlementsCodec, res.data);
6036
6209
  }
6037
6210
 
6211
+ private async setServerMemberRole(
6212
+ permissionID: string,
6213
+ powerLevel: 0 | 50 | 100,
6214
+ ): Promise<Permission> {
6215
+ const res = await this.http.patch(
6216
+ this.getHost() + "/permission/" + permissionID,
6217
+ msgpack.encode({ powerLevel }),
6218
+ { headers: { "Content-Type": "application/msgpack" } },
6219
+ );
6220
+ return decodeHttpResponse(PermissionCodec, res.data);
6221
+ }
6222
+
6038
6223
  private setUser(user: User): void {
6039
6224
  this.user = user;
6040
6225
  // Fresh identity / token: drop stale 404 negative-cache entries so a
@@ -6101,7 +6286,7 @@ export class Client {
6101
6286
  const otks: UnsavedPreKey[] = [];
6102
6287
 
6103
6288
  for (let i = 0; i < amount; i++) {
6104
- otks.push(await this.createPreKey());
6289
+ otks.push(await this.createPreKey("one-time"));
6105
6290
  }
6106
6291
 
6107
6292
  const savedKeys = await this.database.savePreKeys(otks, true);
@@ -6115,6 +6300,30 @@ export class Client {
6115
6300
  );
6116
6301
  }
6117
6302
 
6303
+ private async updateChannel(
6304
+ channelID: string,
6305
+ name: string,
6306
+ ): Promise<Channel> {
6307
+ const res = await this.http.patch(
6308
+ this.getHost() + "/channel/" + channelID,
6309
+ msgpack.encode({ name }),
6310
+ { headers: { "Content-Type": "application/msgpack" } },
6311
+ );
6312
+ return decodeHttpResponse(ChannelCodec, res.data);
6313
+ }
6314
+
6315
+ private async updateServer(
6316
+ serverID: string,
6317
+ name: string,
6318
+ ): Promise<Server> {
6319
+ const res = await this.http.patch(
6320
+ this.getHost() + "/server/" + serverID,
6321
+ msgpack.encode({ name }),
6322
+ { headers: { "Content-Type": "application/msgpack" } },
6323
+ );
6324
+ return decodeHttpResponse(ServerCodec, res.data);
6325
+ }
6326
+
6118
6327
  private async uploadAvatar(avatar: Uint8Array): Promise<void> {
6119
6328
  const canUseMultipart =
6120
6329
  typeof FormData !== "undefined" &&
@@ -6230,4 +6439,52 @@ export class Client {
6230
6439
  return null;
6231
6440
  }
6232
6441
  }
6442
+
6443
+ private async uploadServerIcon(
6444
+ serverID: string,
6445
+ icon: Uint8Array,
6446
+ ): Promise<Server> {
6447
+ const canUseMultipart =
6448
+ typeof FormData !== "undefined" &&
6449
+ (() => {
6450
+ try {
6451
+ void new Blob([new Uint8Array([1, 2, 3])]);
6452
+ return true;
6453
+ } catch {
6454
+ return false;
6455
+ }
6456
+ })();
6457
+
6458
+ if (canUseMultipart) {
6459
+ const payload = new FormData();
6460
+ payload.set("icon", new Blob([new Uint8Array(icon)]));
6461
+ const res = await this.http.post(
6462
+ this.getHost() + "/server-icon/" + serverID,
6463
+ payload,
6464
+ {
6465
+ headers: { "Content-Type": "multipart/form-data" },
6466
+ onUploadProgress: (progressEvent) => {
6467
+ const { loaded, total = 0 } = progressEvent;
6468
+ this.emitter.emit("fileProgress", {
6469
+ direction: "upload",
6470
+ loaded,
6471
+ progress: Math.round(
6472
+ (loaded * 100) / (progressEvent.total ?? 1),
6473
+ ),
6474
+ token: serverID,
6475
+ total,
6476
+ });
6477
+ },
6478
+ },
6479
+ );
6480
+ return decodeHttpResponse(ServerCodec, res.data);
6481
+ }
6482
+
6483
+ const res = await this.http.post(
6484
+ this.getHost() + "/server-icon/" + serverID + "/json",
6485
+ msgpack.encode({ file: XUtils.encodeBase64(icon) }),
6486
+ { headers: { "Content-Type": "application/msgpack" } },
6487
+ );
6488
+ return decodeHttpResponse(ServerCodec, res.data);
6489
+ }
6233
6490
  }