@vex-chat/libvex 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  }
@@ -504,9 +524,8 @@ export interface Devices {
504
524
  }) => Promise<void>;
505
525
  /**
506
526
  * 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.
527
+ * The server verifies a signature from this approved device before
528
+ * admitting the pending device into the account cluster.
510
529
  */
511
530
  approveRequest: (requestID: string) => Promise<Device>;
512
531
  /**
@@ -547,10 +566,8 @@ export interface Devices {
547
566
  * the request's private signing key by signing the challenge issued by
548
567
  * the server in the original 202 response.
549
568
  *
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}).
569
+ * @param args - The request ID and hex challenge returned by `/register`
570
+ * (or carried by {@link DeviceApprovalRequiredError}).
554
571
  * @returns The current {@link PendingDeviceRequest} or `null` if the
555
572
  * server no longer has a record of it.
556
573
  */
@@ -702,6 +719,11 @@ export interface Keys {
702
719
  * @ignore
703
720
  */
704
721
  export interface Me {
722
+ /** Replace the account password after proving the current password. */
723
+ changePassword: (
724
+ currentPassword: string,
725
+ newPassword: string,
726
+ ) => Promise<void>;
705
727
  /** Returns metadata for the currently authenticated device. */
706
728
  device: () => Device;
707
729
  /** Uploads and sets a new avatar image for the current user. */
@@ -831,7 +853,7 @@ export interface Passkeys {
831
853
  options: any;
832
854
  requestID: string;
833
855
  }>;
834
- /** Remove a passkey from the account. */
856
+ /** Remove a passkey using the account's approved-device session. */
835
857
  delete: (passkeyID: string) => Promise<void>;
836
858
  /** Delete one of the account's devices using the passkey session. */
837
859
  deleteDevice: (deviceID: string) => Promise<void>;
@@ -868,6 +890,8 @@ export interface Passkeys {
868
890
  recoverDeviceRequest: (requestID: string) => Promise<Device>;
869
891
  /** Reject a pending device-enrollment request using the passkey session. */
870
892
  rejectDeviceRequest: (requestID: string) => Promise<void>;
893
+ /** Replace a forgotten password using this fresh passkey session. */
894
+ resetPassword: (newPassword: string) => Promise<void>;
871
895
  }
872
896
 
873
897
  export type PendingDeviceApprovalStatus =
@@ -881,13 +905,8 @@ export interface PendingDeviceRegistration {
881
905
  expiresAt: string;
882
906
  requestID: string;
883
907
  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;
908
+ /** Existing user's ID used to render the account confirmation step. */
909
+ userID: string;
891
910
  }
892
911
 
893
912
  export interface PendingDeviceRequest {
@@ -1526,6 +1545,7 @@ export class Client {
1526
1545
  * Helpers for information/actions related to the currently authenticated account.
1527
1546
  */
1528
1547
  public me: Me = {
1548
+ changePassword: this.changePassword.bind(this),
1529
1549
  /**
1530
1550
  * Retrieves current device details.
1531
1551
  *
@@ -1618,6 +1638,7 @@ export class Client {
1618
1638
  listDevices: this.passkeyListDevices.bind(this),
1619
1639
  recoverDeviceRequest: this.passkeyRecoverDeviceRequest.bind(this),
1620
1640
  rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
1641
+ resetPassword: this.resetPasswordWithPasskey.bind(this),
1621
1642
  };
1622
1643
 
1623
1644
  /**
@@ -1730,7 +1751,7 @@ export class Client {
1730
1751
 
1731
1752
  private readonly decryptFailureCounts = new Map<string, number>();
1732
1753
 
1733
- private device?: Device;
1754
+ private device: Device | undefined;
1734
1755
 
1735
1756
  private deviceRecords: Record<string, Device> = {};
1736
1757
  // ── Event subscription (composition over inheritance) ───────────────
@@ -1787,7 +1808,7 @@ export class Client {
1787
1808
  private socket: WebSocketLike;
1788
1809
  private token: null | string = null;
1789
1810
 
1790
- private user?: User;
1811
+ private user: undefined | User;
1791
1812
 
1792
1813
  private userRecords: Record<string, User> = {};
1793
1814
  private xKeyRing?: XKeyRing;
@@ -1914,14 +1935,32 @@ export class Client {
1914
1935
 
1915
1936
  let resolvedStorage = storage;
1916
1937
  if (!resolvedStorage) {
1917
- const { createNodeStorage } = await import("./storage/node.js");
1938
+ const nodeStorageModule = "./storage/node.js";
1939
+ const isNodeStorageModule = (
1940
+ value: unknown,
1941
+ ): value is {
1942
+ createNodeStorage: (
1943
+ dbPath: string,
1944
+ atRestAesKey: Uint8Array,
1945
+ ) => Storage;
1946
+ } =>
1947
+ typeof value === "object" &&
1948
+ value !== null &&
1949
+ "createNodeStorage" in value &&
1950
+ typeof value.createNodeStorage === "function";
1951
+ const nodeStorage: unknown = await import(
1952
+ /* @vite-ignore */ nodeStorageModule
1953
+ );
1954
+ if (!isNodeStorageModule(nodeStorage)) {
1955
+ throw new Error("Node storage adapter is unavailable.");
1956
+ }
1918
1957
  const dbFileName = options?.inMemoryDb
1919
1958
  ? ":memory:"
1920
1959
  : XUtils.encodeHex(signKeys.publicKey) + ".sqlite";
1921
1960
  const dbPath = options?.dbFolder
1922
1961
  ? options.dbFolder + "/" + dbFileName
1923
1962
  : dbFileName;
1924
- resolvedStorage = createNodeStorage(dbPath, atRestAes);
1963
+ resolvedStorage = nodeStorage.createNodeStorage(dbPath, atRestAes);
1925
1964
  }
1926
1965
 
1927
1966
  await resolvedStorage.init();
@@ -2292,10 +2331,35 @@ export class Client {
2292
2331
  }
2293
2332
 
2294
2333
  /**
2295
- * Logs out the current authenticated session from the server.
2334
+ * Ends the local authenticated session and disconnects realtime transport.
2335
+ * Spire access tokens are stateless, so local credential removal is the
2336
+ * authoritative logout boundary.
2296
2337
  */
2297
2338
  public async logout(): Promise<void> {
2298
- await this.http.post(this.getHost() + "/goodbye");
2339
+ try {
2340
+ if (this.token) {
2341
+ await this.http.post(this.getHost() + "/goodbye");
2342
+ }
2343
+ } catch {
2344
+ // Logout must still complete locally when the server is unreachable.
2345
+ } finally {
2346
+ this.token = null;
2347
+ this.user = undefined;
2348
+ this.device = undefined;
2349
+ delete this.http.defaults.headers.common.Authorization;
2350
+ delete this.http.defaults.headers.common["X-Device-Token"];
2351
+ this.autoReconnectEnabled = false;
2352
+ this.postAuthVersion++;
2353
+ if (this.reconnectTimer) {
2354
+ clearTimeout(this.reconnectTimer);
2355
+ this.reconnectTimer = null;
2356
+ }
2357
+ if (this.pingInterval) {
2358
+ clearInterval(this.pingInterval);
2359
+ this.pingInterval = null;
2360
+ }
2361
+ this.socket.close();
2362
+ }
2299
2363
  }
2300
2364
 
2301
2365
  /** Removes an event listener. See {@link ClientEvents} for available events. */
@@ -2367,8 +2431,8 @@ export class Client {
2367
2431
  /**
2368
2432
  * Registers a new account on the server.
2369
2433
  *
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.
2434
+ * @param username - Username to register.
2435
+ * @param password - Password for the account and device approval request.
2372
2436
  * @returns `[user, null]` on success, `[null, error]` on failure.
2373
2437
  *
2374
2438
  * @example
@@ -2377,139 +2441,18 @@ export class Client {
2377
2441
  * ```
2378
2442
  */
2379
2443
  public async register(
2380
- username?: string,
2381
- password?: string,
2444
+ username: string,
2445
+ password: string,
2382
2446
  ): 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
- }
2447
+ const passwordError = registrationPasswordError(password);
2448
+ if (passwordError) {
2449
+ return [null, passwordError];
2450
+ }
2451
+ return this.registerAccountOrEnrollment(
2452
+ username,
2453
+ "create-account",
2454
+ password,
2455
+ );
2513
2456
  }
2514
2457
 
2515
2458
  removeAllListeners(event?: keyof ClientEvents): this {
@@ -2517,6 +2460,36 @@ export class Client {
2517
2460
  return this;
2518
2461
  }
2519
2462
 
2463
+ /**
2464
+ * Requests approval for a new device using the account password. This
2465
+ * enrollment flow never creates an account when the username is unknown.
2466
+ */
2467
+ public async requestDeviceEnrollment(
2468
+ username: string,
2469
+ password: string,
2470
+ ): Promise<[null | User, Error | null]> {
2471
+ const passwordError = registrationPasswordError(password);
2472
+ if (passwordError) {
2473
+ return [null, passwordError];
2474
+ }
2475
+ return this.registerAccountOrEnrollment(
2476
+ username,
2477
+ "enroll-device",
2478
+ password,
2479
+ );
2480
+ }
2481
+
2482
+ /**
2483
+ * Requests enrollment for this device after a successful passkey ceremony.
2484
+ * This is not account registration: the passkey bearer must identify an
2485
+ * existing account, and the server returns a pending device request.
2486
+ */
2487
+ public async requestDeviceEnrollmentWithPasskey(
2488
+ username: string,
2489
+ ): Promise<[null | User, Error | null]> {
2490
+ return this.registerAccountOrEnrollment(username, "enroll-device");
2491
+ }
2492
+
2520
2493
  /**
2521
2494
  * Updates the local retention cap (1–30 days) and prunes immediately.
2522
2495
  * Does not affect server-side storage.
@@ -2737,6 +2710,21 @@ export class Client {
2737
2710
  };
2738
2711
  }
2739
2712
 
2713
+ private async changePassword(
2714
+ currentPassword: string,
2715
+ newPassword: string,
2716
+ ): Promise<void> {
2717
+ const passwordError = registrationPasswordError(newPassword);
2718
+ if (passwordError) throw passwordError;
2719
+ if (
2720
+ currentPassword.length === 0 ||
2721
+ currentPassword.length > ACCOUNT_PASSWORD_MAX_LENGTH
2722
+ ) {
2723
+ throw new Error("Current password is invalid.");
2724
+ }
2725
+ await this.replaceAccountPassword({ currentPassword, newPassword });
2726
+ }
2727
+
2740
2728
  private async createChannel(
2741
2729
  name: string,
2742
2730
  serverID: string,
@@ -2848,13 +2836,16 @@ export class Client {
2848
2836
  return decodeHttpResponse(InviteCodec, res.data);
2849
2837
  }
2850
2838
 
2851
- private async createPreKey(): Promise<UnsavedPreKey> {
2839
+ private async createPreKey(
2840
+ kind: "one-time" | "signed",
2841
+ ): Promise<UnsavedPreKey> {
2852
2842
  return this.runWithThisCryptoProfile(async () => {
2853
2843
  const preKeyPair = await xBoxKeyPairAsync();
2854
- const toSign =
2855
- this.cryptoProfile === "fips"
2856
- ? fipsP256PreKeySignPayload(preKeyPair.publicKey)
2857
- : xEncode(xConstants.CURVE, preKeyPair.publicKey);
2844
+ const toSign = xPreKeySignaturePayload(
2845
+ preKeyPair.publicKey,
2846
+ kind,
2847
+ this.cryptoProfile,
2848
+ );
2858
2849
  return {
2859
2850
  keyPair: preKeyPair,
2860
2851
  signature: await xSignAsync(toSign, this.signKeys.secretKey),
@@ -2959,6 +2950,7 @@ export class Client {
2959
2950
 
2960
2951
  // shared secret key
2961
2952
  const SK = xKDF(IKM);
2953
+ const initialSubkeys = xMessageKeySubkeys(SK);
2962
2954
  const PK = (await xBoxKeyPairFromSecretAsync(SK)).publicKey;
2963
2955
 
2964
2956
  const AD = fips
@@ -2972,7 +2964,11 @@ export class Client {
2972
2964
  );
2973
2965
 
2974
2966
  const nonce = xMakeNonce();
2975
- const cipher = await xSecretboxAsync(message, nonce, SK);
2967
+ const cipher = await xSecretboxAsync(
2968
+ message,
2969
+ nonce,
2970
+ initialSubkeys.encryptionKey,
2971
+ );
2976
2972
 
2977
2973
  const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
2978
2974
  const ephKeyWire = ephemeralKeys.publicKey;
@@ -3001,7 +2997,7 @@ export class Client {
3001
2997
  sender: this.getDevice().deviceID,
3002
2998
  };
3003
2999
 
3004
- const hmac = xHMAC(mail, SK);
3000
+ const hmac = xHMAC(mail, initialSubkeys.authenticationKey);
3005
3001
 
3006
3002
  const msg: ResourceMsg = {
3007
3003
  action: "CREATE",
@@ -3190,6 +3186,7 @@ export class Client {
3190
3186
  return z.object({ calls: z.array(CallSessionSchema) }).parse(res.data)
3191
3187
  .calls;
3192
3188
  }
3189
+
3193
3190
  private async fetchIceServers(): Promise<IceServerConfig[]> {
3194
3191
  const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
3195
3192
  responseType: "json",
@@ -3258,7 +3255,6 @@ export class Client {
3258
3255
  return [null, isHttpError(err) ? err : null];
3259
3256
  }
3260
3257
  }
3261
-
3262
3258
  private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
3263
3259
  if (this.isManualCloseInFlight()) {
3264
3260
  return [];
@@ -3657,6 +3653,9 @@ export class Client {
3657
3653
  this.getDevice().deviceID +
3658
3654
  "/mail",
3659
3655
  );
3656
+ if (!this.token || this.isManualCloseInFlight()) {
3657
+ return;
3658
+ }
3660
3659
  const mailBuffer = new Uint8Array(res.data);
3661
3660
  const rawInbox = z
3662
3661
  .array(mailInboxEntry)
@@ -4055,10 +4054,11 @@ export class Client {
4055
4054
  preKey: PreKeysCrypto,
4056
4055
  ): Promise<boolean> {
4057
4056
  return this.runWithThisCryptoProfile(async () => {
4058
- const payload =
4059
- this.cryptoProfile === "fips"
4060
- ? fipsP256PreKeySignPayload(preKey.keyPair.publicKey)
4061
- : xEncode(xConstants.CURVE, preKey.keyPair.publicKey);
4057
+ const payload = xPreKeySignaturePayload(
4058
+ preKey.keyPair.publicKey,
4059
+ "signed",
4060
+ this.cryptoProfile,
4061
+ );
4062
4062
  const opened = await xSignOpenAsync(
4063
4063
  preKey.signature,
4064
4064
  this.signKeys.publicKey,
@@ -4286,7 +4286,7 @@ export class Client {
4286
4286
  !preKeys ||
4287
4287
  !(await this.isPreKeySignedByCurrentDevice(preKeys))
4288
4288
  ) {
4289
- const unsaved = await this.createPreKey();
4289
+ const unsaved = await this.createPreKey("signed");
4290
4290
  const [saved] = await this.database.savePreKeys(
4291
4291
  [unsaved],
4292
4292
  false,
@@ -4648,10 +4648,14 @@ export class Client {
4648
4648
 
4649
4649
  // shared secret key
4650
4650
  const SK = xKDF(IKM);
4651
+ const initialSubkeys = xMessageKeySubkeys(SK);
4651
4652
  const PK = (await xBoxKeyPairFromSecretAsync(SK))
4652
4653
  .publicKey;
4653
4654
 
4654
- const hmac = xHMAC(mail, SK);
4655
+ const hmac = xHMAC(
4656
+ mail,
4657
+ initialSubkeys.authenticationKey,
4658
+ );
4655
4659
 
4656
4660
  // associated data
4657
4661
  const AD = fipsRead
@@ -4701,7 +4705,7 @@ export class Client {
4701
4705
  const unsealed = await xSecretboxOpenAsync(
4702
4706
  new Uint8Array(mail.cipher),
4703
4707
  new Uint8Array(mail.nonce),
4704
- SK,
4708
+ initialSubkeys.encryptionKey,
4705
4709
  );
4706
4710
  if (unsealed) {
4707
4711
  let plaintext = "";
@@ -4918,12 +4922,16 @@ export class Client {
4918
4922
  );
4919
4923
  }
4920
4924
 
4921
- let messageKey = takeReceiveMessageKey(
4925
+ const messageKey = takeReceiveMessageKey(
4922
4926
  candidateSession,
4923
4927
  ratchetHeader.dhPub,
4924
4928
  ratchetHeader.n,
4925
4929
  );
4926
- let HMAC = xHMAC(mail, messageKey);
4930
+ let messageSubkeys = xMessageKeySubkeys(messageKey);
4931
+ let HMAC = xHMAC(
4932
+ mail,
4933
+ messageSubkeys.authenticationKey,
4934
+ );
4927
4935
 
4928
4936
  if (
4929
4937
  !XUtils.bytesEqual(HMAC, header) &&
@@ -4942,9 +4950,11 @@ export class Client {
4942
4950
  ratchetHeader.dhPub,
4943
4951
  ratchetHeader.n,
4944
4952
  );
4953
+ const ratchetedSubkeys =
4954
+ xMessageKeySubkeys(ratchetedMessageKey);
4945
4955
  const ratchetedHMAC = xHMAC(
4946
4956
  mail,
4947
- ratchetedMessageKey,
4957
+ ratchetedSubkeys.authenticationKey,
4948
4958
  );
4949
4959
  if (XUtils.bytesEqual(ratchetedHMAC, header)) {
4950
4960
  if (libvexDebugDmEnabled()) {
@@ -4958,7 +4968,7 @@ export class Client {
4958
4968
  );
4959
4969
  }
4960
4970
  candidateSession = ratchetedCandidate;
4961
- messageKey = ratchetedMessageKey;
4971
+ messageSubkeys = ratchetedSubkeys;
4962
4972
  HMAC = ratchetedHMAC;
4963
4973
  }
4964
4974
  }
@@ -4999,7 +5009,7 @@ export class Client {
4999
5009
  const decrypted = await xSecretboxOpenAsync(
5000
5010
  new Uint8Array(mail.cipher),
5001
5011
  new Uint8Array(mail.nonce),
5002
- messageKey,
5012
+ messageSubkeys.encryptionKey,
5003
5013
  );
5004
5014
 
5005
5015
  if (decrypted) {
@@ -5144,6 +5154,99 @@ export class Client {
5144
5154
  return decodeHttpResponse(PermissionCodec, res.data);
5145
5155
  }
5146
5156
 
5157
+ private async registerAccountOrEnrollment(
5158
+ username: string,
5159
+ intent: RegistrationPayload["intent"],
5160
+ password?: string,
5161
+ ): Promise<[null | User, Error | null]> {
5162
+ const resolvedUsername = username.trim().toLowerCase();
5163
+ if (!/^\w{3,19}$/.test(resolvedUsername)) {
5164
+ return [
5165
+ null,
5166
+ new Error(
5167
+ "Username must be between three and nineteen letters, digits, or underscores.",
5168
+ ),
5169
+ ];
5170
+ }
5171
+ while (!this.xKeyRing) {
5172
+ await sleep(100);
5173
+ }
5174
+ const regKey = await this.getToken("register");
5175
+ if (regKey) {
5176
+ const signKey = XUtils.encodeHex(this.signKeys.publicKey);
5177
+ const signed = XUtils.encodeHex(
5178
+ await xSignAsync(
5179
+ Uint8Array.from(uuid.parse(regKey.key)),
5180
+ this.signKeys.secretKey,
5181
+ ),
5182
+ );
5183
+ const preKeyIndex = this.xKeyRing.preKeys.index;
5184
+ const regMsg: RegistrationPayload = {
5185
+ deviceName: this.options?.deviceName ?? "unknown",
5186
+ intent,
5187
+ preKey: XUtils.encodeHex(
5188
+ this.xKeyRing.preKeys.keyPair.publicKey,
5189
+ ),
5190
+ preKeyIndex,
5191
+ preKeySignature: XUtils.encodeHex(
5192
+ this.xKeyRing.preKeys.signature,
5193
+ ),
5194
+ signed,
5195
+ signKey,
5196
+ username: resolvedUsername,
5197
+ };
5198
+ if (password !== undefined) {
5199
+ regMsg.password = password;
5200
+ }
5201
+ try {
5202
+ const res = await this.http.post(
5203
+ this.getHost() + "/register",
5204
+ msgpack.encode(regMsg),
5205
+ { headers: { "Content-Type": "application/msgpack" } },
5206
+ );
5207
+
5208
+ try {
5209
+ const { device, token, user } = decodeHttpResponse(
5210
+ RegisterResponseCodec,
5211
+ res.data,
5212
+ );
5213
+ this.device = device;
5214
+ this.setUser(user);
5215
+ this.token = token;
5216
+ this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
5217
+ } catch {
5218
+ const pendingApproval = decodeHttpResponse(
5219
+ RegisterPendingApprovalCodec,
5220
+ res.data,
5221
+ );
5222
+ return [
5223
+ null,
5224
+ new DeviceApprovalRequiredError({
5225
+ challenge: pendingApproval.challenge,
5226
+ expiresAt: pendingApproval.expiresAt,
5227
+ requestID: pendingApproval.requestID,
5228
+ userID: pendingApproval.userID,
5229
+ }),
5230
+ ];
5231
+ }
5232
+ return [this.getUser(), null];
5233
+ } catch (err: unknown) {
5234
+ if (isHttpError(err) && err.response) {
5235
+ return [
5236
+ null,
5237
+ new Error(spireErrorBodyMessage(err.response.data)),
5238
+ ];
5239
+ }
5240
+ return [
5241
+ null,
5242
+ err instanceof Error ? err : new Error(String(err)),
5243
+ ];
5244
+ }
5245
+ } else {
5246
+ return [null, new Error("Couldn't get regkey from server.")];
5247
+ }
5248
+ }
5249
+
5147
5250
  private registerDecryptFailure(mail: MailWS): number {
5148
5251
  const count = (this.decryptFailureCounts.get(mail.mailID) ?? 0) + 1;
5149
5252
  this.decryptFailureCounts.set(mail.mailID, count);
@@ -5211,6 +5314,23 @@ export class Client {
5211
5314
  );
5212
5315
  }
5213
5316
 
5317
+ private async replaceAccountPassword(payload: {
5318
+ currentPassword?: string;
5319
+ newPassword: string;
5320
+ }): Promise<void> {
5321
+ await this.http.patch(
5322
+ this.getHost() + "/user/" + this.getUser().userID + "/password",
5323
+ msgpack.encode(payload),
5324
+ { headers: { "Content-Type": "application/msgpack" } },
5325
+ );
5326
+ }
5327
+
5328
+ private async resetPasswordWithPasskey(newPassword: string): Promise<void> {
5329
+ const passwordError = registrationPasswordError(newPassword);
5330
+ if (passwordError) throw passwordError;
5331
+ await this.replaceAccountPassword({ newPassword });
5332
+ }
5333
+
5214
5334
  private async respond(msg: ChallMsg) {
5215
5335
  const response: RespMsg = {
5216
5336
  signed: await xSignAsync(
@@ -5322,7 +5442,7 @@ export class Client {
5322
5442
  challenge: newDevice.challenge,
5323
5443
  expiresAt: newDevice.expiresAt,
5324
5444
  requestID: newDevice.requestID,
5325
- userID: newDevice.userID ?? null,
5445
+ userID: newDevice.userID,
5326
5446
  });
5327
5447
  } else {
5328
5448
  throw new Error("Error registering device.");
@@ -5717,6 +5837,7 @@ export class Client {
5717
5837
  await ratchetStepSend(session);
5718
5838
  }
5719
5839
  const { messageKey, n } = takeSendMessageKey(session);
5840
+ const messageSubkeys = xMessageKeySubkeys(messageKey);
5720
5841
  const ratchetHeader = {
5721
5842
  dhPub: session.DHsPublic,
5722
5843
  n,
@@ -5724,7 +5845,11 @@ export class Client {
5724
5845
  version: 1 as const,
5725
5846
  };
5726
5847
  const nonce = xMakeNonce();
5727
- const cipher = await xSecretboxAsync(msg, nonce, messageKey);
5848
+ const cipher = await xSecretboxAsync(
5849
+ msg,
5850
+ nonce,
5851
+ messageSubkeys.encryptionKey,
5852
+ );
5728
5853
  const extra = encodeRatchetHeader(ratchetHeader);
5729
5854
 
5730
5855
  const mail: MailWS = {
@@ -5749,7 +5874,7 @@ export class Client {
5749
5874
  type: "resource",
5750
5875
  };
5751
5876
 
5752
- const hmac = xHMAC(mail, messageKey);
5877
+ const hmac = xHMAC(mail, messageSubkeys.authenticationKey);
5753
5878
 
5754
5879
  const fwdOut = forward
5755
5880
  ? messageSchema.parse(msgpack.decode(msg))
@@ -6101,7 +6226,7 @@ export class Client {
6101
6226
  const otks: UnsavedPreKey[] = [];
6102
6227
 
6103
6228
  for (let i = 0; i < amount; i++) {
6104
- otks.push(await this.createPreKey());
6229
+ otks.push(await this.createPreKey("one-time"));
6105
6230
  }
6106
6231
 
6107
6232
  const savedKeys = await this.database.savePreKeys(otks, true);