@vex-chat/libvex 7.4.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 {
@@ -1306,8 +1325,10 @@ export interface Users {
1306
1325
  * const client = await Client.create(privateKey);
1307
1326
  *
1308
1327
  * // you must register once before you can log in
1309
- * await client.register(Client.randomUsername());
1310
- * await client.login();
1328
+ * const username = Client.randomUsername();
1329
+ * const password = "correct horse battery staple";
1330
+ * await client.register(username, password);
1331
+ * await client.login(username, password);
1311
1332
  *
1312
1333
  * // The ready event fires after connect() finishes post-auth setup.
1313
1334
  * // Wait for it before performing messaging or user operations.
@@ -1524,6 +1545,7 @@ export class Client {
1524
1545
  * Helpers for information/actions related to the currently authenticated account.
1525
1546
  */
1526
1547
  public me: Me = {
1548
+ changePassword: this.changePassword.bind(this),
1527
1549
  /**
1528
1550
  * Retrieves current device details.
1529
1551
  *
@@ -1616,6 +1638,7 @@ export class Client {
1616
1638
  listDevices: this.passkeyListDevices.bind(this),
1617
1639
  recoverDeviceRequest: this.passkeyRecoverDeviceRequest.bind(this),
1618
1640
  rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
1641
+ resetPassword: this.resetPasswordWithPasskey.bind(this),
1619
1642
  };
1620
1643
 
1621
1644
  /**
@@ -1728,7 +1751,7 @@ export class Client {
1728
1751
 
1729
1752
  private readonly decryptFailureCounts = new Map<string, number>();
1730
1753
 
1731
- private device?: Device;
1754
+ private device: Device | undefined;
1732
1755
 
1733
1756
  private deviceRecords: Record<string, Device> = {};
1734
1757
  // ── Event subscription (composition over inheritance) ───────────────
@@ -1785,7 +1808,7 @@ export class Client {
1785
1808
  private socket: WebSocketLike;
1786
1809
  private token: null | string = null;
1787
1810
 
1788
- private user?: User;
1811
+ private user: undefined | User;
1789
1812
 
1790
1813
  private userRecords: Record<string, User> = {};
1791
1814
  private xKeyRing?: XKeyRing;
@@ -1912,14 +1935,32 @@ export class Client {
1912
1935
 
1913
1936
  let resolvedStorage = storage;
1914
1937
  if (!resolvedStorage) {
1915
- 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
+ }
1916
1957
  const dbFileName = options?.inMemoryDb
1917
1958
  ? ":memory:"
1918
1959
  : XUtils.encodeHex(signKeys.publicKey) + ".sqlite";
1919
1960
  const dbPath = options?.dbFolder
1920
1961
  ? options.dbFolder + "/" + dbFileName
1921
1962
  : dbFileName;
1922
- resolvedStorage = createNodeStorage(dbPath, atRestAes);
1963
+ resolvedStorage = nodeStorage.createNodeStorage(dbPath, atRestAes);
1923
1964
  }
1924
1965
 
1925
1966
  await resolvedStorage.init();
@@ -2290,10 +2331,35 @@ export class Client {
2290
2331
  }
2291
2332
 
2292
2333
  /**
2293
- * 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.
2294
2337
  */
2295
2338
  public async logout(): Promise<void> {
2296
- 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
+ }
2297
2363
  }
2298
2364
 
2299
2365
  /** Removes an event listener. See {@link ClientEvents} for available events. */
@@ -2365,139 +2431,28 @@ export class Client {
2365
2431
  /**
2366
2432
  * Registers a new account on the server.
2367
2433
  *
2368
- * @param username - Optional username to register (must be unique when provided).
2369
- * @param password - Optional legacy password used when talking to pre-keycluster servers.
2434
+ * @param username - Username to register.
2435
+ * @param password - Password for the account and device approval request.
2370
2436
  * @returns `[user, null]` on success, `[null, error]` on failure.
2371
2437
  *
2372
2438
  * @example
2373
2439
  * ```ts
2374
- * const [user, err] = await client.register("MyUsername");
2440
+ * const [user, err] = await client.register("MyUsername", "correct horse battery staple");
2375
2441
  * ```
2376
2442
  */
2377
2443
  public async register(
2378
- username?: string,
2379
- password?: string,
2444
+ username: string,
2445
+ password: string,
2380
2446
  ): Promise<[null | User, Error | null]> {
2381
- while (!this.xKeyRing) {
2382
- await sleep(100);
2383
- }
2384
- const regKey = await this.getToken("register");
2385
- if (regKey) {
2386
- // Usernames are case-insensitive at the protocol level;
2387
- // lowercase before sending so the local SDK view matches
2388
- // what the server canonicalizes and persists.
2389
- const resolvedUsername =
2390
- username?.trim().length !== 0 && username !== undefined
2391
- ? username.trim().toLowerCase()
2392
- : Client.randomUsername();
2393
- const resolvedPassword =
2394
- password?.trim().length !== 0 && password !== undefined
2395
- ? password
2396
- : uuid.v4();
2397
- const signKey = XUtils.encodeHex(this.signKeys.publicKey);
2398
- const signed = XUtils.encodeHex(
2399
- await xSignAsync(
2400
- Uint8Array.from(uuid.parse(regKey.key)),
2401
- this.signKeys.secretKey,
2402
- ),
2403
- );
2404
- const preKeyIndex = this.xKeyRing.preKeys.index;
2405
- const regMsg: RegistrationPayload = {
2406
- deviceName: this.options?.deviceName ?? "unknown",
2407
- password: resolvedPassword,
2408
- preKey: XUtils.encodeHex(
2409
- this.xKeyRing.preKeys.keyPair.publicKey,
2410
- ),
2411
- preKeyIndex,
2412
- preKeySignature: XUtils.encodeHex(
2413
- this.xKeyRing.preKeys.signature,
2414
- ),
2415
- signed,
2416
- signKey,
2417
- username: resolvedUsername,
2418
- };
2419
- try {
2420
- const res = await this.http.post(
2421
- this.getHost() + "/register",
2422
- msgpack.encode(regMsg),
2423
- { headers: { "Content-Type": "application/msgpack" } },
2424
- );
2425
-
2426
- // New key-cluster server response: { device, token, user }.
2427
- // Legacy response (still deployed in some environments): user only.
2428
- let didDecodeRegisterResponse = false;
2429
- let pendingApproval: null | PendingDeviceRegistration = null;
2430
- try {
2431
- const { device, token, user } = decodeHttpResponse(
2432
- RegisterResponseCodec,
2433
- res.data,
2434
- );
2435
- this.device = device;
2436
- this.setUser(user);
2437
- this.token = token;
2438
- this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
2439
- didDecodeRegisterResponse = true;
2440
- } catch {
2441
- // fall through to legacy decode path
2442
- }
2443
-
2444
- if (!didDecodeRegisterResponse) {
2445
- try {
2446
- pendingApproval = decodeHttpResponse(
2447
- RegisterPendingApprovalCodec,
2448
- res.data,
2449
- );
2450
- } catch {
2451
- // fall through to legacy decode path
2452
- }
2453
- }
2454
-
2455
- if (!didDecodeRegisterResponse) {
2456
- if (pendingApproval !== null) {
2457
- return [
2458
- null,
2459
- new DeviceApprovalRequiredError({
2460
- challenge: pendingApproval.challenge,
2461
- expiresAt: pendingApproval.expiresAt,
2462
- requestID: pendingApproval.requestID,
2463
- userID: pendingApproval.userID ?? null,
2464
- }),
2465
- ];
2466
- }
2467
- const legacyUser = decodeHttpResponse(UserCodec, res.data);
2468
- this.setUser(legacyUser);
2469
-
2470
- // Legacy servers require /auth after /register to get a JWT.
2471
- const loginResult = await this.login(
2472
- resolvedUsername,
2473
- resolvedPassword,
2474
- );
2475
- if (!loginResult.ok) {
2476
- return [
2477
- null,
2478
- new Error(
2479
- loginResult.error ??
2480
- "Legacy register succeeded but login failed.",
2481
- ),
2482
- ];
2483
- }
2484
- }
2485
- return [this.getUser(), null];
2486
- } catch (err: unknown) {
2487
- if (isHttpError(err) && err.response) {
2488
- return [
2489
- null,
2490
- new Error(spireErrorBodyMessage(err.response.data)),
2491
- ];
2492
- }
2493
- return [
2494
- null,
2495
- err instanceof Error ? err : new Error(String(err)),
2496
- ];
2497
- }
2498
- } else {
2499
- return [null, new Error("Couldn't get regkey from server.")];
2500
- }
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
+ );
2501
2456
  }
2502
2457
 
2503
2458
  removeAllListeners(event?: keyof ClientEvents): this {
@@ -2505,6 +2460,36 @@ export class Client {
2505
2460
  return this;
2506
2461
  }
2507
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
+
2508
2493
  /**
2509
2494
  * Updates the local retention cap (1–30 days) and prunes immediately.
2510
2495
  * Does not affect server-side storage.
@@ -2725,6 +2710,21 @@ export class Client {
2725
2710
  };
2726
2711
  }
2727
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
+
2728
2728
  private async createChannel(
2729
2729
  name: string,
2730
2730
  serverID: string,
@@ -2836,13 +2836,16 @@ export class Client {
2836
2836
  return decodeHttpResponse(InviteCodec, res.data);
2837
2837
  }
2838
2838
 
2839
- private async createPreKey(): Promise<UnsavedPreKey> {
2839
+ private async createPreKey(
2840
+ kind: "one-time" | "signed",
2841
+ ): Promise<UnsavedPreKey> {
2840
2842
  return this.runWithThisCryptoProfile(async () => {
2841
2843
  const preKeyPair = await xBoxKeyPairAsync();
2842
- const toSign =
2843
- this.cryptoProfile === "fips"
2844
- ? fipsP256PreKeySignPayload(preKeyPair.publicKey)
2845
- : xEncode(xConstants.CURVE, preKeyPair.publicKey);
2844
+ const toSign = xPreKeySignaturePayload(
2845
+ preKeyPair.publicKey,
2846
+ kind,
2847
+ this.cryptoProfile,
2848
+ );
2846
2849
  return {
2847
2850
  keyPair: preKeyPair,
2848
2851
  signature: await xSignAsync(toSign, this.signKeys.secretKey),
@@ -2947,6 +2950,7 @@ export class Client {
2947
2950
 
2948
2951
  // shared secret key
2949
2952
  const SK = xKDF(IKM);
2953
+ const initialSubkeys = xMessageKeySubkeys(SK);
2950
2954
  const PK = (await xBoxKeyPairFromSecretAsync(SK)).publicKey;
2951
2955
 
2952
2956
  const AD = fips
@@ -2960,7 +2964,11 @@ export class Client {
2960
2964
  );
2961
2965
 
2962
2966
  const nonce = xMakeNonce();
2963
- const cipher = await xSecretboxAsync(message, nonce, SK);
2967
+ const cipher = await xSecretboxAsync(
2968
+ message,
2969
+ nonce,
2970
+ initialSubkeys.encryptionKey,
2971
+ );
2964
2972
 
2965
2973
  const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
2966
2974
  const ephKeyWire = ephemeralKeys.publicKey;
@@ -2989,7 +2997,7 @@ export class Client {
2989
2997
  sender: this.getDevice().deviceID,
2990
2998
  };
2991
2999
 
2992
- const hmac = xHMAC(mail, SK);
3000
+ const hmac = xHMAC(mail, initialSubkeys.authenticationKey);
2993
3001
 
2994
3002
  const msg: ResourceMsg = {
2995
3003
  action: "CREATE",
@@ -3178,6 +3186,7 @@ export class Client {
3178
3186
  return z.object({ calls: z.array(CallSessionSchema) }).parse(res.data)
3179
3187
  .calls;
3180
3188
  }
3189
+
3181
3190
  private async fetchIceServers(): Promise<IceServerConfig[]> {
3182
3191
  const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
3183
3192
  responseType: "json",
@@ -3246,7 +3255,6 @@ export class Client {
3246
3255
  return [null, isHttpError(err) ? err : null];
3247
3256
  }
3248
3257
  }
3249
-
3250
3258
  private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
3251
3259
  if (this.isManualCloseInFlight()) {
3252
3260
  return [];
@@ -3645,6 +3653,9 @@ export class Client {
3645
3653
  this.getDevice().deviceID +
3646
3654
  "/mail",
3647
3655
  );
3656
+ if (!this.token || this.isManualCloseInFlight()) {
3657
+ return;
3658
+ }
3648
3659
  const mailBuffer = new Uint8Array(res.data);
3649
3660
  const rawInbox = z
3650
3661
  .array(mailInboxEntry)
@@ -4043,10 +4054,11 @@ export class Client {
4043
4054
  preKey: PreKeysCrypto,
4044
4055
  ): Promise<boolean> {
4045
4056
  return this.runWithThisCryptoProfile(async () => {
4046
- const payload =
4047
- this.cryptoProfile === "fips"
4048
- ? fipsP256PreKeySignPayload(preKey.keyPair.publicKey)
4049
- : xEncode(xConstants.CURVE, preKey.keyPair.publicKey);
4057
+ const payload = xPreKeySignaturePayload(
4058
+ preKey.keyPair.publicKey,
4059
+ "signed",
4060
+ this.cryptoProfile,
4061
+ );
4050
4062
  const opened = await xSignOpenAsync(
4051
4063
  preKey.signature,
4052
4064
  this.signKeys.publicKey,
@@ -4274,7 +4286,7 @@ export class Client {
4274
4286
  !preKeys ||
4275
4287
  !(await this.isPreKeySignedByCurrentDevice(preKeys))
4276
4288
  ) {
4277
- const unsaved = await this.createPreKey();
4289
+ const unsaved = await this.createPreKey("signed");
4278
4290
  const [saved] = await this.database.savePreKeys(
4279
4291
  [unsaved],
4280
4292
  false,
@@ -4636,10 +4648,14 @@ export class Client {
4636
4648
 
4637
4649
  // shared secret key
4638
4650
  const SK = xKDF(IKM);
4651
+ const initialSubkeys = xMessageKeySubkeys(SK);
4639
4652
  const PK = (await xBoxKeyPairFromSecretAsync(SK))
4640
4653
  .publicKey;
4641
4654
 
4642
- const hmac = xHMAC(mail, SK);
4655
+ const hmac = xHMAC(
4656
+ mail,
4657
+ initialSubkeys.authenticationKey,
4658
+ );
4643
4659
 
4644
4660
  // associated data
4645
4661
  const AD = fipsRead
@@ -4689,7 +4705,7 @@ export class Client {
4689
4705
  const unsealed = await xSecretboxOpenAsync(
4690
4706
  new Uint8Array(mail.cipher),
4691
4707
  new Uint8Array(mail.nonce),
4692
- SK,
4708
+ initialSubkeys.encryptionKey,
4693
4709
  );
4694
4710
  if (unsealed) {
4695
4711
  let plaintext = "";
@@ -4906,12 +4922,16 @@ export class Client {
4906
4922
  );
4907
4923
  }
4908
4924
 
4909
- let messageKey = takeReceiveMessageKey(
4925
+ const messageKey = takeReceiveMessageKey(
4910
4926
  candidateSession,
4911
4927
  ratchetHeader.dhPub,
4912
4928
  ratchetHeader.n,
4913
4929
  );
4914
- let HMAC = xHMAC(mail, messageKey);
4930
+ let messageSubkeys = xMessageKeySubkeys(messageKey);
4931
+ let HMAC = xHMAC(
4932
+ mail,
4933
+ messageSubkeys.authenticationKey,
4934
+ );
4915
4935
 
4916
4936
  if (
4917
4937
  !XUtils.bytesEqual(HMAC, header) &&
@@ -4930,9 +4950,11 @@ export class Client {
4930
4950
  ratchetHeader.dhPub,
4931
4951
  ratchetHeader.n,
4932
4952
  );
4953
+ const ratchetedSubkeys =
4954
+ xMessageKeySubkeys(ratchetedMessageKey);
4933
4955
  const ratchetedHMAC = xHMAC(
4934
4956
  mail,
4935
- ratchetedMessageKey,
4957
+ ratchetedSubkeys.authenticationKey,
4936
4958
  );
4937
4959
  if (XUtils.bytesEqual(ratchetedHMAC, header)) {
4938
4960
  if (libvexDebugDmEnabled()) {
@@ -4946,7 +4968,7 @@ export class Client {
4946
4968
  );
4947
4969
  }
4948
4970
  candidateSession = ratchetedCandidate;
4949
- messageKey = ratchetedMessageKey;
4971
+ messageSubkeys = ratchetedSubkeys;
4950
4972
  HMAC = ratchetedHMAC;
4951
4973
  }
4952
4974
  }
@@ -4987,7 +5009,7 @@ export class Client {
4987
5009
  const decrypted = await xSecretboxOpenAsync(
4988
5010
  new Uint8Array(mail.cipher),
4989
5011
  new Uint8Array(mail.nonce),
4990
- messageKey,
5012
+ messageSubkeys.encryptionKey,
4991
5013
  );
4992
5014
 
4993
5015
  if (decrypted) {
@@ -5132,6 +5154,99 @@ export class Client {
5132
5154
  return decodeHttpResponse(PermissionCodec, res.data);
5133
5155
  }
5134
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
+
5135
5250
  private registerDecryptFailure(mail: MailWS): number {
5136
5251
  const count = (this.decryptFailureCounts.get(mail.mailID) ?? 0) + 1;
5137
5252
  this.decryptFailureCounts.set(mail.mailID, count);
@@ -5199,6 +5314,23 @@ export class Client {
5199
5314
  );
5200
5315
  }
5201
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
+
5202
5334
  private async respond(msg: ChallMsg) {
5203
5335
  const response: RespMsg = {
5204
5336
  signed: await xSignAsync(
@@ -5310,7 +5442,7 @@ export class Client {
5310
5442
  challenge: newDevice.challenge,
5311
5443
  expiresAt: newDevice.expiresAt,
5312
5444
  requestID: newDevice.requestID,
5313
- userID: newDevice.userID ?? null,
5445
+ userID: newDevice.userID,
5314
5446
  });
5315
5447
  } else {
5316
5448
  throw new Error("Error registering device.");
@@ -5705,6 +5837,7 @@ export class Client {
5705
5837
  await ratchetStepSend(session);
5706
5838
  }
5707
5839
  const { messageKey, n } = takeSendMessageKey(session);
5840
+ const messageSubkeys = xMessageKeySubkeys(messageKey);
5708
5841
  const ratchetHeader = {
5709
5842
  dhPub: session.DHsPublic,
5710
5843
  n,
@@ -5712,7 +5845,11 @@ export class Client {
5712
5845
  version: 1 as const,
5713
5846
  };
5714
5847
  const nonce = xMakeNonce();
5715
- const cipher = await xSecretboxAsync(msg, nonce, messageKey);
5848
+ const cipher = await xSecretboxAsync(
5849
+ msg,
5850
+ nonce,
5851
+ messageSubkeys.encryptionKey,
5852
+ );
5716
5853
  const extra = encodeRatchetHeader(ratchetHeader);
5717
5854
 
5718
5855
  const mail: MailWS = {
@@ -5737,7 +5874,7 @@ export class Client {
5737
5874
  type: "resource",
5738
5875
  };
5739
5876
 
5740
- const hmac = xHMAC(mail, messageKey);
5877
+ const hmac = xHMAC(mail, messageSubkeys.authenticationKey);
5741
5878
 
5742
5879
  const fwdOut = forward
5743
5880
  ? messageSchema.parse(msgpack.decode(msg))
@@ -6089,7 +6226,7 @@ export class Client {
6089
6226
  const otks: UnsavedPreKey[] = [];
6090
6227
 
6091
6228
  for (let i = 0; i < amount; i++) {
6092
- otks.push(await this.createPreKey());
6229
+ otks.push(await this.createPreKey("one-time"));
6093
6230
  }
6094
6231
 
6095
6232
  const savedKeys = await this.database.savePreKeys(otks, true);