@vex-chat/libvex 5.1.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CLA.md +38 -0
  2. package/LICENSE-COMMERCIAL +10 -0
  3. package/LICENSING.md +15 -0
  4. package/README.md +8 -2
  5. package/dist/Client.d.ts +47 -3
  6. package/dist/Client.d.ts.map +1 -1
  7. package/dist/Client.js +998 -496
  8. package/dist/Client.js.map +1 -1
  9. package/dist/Storage.d.ts +5 -0
  10. package/dist/Storage.d.ts.map +1 -1
  11. package/dist/Storage.js +5 -0
  12. package/dist/Storage.js.map +1 -1
  13. package/dist/__tests__/harness/memory-storage.d.ts +7 -2
  14. package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
  15. package/dist/__tests__/harness/memory-storage.js +44 -29
  16. package/dist/__tests__/harness/memory-storage.js.map +1 -1
  17. package/dist/codec.d.ts +9 -9
  18. package/dist/codec.d.ts.map +1 -1
  19. package/dist/codec.js +17 -19
  20. package/dist/codec.js.map +1 -1
  21. package/dist/codecs.d.ts +5 -0
  22. package/dist/codecs.d.ts.map +1 -1
  23. package/dist/codecs.js +5 -0
  24. package/dist/codecs.js.map +1 -1
  25. package/dist/index.d.ts +5 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +5 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/keystore/memory.d.ts +5 -0
  30. package/dist/keystore/memory.d.ts.map +1 -1
  31. package/dist/keystore/memory.js +5 -0
  32. package/dist/keystore/memory.js.map +1 -1
  33. package/dist/keystore/node.d.ts +5 -0
  34. package/dist/keystore/node.d.ts.map +1 -1
  35. package/dist/keystore/node.js +16 -8
  36. package/dist/keystore/node.js.map +1 -1
  37. package/dist/preset/common.d.ts +5 -0
  38. package/dist/preset/common.d.ts.map +1 -1
  39. package/dist/preset/common.js +5 -0
  40. package/dist/preset/common.js.map +1 -1
  41. package/dist/preset/node.d.ts +5 -0
  42. package/dist/preset/node.d.ts.map +1 -1
  43. package/dist/preset/node.js +9 -1
  44. package/dist/preset/node.js.map +1 -1
  45. package/dist/preset/test.d.ts +5 -0
  46. package/dist/preset/test.d.ts.map +1 -1
  47. package/dist/preset/test.js +9 -1
  48. package/dist/preset/test.js.map +1 -1
  49. package/dist/storage/node/http-agents.d.ts +5 -0
  50. package/dist/storage/node/http-agents.d.ts.map +1 -1
  51. package/dist/storage/node/http-agents.js +5 -0
  52. package/dist/storage/node/http-agents.js.map +1 -1
  53. package/dist/storage/node.d.ts +6 -1
  54. package/dist/storage/node.d.ts.map +1 -1
  55. package/dist/storage/node.js +7 -4
  56. package/dist/storage/node.js.map +1 -1
  57. package/dist/storage/schema.d.ts +5 -0
  58. package/dist/storage/schema.d.ts.map +1 -1
  59. package/dist/storage/schema.js +5 -0
  60. package/dist/storage/schema.js.map +1 -1
  61. package/dist/storage/sqlite.d.ts +22 -4
  62. package/dist/storage/sqlite.d.ts.map +1 -1
  63. package/dist/storage/sqlite.js +172 -98
  64. package/dist/storage/sqlite.js.map +1 -1
  65. package/dist/transport/types.d.ts +5 -0
  66. package/dist/transport/types.d.ts.map +1 -1
  67. package/dist/transport/types.js +5 -0
  68. package/dist/transport/types.js.map +1 -1
  69. package/dist/transport/websocket.d.ts +5 -0
  70. package/dist/transport/websocket.d.ts.map +1 -1
  71. package/dist/transport/websocket.js +5 -0
  72. package/dist/transport/websocket.js.map +1 -1
  73. package/dist/types/crypto.d.ts +5 -0
  74. package/dist/types/crypto.d.ts.map +1 -1
  75. package/dist/types/crypto.js +3 -5
  76. package/dist/types/crypto.js.map +1 -1
  77. package/dist/types/identity.d.ts +5 -0
  78. package/dist/types/identity.d.ts.map +1 -1
  79. package/dist/types/identity.js +3 -2
  80. package/dist/types/identity.js.map +1 -1
  81. package/dist/types/index.d.ts +5 -0
  82. package/dist/types/index.d.ts.map +1 -1
  83. package/dist/types/index.js +5 -0
  84. package/dist/types/index.js.map +1 -1
  85. package/dist/utils/capitalize.d.ts +5 -0
  86. package/dist/utils/capitalize.d.ts.map +1 -1
  87. package/dist/utils/capitalize.js +5 -0
  88. package/dist/utils/capitalize.js.map +1 -1
  89. package/dist/utils/fipsMailExtra.d.ts +30 -0
  90. package/dist/utils/fipsMailExtra.d.ts.map +1 -0
  91. package/dist/utils/fipsMailExtra.js +114 -0
  92. package/dist/utils/fipsMailExtra.js.map +1 -0
  93. package/dist/utils/formatBytes.d.ts +5 -0
  94. package/dist/utils/formatBytes.d.ts.map +1 -1
  95. package/dist/utils/formatBytes.js +5 -0
  96. package/dist/utils/formatBytes.js.map +1 -1
  97. package/dist/utils/resolveAtRestAesKey.d.ts +13 -0
  98. package/dist/utils/resolveAtRestAesKey.d.ts.map +1 -0
  99. package/dist/utils/resolveAtRestAesKey.js +26 -0
  100. package/dist/utils/resolveAtRestAesKey.js.map +1 -0
  101. package/dist/utils/sqlSessionToCrypto.d.ts +5 -0
  102. package/dist/utils/sqlSessionToCrypto.d.ts.map +1 -1
  103. package/dist/utils/sqlSessionToCrypto.js +5 -0
  104. package/dist/utils/sqlSessionToCrypto.js.map +1 -1
  105. package/dist/utils/uint8uuid.d.ts +5 -0
  106. package/dist/utils/uint8uuid.d.ts.map +1 -1
  107. package/dist/utils/uint8uuid.js +5 -0
  108. package/dist/utils/uint8uuid.js.map +1 -1
  109. package/package.json +10 -3
  110. package/src/Client.ts +1281 -642
  111. package/src/Storage.ts +6 -0
  112. package/src/__tests__/codec.test.ts +6 -0
  113. package/src/__tests__/harness/fixtures.ts +6 -0
  114. package/src/__tests__/harness/memory-storage.ts +72 -52
  115. package/src/__tests__/harness/platform-transports.ts +6 -0
  116. package/src/__tests__/harness/poison-node-imports.ts +6 -0
  117. package/src/__tests__/harness/shared-suite.ts +288 -124
  118. package/src/__tests__/platform-browser.test.ts +15 -1
  119. package/src/__tests__/platform-node.test.ts +17 -3
  120. package/src/codec.ts +21 -8
  121. package/src/codecs.ts +6 -0
  122. package/src/index.ts +6 -0
  123. package/src/keystore/memory.ts +6 -0
  124. package/src/keystore/node.ts +27 -13
  125. package/src/preset/common.ts +6 -0
  126. package/src/preset/node.ts +14 -1
  127. package/src/preset/test.ts +14 -1
  128. package/src/storage/node/http-agents.ts +6 -0
  129. package/src/storage/node.ts +11 -4
  130. package/src/storage/schema.ts +6 -0
  131. package/src/storage/sqlite.ts +208 -135
  132. package/src/transport/types.ts +6 -0
  133. package/src/transport/websocket.ts +6 -0
  134. package/src/types/crypto.ts +6 -0
  135. package/src/types/identity.ts +6 -0
  136. package/src/types/index.ts +6 -0
  137. package/src/utils/capitalize.ts +6 -0
  138. package/src/utils/fipsMailExtra.ts +164 -0
  139. package/src/utils/formatBytes.ts +6 -0
  140. package/src/utils/resolveAtRestAesKey.ts +39 -0
  141. package/src/utils/sqlSessionToCrypto.ts +6 -0
  142. package/src/utils/uint8uuid.ts +6 -0
package/dist/Client.js CHANGED
@@ -1,13 +1,111 @@
1
- import { xBoxKeyPair, xBoxKeyPairFromSecret, xConcat, xConstants, xDH, xEncode, xHMAC, xKDF, XKeyConvert, xMakeNonce, xMnemonic, xRandomBytes, xSecretbox, xSecretboxOpen, xSign, xSignKeyPair, xSignKeyPairFromSecret, XUtils, } from "@vex-chat/crypto";
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+ import { getCryptoProfile, setCryptoProfile, xBoxKeyPairAsync, xBoxKeyPairFromSecretAsync, xConcat, xConstants, xDHAsync, xEcdhKeyPairFromEcdsaKeyPairAsync, xEncode, xHMAC, xKDF, XKeyConvert, xMakeNonce, xMnemonic, xRandomBytes, xSecretboxAsync, xSecretboxOpenAsync, xSignAsync, xSignKeyPair, xSignKeyPairAsync, xSignKeyPairFromSecret, xSignKeyPairFromSecretAsync, XUtils, } from "@vex-chat/crypto";
2
7
  import { MailType, MailWSSchema, PermissionSchema, WSMessageSchema, } from "@vex-chat/types";
3
8
  import axios, { isAxiosError } from "axios";
4
9
  import { EventEmitter } from "eventemitter3";
5
10
  import * as uuid from "uuid";
6
11
  import { z } from "zod/v4";
7
12
  import { WebSocketAdapter } from "./transport/websocket.js";
13
+ import { decodeFipsInitialExtraV1, decodeFipsSubsequentExtraV1, encodeFipsInitialExtraV1, encodeFipsSubsequentExtraV1, fipsP256AdFromIdentityPubs, fipsP256PreKeySignPayload, isFipsInitialExtraV1, isFipsSubsequentExtraV1, } from "./utils/fipsMailExtra.js";
8
14
  function sleep(ms) {
9
15
  return new Promise((resolve) => setTimeout(resolve, ms));
10
16
  }
17
+ function isRecord(x) {
18
+ return typeof x === "object" && x !== null;
19
+ }
20
+ /**
21
+ * Spire 5+ JSON error bodies use `{ "error": { "message", "requestId"?, "details"? } }`.
22
+ * Responses are `arraybuffer` — decode UTF-8 and parse for a one-line `Error` message
23
+ * (plus requestId) instead of a raw JSON blob.
24
+ */
25
+ function spireErrorBodyMessage(data, max = 8_000) {
26
+ let text;
27
+ if (data instanceof ArrayBuffer) {
28
+ text = new TextDecoder("utf-8", { fatal: false }).decode(new Uint8Array(data));
29
+ }
30
+ else if (data instanceof Uint8Array) {
31
+ text = new TextDecoder("utf-8", { fatal: false }).decode(data);
32
+ }
33
+ else {
34
+ return String(data).slice(0, max);
35
+ }
36
+ const t = text.trim();
37
+ if (t.startsWith("{")) {
38
+ try {
39
+ // JSON.parse is typed as any; assign into unknown for safe narrowing.
40
+ const parsed = JSON.parse(t);
41
+ if (!isRecord(parsed)) {
42
+ return t.length > max ? t.slice(0, max) + "…" : t;
43
+ }
44
+ const errField = parsed["error"];
45
+ if (!isRecord(errField)) {
46
+ return t.length > max ? t.slice(0, max) + "…" : t;
47
+ }
48
+ const message = errField["message"];
49
+ if (typeof message !== "string") {
50
+ return t.length > max ? t.slice(0, max) + "…" : t;
51
+ }
52
+ const parts = [message];
53
+ const requestId = errField["requestId"];
54
+ if (typeof requestId === "string" && requestId.length > 0) {
55
+ parts.push(`(requestId: ${requestId})`);
56
+ }
57
+ if (errField["details"] !== undefined) {
58
+ let d = JSON.stringify(errField["details"]);
59
+ if (d.length > 500) {
60
+ d = d.slice(0, 500) + "…";
61
+ }
62
+ parts.push(d);
63
+ }
64
+ return parts.join(" ");
65
+ }
66
+ catch {
67
+ /* fall through to raw */
68
+ }
69
+ }
70
+ return t.length > max ? t.slice(0, max) + "…" : t;
71
+ }
72
+ /**
73
+ * Set `LIBVEX_DEBUG_DM=1` (e.g. in vitest / shell) to log DM multi-device / X3DH paths.
74
+ * Uses indirect `globalThis` lookup so the bare `process` global never appears in
75
+ * source that the platform-guard plugin scans (browser/RN/Tauri).
76
+ */
77
+ function libvexDebugDmEnabled() {
78
+ try {
79
+ const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
80
+ if (!g) {
81
+ return false;
82
+ }
83
+ const proc = typeof g.get === "function" ? g.get() : g.value;
84
+ if (typeof proc !== "object" || proc === null) {
85
+ return false;
86
+ }
87
+ const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
88
+ if (!envDesc) {
89
+ return false;
90
+ }
91
+ const env = typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
92
+ if (typeof env !== "object" || env === null) {
93
+ return false;
94
+ }
95
+ return Reflect.get(env, "LIBVEX_DEBUG_DM") === "1";
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }
101
+ function debugLibvexDm(msg, data) {
102
+ if (!libvexDebugDmEnabled()) {
103
+ return;
104
+ }
105
+ const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
106
+ // eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
107
+ console.error(`[libvex:debug-dm] ${payload}`);
108
+ }
11
109
  import { msgpack } from "./codec.js";
12
110
  import { ActionTokenCodec, AuthResponseCodec, ChannelArrayCodec, ChannelCodec, ConnectResponseCodec, decodeAxios, DeviceArrayCodec, DeviceChallengeCodec, DeviceCodec, EmojiArrayCodec, EmojiCodec, FileSQLCodec, InviteArrayCodec, InviteCodec, KeyBundleCodec, OtkCountCodec, PermissionArrayCodec, PermissionCodec, ServerArrayCodec, ServerCodec, UserArrayCodec, UserCodec, WhoamiCodec, } from "./codecs.js";
13
111
  import { capitalize } from "./utils/capitalize.js";
@@ -42,12 +140,14 @@ export class Client {
42
140
  * Pass-through utility from `@vex-chat/crypto`.
43
141
  */
44
142
  static decryptKeyData = XUtils.decryptKeyData;
143
+ static decryptKeyDataAsync = XUtils.decryptKeyDataAsync;
45
144
  /**
46
145
  * Encrypts a secret key with a password.
47
146
  *
48
147
  * Pass-through utility from `@vex-chat/crypto`.
49
148
  */
50
149
  static encryptKeyData = XUtils.encryptKeyData;
150
+ static encryptKeyDataAsync = XUtils.encryptKeyDataAsync;
51
151
  static NOT_FOUND_TTL = 30 * 60 * 1000;
52
152
  /**
53
153
  * Browser-safe NODE_ENV accessor.
@@ -312,11 +412,18 @@ export class Client {
312
412
  * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
313
413
  */
314
414
  nodeHttpAgents;
415
+ /** Cancels in-flight axios work on `close()` so `postAuth`/`getMail` cannot hang forever. */
416
+ httpAbortController = new AbortController();
315
417
  http;
316
418
  idKeys;
317
419
  isAlive = true;
318
420
  mailInterval;
319
421
  manuallyClosing = false;
422
+ /**
423
+ * Bumped when the WebSocket is torn down and re-opened so the previous
424
+ * `postAuth` loop exits instead of overlapping a new one.
425
+ */
426
+ postAuthVersion = 0;
320
427
  /* Retrieves the userID with the user identifier.
321
428
  user identifier is checked for userID, then signkey,
322
429
  and finally falls back to username. */
@@ -335,9 +442,12 @@ export class Client {
335
442
  user;
336
443
  userRecords = {};
337
444
  xKeyRing;
338
- constructor(privateKey, options, storage) {
339
- // (no super — composition, not inheritance)
445
+ cryptoProfile;
446
+ constructor(material, options, storage) {
340
447
  this.options = options;
448
+ this.cryptoProfile = material.cryptoProfile;
449
+ this.signKeys = material.signKeys;
450
+ this.idKeys = material.idKeys;
341
451
  if (options?.unsafeHttp) {
342
452
  const env = Client.getNodeEnv();
343
453
  if (env !== "development" && env !== "test") {
@@ -349,13 +459,6 @@ export class Client {
349
459
  else {
350
460
  this.prefixes = { HTTP: "https://", WS: "wss://" };
351
461
  }
352
- this.signKeys = privateKey
353
- ? xSignKeyPairFromSecret(XUtils.decodeHex(privateKey))
354
- : xSignKeyPair();
355
- this.idKeys = XKeyConvert.convertKeyPair(this.signKeys);
356
- if (!this.idKeys) {
357
- throw new Error("Could not convert key to X25519!");
358
- }
359
462
  this.host = options?.host || "api.vex.wtf";
360
463
  const dbFileName = options?.inMemoryDb
361
464
  ? ":memory:"
@@ -370,7 +473,10 @@ export class Client {
370
473
  this.database.on("error", (_error) => {
371
474
  void this.close(true);
372
475
  });
373
- this.http = axios.create({ responseType: "arraybuffer" });
476
+ this.http = axios.create({
477
+ responseType: "arraybuffer",
478
+ signal: this.httpAbortController.signal,
479
+ });
374
480
  const devKey = options?.devApiKey?.trim();
375
481
  if (devKey !== undefined && devKey.length > 0) {
376
482
  this.http.defaults.headers.common["x-dev-api-key"] = devKey;
@@ -391,29 +497,73 @@ export class Client {
391
497
  * ```
392
498
  */
393
499
  static create = async (privateKey, options, storage) => {
394
- const opts = options;
395
- const sk = privateKey ?? XUtils.encodeHex(xSignKeyPair().secretKey);
500
+ const profile = options?.cryptoProfile ?? "tweetnacl";
501
+ setCryptoProfile(profile);
502
+ if (profile === "fips" &&
503
+ typeof globalThis.crypto.subtle !== "object") {
504
+ throw new Error('cryptoProfile="fips" requires Web Crypto (globalThis.crypto.subtle).');
505
+ }
506
+ let signKeys;
507
+ if (privateKey) {
508
+ const d = XUtils.decodeHex(privateKey);
509
+ signKeys =
510
+ profile === "tweetnacl"
511
+ ? xSignKeyPairFromSecret(d)
512
+ : await xSignKeyPairFromSecretAsync(d);
513
+ }
514
+ else {
515
+ signKeys =
516
+ profile === "tweetnacl"
517
+ ? xSignKeyPair()
518
+ : await xSignKeyPairAsync();
519
+ }
520
+ const idKeys = profile === "tweetnacl"
521
+ ? (() => {
522
+ const c = XKeyConvert.convertKeyPair(signKeys);
523
+ if (!c) {
524
+ throw new Error("Could not convert key to X25519!");
525
+ }
526
+ return c;
527
+ })()
528
+ : await xEcdhKeyPairFromEcdsaKeyPairAsync(signKeys);
529
+ const atRestAes = XUtils.deriveLocalAtRestAesKey(idKeys.secretKey, profile);
396
530
  let resolvedStorage = storage;
397
531
  if (!resolvedStorage) {
398
532
  const { createNodeStorage } = await import("./storage/node.js");
399
- const dbFileName = opts?.inMemoryDb
533
+ const dbFileName = options?.inMemoryDb
400
534
  ? ":memory:"
401
- : XUtils.encodeHex(xSignKeyPairFromSecret(XUtils.decodeHex(sk)).publicKey) + ".sqlite";
402
- const dbPath = opts?.dbFolder
403
- ? opts.dbFolder + "/" + dbFileName
535
+ : XUtils.encodeHex(signKeys.publicKey) + ".sqlite";
536
+ const dbPath = options?.dbFolder
537
+ ? options.dbFolder + "/" + dbFileName
404
538
  : dbFileName;
405
- resolvedStorage = createNodeStorage(dbPath, sk);
406
- }
407
- const client = new Client(sk, opts, resolvedStorage);
539
+ resolvedStorage = createNodeStorage(dbPath, atRestAes);
540
+ }
541
+ await resolvedStorage.init();
542
+ const client = new Client({
543
+ cryptoProfile: profile,
544
+ idKeys,
545
+ signKeys,
546
+ }, options, resolvedStorage);
408
547
  await client.init();
409
548
  return client;
410
549
  };
411
550
  /**
412
- * Generates an ed25519 secret key as a hex string.
413
- *
414
- * @returns A secret key to use for the client. Save it permanently somewhere safe.
551
+ * Generates a signing secret key as a hex string (tweetnacl: Ed25519; fips: P-256 pkcs8).
552
+ * In `fips` mode, use `Client.generateSecretKeyAsync()` instead (Web Crypto is async).
415
553
  */
416
554
  static generateSecretKey() {
555
+ if (getCryptoProfile() === "fips") {
556
+ throw new Error('Use await Client.generateSecretKeyAsync() when the active crypto profile is "fips".');
557
+ }
558
+ return XUtils.encodeHex(xSignKeyPair().secretKey);
559
+ }
560
+ /**
561
+ * Async key generation — required for `fips` profile; safe for `tweetnacl` as well.
562
+ */
563
+ static async generateSecretKeyAsync() {
564
+ if (getCryptoProfile() === "fips") {
565
+ return XUtils.encodeHex((await xSignKeyPairAsync()).secretKey);
566
+ }
417
567
  return XUtils.encodeHex(xSignKeyPair().secretKey);
418
568
  }
419
569
  /**
@@ -431,17 +581,23 @@ export class Client {
431
581
  }
432
582
  static deserializeExtra(type, extra) {
433
583
  switch (type) {
434
- case MailType.initial:
435
- /* 32 bytes for signkey, 32 bytes for ephemeral key,
436
- 68 bytes for AD, 6 bytes for otk index (empty for no otk) */
584
+ case MailType.initial: {
585
+ if (isFipsInitialExtraV1(extra)) {
586
+ const [a, b, c, d] = decodeFipsInitialExtraV1(extra);
587
+ return [a, b, c, d];
588
+ }
589
+ /* 32B sign | 32B eph | 32B PK | 68B AD | 6B index (tweetnacl) */
437
590
  const signKey = extra.slice(0, 32);
438
591
  const ephKey = extra.slice(32, 64);
439
592
  const ad = extra.slice(96, 164);
440
593
  const index = extra.slice(164, 170);
441
594
  return [signKey, ephKey, ad, index];
595
+ }
442
596
  case MailType.subsequent:
443
- const publicKey = extra;
444
- return [publicKey];
597
+ if (isFipsSubsequentExtraV1(extra)) {
598
+ return [decodeFipsSubsequentExtraV1(extra)];
599
+ }
600
+ return [extra];
445
601
  default:
446
602
  return [];
447
603
  }
@@ -522,6 +678,7 @@ export class Client {
522
678
  */
523
679
  async close(muteEvent = false) {
524
680
  this.manuallyClosing = true;
681
+ this.httpAbortController.abort();
525
682
  this.socket.close();
526
683
  await this.database.close();
527
684
  if (this.nodeHttpAgents) {
@@ -556,8 +713,8 @@ export class Client {
556
713
  if (!connectToken) {
557
714
  throw new Error("Couldn't get connect token.");
558
715
  }
559
- const signed = xSign(Uint8Array.from(uuid.parse(connectToken.key)), this.signKeys.secretKey);
560
- const res = await this.http.post(this.getHost() + "/device/" + this.device.deviceID + "/connect", msgpack.encode({ signed }), { headers: { "Content-Type": "application/msgpack" } });
716
+ const signedAsync = await xSignAsync(Uint8Array.from(uuid.parse(connectToken.key)), this.signKeys.secretKey);
717
+ const res = await this.http.post(this.getHost() + "/device/" + this.device.deviceID + "/connect", msgpack.encode({ signed: signedAsync }), { headers: { "Content-Type": "application/msgpack" } });
561
718
  const { deviceToken } = decodeAxios(ConnectResponseCodec, res.data);
562
719
  this.http.defaults.headers.common["X-Device-Token"] = deviceToken;
563
720
  this.initSocket();
@@ -566,6 +723,50 @@ export class Client {
566
723
  await new Promise((r) => setTimeout(r, 0));
567
724
  await this.negotiateOTK();
568
725
  }
726
+ /**
727
+ * Tears down the current WebSocket and opens a new one, keeping the same
728
+ * session (user + device in storage). Restarts the post-auth mail loop.
729
+ * Use for long-running processes or e2e where a fresh socket matches a
730
+ * newly-registered second device.
731
+ */
732
+ async reconnectWebsocket() {
733
+ this.postAuthVersion++;
734
+ if (this.pingInterval) {
735
+ clearInterval(this.pingInterval);
736
+ this.pingInterval = null;
737
+ }
738
+ this.socket.close();
739
+ try {
740
+ await new Promise((resolve, reject) => {
741
+ const t = setTimeout(() => {
742
+ this.off("connected", onC);
743
+ reject(new Error("reconnectWebsocket: timed out waiting for authorized"));
744
+ }, 15_000);
745
+ const onC = () => {
746
+ clearTimeout(t);
747
+ this.off("connected", onC);
748
+ resolve();
749
+ };
750
+ this.on("connected", onC);
751
+ try {
752
+ this.initSocket();
753
+ }
754
+ catch (err) {
755
+ clearTimeout(t);
756
+ this.off("connected", onC);
757
+ const e = err instanceof Error
758
+ ? err
759
+ : new Error(String(err), { cause: err });
760
+ reject(e);
761
+ }
762
+ });
763
+ }
764
+ catch (e) {
765
+ throw e instanceof Error ? e : new Error(String(e), { cause: e });
766
+ }
767
+ await new Promise((r) => setTimeout(r, 0));
768
+ await this.negotiateOTK();
769
+ }
569
770
  /**
570
771
  * Delete all local data — message history, encryption sessions, and prekeys.
571
772
  * Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
@@ -623,6 +824,12 @@ export class Client {
623
824
  return { ok: true };
624
825
  }
625
826
  catch (err) {
827
+ if (isAxiosError(err) && err.response) {
828
+ return {
829
+ error: spireErrorBodyMessage(err.response.data),
830
+ ok: false,
831
+ };
832
+ }
626
833
  const error = err instanceof Error ? err.message : String(err);
627
834
  return { error, ok: false };
628
835
  }
@@ -647,7 +854,7 @@ export class Client {
647
854
  signKey: signKeyHex,
648
855
  }), { headers: { "Content-Type": "application/msgpack" } });
649
856
  const { challenge, challengeID } = decodeAxios(DeviceChallengeCodec, challengeRes.data);
650
- const signed = XUtils.encodeHex(xSign(XUtils.decodeHex(challenge), this.signKeys.secretKey));
857
+ const signed = XUtils.encodeHex(await xSignAsync(XUtils.decodeHex(challenge), this.signKeys.secretKey));
651
858
  const verifyRes = await this.http.post(this.getHost() + "/auth/device/verify", msgpack.encode({ challengeID, signed }), { headers: { "Content-Type": "application/msgpack" } });
652
859
  const { token, user } = decodeAxios(AuthResponseCodec, verifyRes.data);
653
860
  this.setUser(user);
@@ -704,7 +911,7 @@ export class Client {
704
911
  const regKey = await this.getToken("register");
705
912
  if (regKey) {
706
913
  const signKey = XUtils.encodeHex(this.signKeys.publicKey);
707
- const signed = XUtils.encodeHex(xSign(Uint8Array.from(uuid.parse(regKey.key)), this.signKeys.secretKey));
914
+ const signed = XUtils.encodeHex(await xSignAsync(Uint8Array.from(uuid.parse(regKey.key)), this.signKeys.secretKey));
708
915
  const preKeyIndex = this.xKeyRing.preKeys.index;
709
916
  const regMsg = {
710
917
  deviceName: this.options?.deviceName ?? "unknown",
@@ -723,11 +930,10 @@ export class Client {
723
930
  }
724
931
  catch (err) {
725
932
  if (isAxiosError(err) && err.response) {
726
- const raw = err.response.data;
727
- const msg = raw instanceof ArrayBuffer || raw instanceof Uint8Array
728
- ? new TextDecoder().decode(raw)
729
- : String(raw);
730
- return [null, new Error(msg)];
933
+ return [
934
+ null,
935
+ new Error(spireErrorBodyMessage(err.response.data)),
936
+ ];
731
937
  }
732
938
  return [
733
939
  null,
@@ -786,41 +992,45 @@ export class Client {
786
992
  }
787
993
  // returns the file details and the encryption key
788
994
  async createFile(file) {
789
- const nonce = xMakeNonce();
790
- const key = xBoxKeyPair();
791
- const box = xSecretbox(Uint8Array.from(file), nonce, key.secretKey);
792
- if (typeof FormData !== "undefined") {
793
- const fpayload = new FormData();
794
- fpayload.set("owner", this.getDevice().deviceID);
795
- fpayload.set("nonce", XUtils.encodeHex(nonce));
796
- fpayload.set("file", new Blob([new Uint8Array(box)]));
797
- const fres = await this.http.post(this.getHost() + "/file", fpayload, {
798
- headers: { "Content-Type": "multipart/form-data" },
799
- onUploadProgress: (progressEvent) => {
800
- const percentCompleted = Math.round((progressEvent.loaded * 100) /
801
- (progressEvent.total ?? 1));
802
- const { loaded, total = 0 } = progressEvent;
803
- const progress = {
804
- direction: "upload",
805
- loaded,
806
- progress: percentCompleted,
807
- token: XUtils.encodeHex(nonce),
808
- total,
809
- };
810
- this.emitter.emit("fileProgress", progress);
811
- },
812
- });
813
- const fcreatedFile = decodeAxios(FileSQLCodec, fres.data);
814
- return [fcreatedFile, XUtils.encodeHex(key.secretKey)];
815
- }
816
- const payload = {
817
- file: XUtils.encodeBase64(box),
818
- nonce: XUtils.encodeHex(nonce),
819
- owner: this.getDevice().deviceID,
820
- };
821
- const res = await this.http.post(this.getHost() + "/file/json", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
822
- const createdFile = decodeAxios(FileSQLCodec, res.data);
823
- return [createdFile, XUtils.encodeHex(key.secretKey)];
995
+ return this.runWithThisCryptoProfile(async () => {
996
+ const nonce = xMakeNonce();
997
+ const fileKey = this.cryptoProfile === "fips"
998
+ ? xRandomBytes(32)
999
+ : (await xBoxKeyPairAsync()).secretKey;
1000
+ const box = await xSecretboxAsync(Uint8Array.from(file), nonce, fileKey);
1001
+ if (typeof FormData !== "undefined") {
1002
+ const fpayload = new FormData();
1003
+ fpayload.set("owner", this.getDevice().deviceID);
1004
+ fpayload.set("nonce", XUtils.encodeHex(nonce));
1005
+ fpayload.set("file", new Blob([new Uint8Array(box)]));
1006
+ const fres = await this.http.post(this.getHost() + "/file", fpayload, {
1007
+ headers: { "Content-Type": "multipart/form-data" },
1008
+ onUploadProgress: (progressEvent) => {
1009
+ const percentCompleted = Math.round((progressEvent.loaded * 100) /
1010
+ (progressEvent.total ?? 1));
1011
+ const { loaded, total = 0 } = progressEvent;
1012
+ const progress = {
1013
+ direction: "upload",
1014
+ loaded,
1015
+ progress: percentCompleted,
1016
+ token: XUtils.encodeHex(nonce),
1017
+ total,
1018
+ };
1019
+ this.emitter.emit("fileProgress", progress);
1020
+ },
1021
+ });
1022
+ const fcreatedFile = decodeAxios(FileSQLCodec, fres.data);
1023
+ return [fcreatedFile, XUtils.encodeHex(fileKey)];
1024
+ }
1025
+ const payload = {
1026
+ file: XUtils.encodeBase64(box),
1027
+ nonce: XUtils.encodeHex(nonce),
1028
+ owner: this.getDevice().deviceID,
1029
+ };
1030
+ const res = await this.http.post(this.getHost() + "/file/json", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
1031
+ const createdFile = decodeAxios(FileSQLCodec, res.data);
1032
+ return [createdFile, XUtils.encodeHex(fileKey)];
1033
+ });
824
1034
  }
825
1035
  async createInvite(serverID, duration) {
826
1036
  const payload = {
@@ -830,145 +1040,190 @@ export class Client {
830
1040
  const res = await this.http.post(this.getHost() + "/server/" + serverID + "/invites", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
831
1041
  return decodeAxios(InviteCodec, res.data);
832
1042
  }
833
- createPreKey() {
834
- const preKeyPair = xBoxKeyPair();
1043
+ async createPreKey() {
1044
+ const preKeyPair = await xBoxKeyPairAsync();
1045
+ const toSign = this.cryptoProfile === "fips"
1046
+ ? fipsP256PreKeySignPayload(preKeyPair.publicKey)
1047
+ : xEncode(xConstants.CURVE, preKeyPair.publicKey);
835
1048
  return {
836
1049
  keyPair: preKeyPair,
837
- signature: xSign(xEncode(xConstants.CURVE, preKeyPair.publicKey), this.signKeys.secretKey),
1050
+ signature: await xSignAsync(toSign, this.signKeys.secretKey),
838
1051
  };
839
1052
  }
840
1053
  async createServer(name) {
841
1054
  const res = await this.http.post(this.getHost() + "/server/" + globalThis.btoa(name));
842
1055
  return decodeAxios(ServerCodec, res.data);
843
1056
  }
844
- async createSession(device, user, message, group,
845
- /* this is passed through if the first message is
846
- part of a group message */
847
- mailID, forward) {
848
- let keyBundle;
1057
+ /**
1058
+ * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
1059
+ * active profile. When several {@link Client} instances use different
1060
+ * `cryptoProfile` values, scope the global to this instance for the duration
1061
+ * of that crypto work.
1062
+ */
1063
+ async runWithThisCryptoProfile(fn) {
1064
+ const prev = getCryptoProfile();
1065
+ if (prev === this.cryptoProfile) {
1066
+ return await fn();
1067
+ }
1068
+ setCryptoProfile(this.cryptoProfile);
849
1069
  try {
850
- keyBundle = await this.retrieveKeyBundle(device.deviceID);
1070
+ return await fn();
851
1071
  }
852
- catch {
853
- return;
1072
+ finally {
1073
+ setCryptoProfile(prev);
854
1074
  }
855
- if (!this.xKeyRing) {
856
- if (this.manuallyClosing) {
857
- return;
1075
+ }
1076
+ async createSession(device, user, message, group,
1077
+ /* this is passed through if the first message is
1078
+ part of a group message */
1079
+ mailID, forward,
1080
+ /**
1081
+ * When `readMail` triggers a best-effort session re-establish, key-bundle
1082
+ * errors should not reject the full read pipeline.
1083
+ */
1084
+ allowKeyBundleFailure = false) {
1085
+ return this.runWithThisCryptoProfile(async () => {
1086
+ let keyBundle;
1087
+ try {
1088
+ keyBundle = await this.retrieveKeyBundle(device.deviceID);
858
1089
  }
859
- throw new Error("Key ring not initialized.");
860
- }
861
- // my keys
862
- const IK_A = this.xKeyRing.identityKeys.secretKey;
863
- const IK_AP = this.xKeyRing.identityKeys.publicKey;
864
- const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
865
- // their keys
866
- const IK_B_raw = XKeyConvert.convertPublicKey(new Uint8Array(keyBundle.signKey));
867
- if (!IK_B_raw) {
868
- throw new Error("Could not convert sign key to X25519.");
869
- }
870
- const IK_B = IK_B_raw;
871
- const SPK_B = new Uint8Array(keyBundle.preKey.publicKey);
872
- const OPK_B = keyBundle.otk
873
- ? new Uint8Array(keyBundle.otk.publicKey)
874
- : null;
875
- // diffie hellman functions
876
- const DH1 = xDH(new Uint8Array(IK_A), SPK_B);
877
- const DH2 = xDH(new Uint8Array(EK_A), IK_B);
878
- const DH3 = xDH(new Uint8Array(EK_A), SPK_B);
879
- const DH4 = OPK_B ? xDH(new Uint8Array(EK_A), OPK_B) : null;
880
- // initial key material
881
- const IKM = DH4 ? xConcat(DH1, DH2, DH3, DH4) : xConcat(DH1, DH2, DH3);
882
- // one time key index
883
- const IDX = keyBundle.otk
884
- ? XUtils.numberToUint8Arr(keyBundle.otk.index ?? 0)
885
- : XUtils.numberToUint8Arr(0);
886
- // shared secret key
887
- const SK = xKDF(IKM);
888
- const PK = xBoxKeyPairFromSecret(SK).publicKey;
889
- const AD = xConcat(xEncode(xConstants.CURVE, IK_AP), xEncode(xConstants.CURVE, IK_B));
890
- const nonce = xMakeNonce();
891
- const cipher = xSecretbox(message, nonce, SK);
892
- /* 32 bytes for signkey, 32 bytes for ephemeral key,
893
- 68 bytes for AD, 6 bytes for otk index (empty for no otk) */
894
- const extra = xConcat(this.signKeys.publicKey, this.xKeyRing.ephemeralKeys.publicKey, PK, AD, IDX);
895
- const mail = {
896
- authorID: this.getUser().userID,
897
- cipher,
898
- extra,
899
- forward,
900
- group,
901
- mailID: mailID || uuid.v4(),
902
- mailType: MailType.initial,
903
- nonce,
904
- readerID: user.userID,
905
- recipient: device.deviceID,
906
- sender: this.getDevice().deviceID,
907
- };
908
- const hmac = xHMAC(mail, SK);
909
- const msg = {
910
- action: "CREATE",
911
- data: mail,
912
- resourceType: "mail",
913
- transmissionID: uuid.v4(),
914
- type: "resource",
915
- };
916
- // discard the ephemeral keys
917
- this.newEphemeralKeys();
918
- const sessionEntry = {
919
- deviceID: device.deviceID,
920
- fingerprint: XUtils.encodeHex(AD),
921
- lastUsed: new Date().toISOString(),
922
- mode: "initiator",
923
- publicKey: XUtils.encodeHex(PK),
924
- sessionID: uuid.v4(),
925
- SK: XUtils.encodeHex(SK),
926
- userID: user.userID,
927
- verified: false,
928
- };
929
- await this.database.saveSession(sessionEntry);
930
- this.emitter.emit("session", sessionEntry, user);
931
- // emit the message
932
- const forwardedMsg = forward
933
- ? messageSchema.parse(msgpack.decode(message))
934
- : null;
935
- const emitMsg = forwardedMsg
936
- ? { ...forwardedMsg, forward: true }
937
- : {
938
- authorID: mail.authorID,
939
- decrypted: true,
940
- direction: "outgoing",
941
- forward: mail.forward,
942
- group: mail.group ? uuid.stringify(mail.group) : null,
943
- mailID: mail.mailID,
944
- message: XUtils.encodeUTF8(message),
945
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
946
- readerID: mail.readerID,
947
- recipient: mail.recipient,
948
- sender: mail.sender,
949
- timestamp: new Date().toISOString(),
950
- };
951
- this.emitter.emit("message", emitMsg);
952
- // send mail and wait for response
953
- await new Promise((res, rej) => {
954
- const callback = (packedMsg) => {
955
- const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
956
- if (receivedMsg.transmissionID === msg.transmissionID) {
957
- this.socket.off("message", callback);
958
- const parsed = WSMessageSchema.safeParse(receivedMsg);
959
- if (parsed.success && parsed.data.type === "success") {
960
- res(parsed.data.data);
961
- }
962
- else {
963
- rej(new Error("Mail delivery failed: " +
964
- JSON.stringify(receivedMsg)));
965
- }
1090
+ catch (e) {
1091
+ if (allowKeyBundleFailure) {
1092
+ return;
1093
+ }
1094
+ const wrap = e instanceof Error ? e : new Error(String(e), { cause: e });
1095
+ throw new Error(`Failed to load keyBundle for device ${device.deviceID}: ${wrap.message}`, { cause: e });
1096
+ }
1097
+ if (!this.xKeyRing) {
1098
+ if (this.manuallyClosing) {
1099
+ return;
966
1100
  }
1101
+ throw new Error("Key ring not initialized.");
1102
+ }
1103
+ // my keys
1104
+ const IK_A = this.xKeyRing.identityKeys.secretKey;
1105
+ const IK_AP = this.xKeyRing.identityKeys.publicKey;
1106
+ const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
1107
+ const fips = this.cryptoProfile === "fips";
1108
+ // their keys — FIPS: `signKey` in bundle is the peer P-256 ECDH identity (raw, typically 65B).
1109
+ const SPK_B = new Uint8Array(keyBundle.preKey.publicKey);
1110
+ const OPK_B = keyBundle.otk
1111
+ ? new Uint8Array(keyBundle.otk.publicKey)
1112
+ : null;
1113
+ const IK_B = fips
1114
+ ? new Uint8Array(keyBundle.signKey)
1115
+ : (() => {
1116
+ const c = XKeyConvert.convertPublicKey(new Uint8Array(keyBundle.signKey));
1117
+ if (!c) {
1118
+ throw new Error("Could not convert sign key to X25519.");
1119
+ }
1120
+ return c;
1121
+ })();
1122
+ // diffie hellman functions
1123
+ const DH1 = await xDHAsync(new Uint8Array(IK_A), SPK_B);
1124
+ const DH2 = await xDHAsync(new Uint8Array(EK_A), IK_B);
1125
+ const DH3 = await xDHAsync(new Uint8Array(EK_A), SPK_B);
1126
+ const DH4 = OPK_B
1127
+ ? await xDHAsync(new Uint8Array(EK_A), OPK_B)
1128
+ : null;
1129
+ // initial key material
1130
+ const IKM = DH4
1131
+ ? xConcat(DH1, DH2, DH3, DH4)
1132
+ : xConcat(DH1, DH2, DH3);
1133
+ // one time key index
1134
+ const IDX = keyBundle.otk
1135
+ ? XUtils.numberToUint8Arr(keyBundle.otk.index ?? 0)
1136
+ : XUtils.numberToUint8Arr(0);
1137
+ // shared secret key
1138
+ const SK = xKDF(IKM);
1139
+ const PK = (await xBoxKeyPairFromSecretAsync(SK)).publicKey;
1140
+ const AD = fips
1141
+ ? fipsP256AdFromIdentityPubs(IK_AP, new Uint8Array(keyBundle.signKey))
1142
+ : xConcat(xEncode(xConstants.CURVE, IK_AP), xEncode(xConstants.CURVE, IK_B));
1143
+ const nonce = xMakeNonce();
1144
+ const cipher = await xSecretboxAsync(message, nonce, SK);
1145
+ const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
1146
+ const ephKeyWire = this.xKeyRing.ephemeralKeys.publicKey;
1147
+ const extra = fips
1148
+ ? encodeFipsInitialExtraV1(signKeyWire, ephKeyWire, PK, AD, IDX)
1149
+ : xConcat(this.signKeys.publicKey, this.xKeyRing.ephemeralKeys.publicKey, PK, AD, IDX);
1150
+ const mail = {
1151
+ authorID: this.getUser().userID,
1152
+ cipher,
1153
+ extra,
1154
+ forward,
1155
+ group,
1156
+ mailID: mailID || uuid.v4(),
1157
+ mailType: MailType.initial,
1158
+ nonce,
1159
+ readerID: user.userID,
1160
+ recipient: device.deviceID,
1161
+ sender: this.getDevice().deviceID,
1162
+ };
1163
+ const hmac = xHMAC(mail, SK);
1164
+ const msg = {
1165
+ action: "CREATE",
1166
+ data: mail,
1167
+ resourceType: "mail",
1168
+ transmissionID: uuid.v4(),
1169
+ type: "resource",
967
1170
  };
968
- this.socket.on("message", callback);
969
- void this.send(msg, hmac);
1171
+ // discard the ephemeral keys
1172
+ await this.newEphemeralKeys();
1173
+ const sessionEntry = {
1174
+ deviceID: device.deviceID,
1175
+ fingerprint: XUtils.encodeHex(AD),
1176
+ lastUsed: new Date().toISOString(),
1177
+ mode: "initiator",
1178
+ publicKey: XUtils.encodeHex(PK),
1179
+ sessionID: uuid.v4(),
1180
+ SK: XUtils.encodeHex(SK),
1181
+ userID: user.userID,
1182
+ verified: false,
1183
+ };
1184
+ await this.database.saveSession(sessionEntry);
1185
+ this.emitter.emit("session", sessionEntry, user);
1186
+ // emit the message
1187
+ const forwardedMsg = forward
1188
+ ? messageSchema.parse(msgpack.decode(message))
1189
+ : null;
1190
+ const emitMsg = forwardedMsg
1191
+ ? { ...forwardedMsg, forward: true }
1192
+ : {
1193
+ authorID: mail.authorID,
1194
+ decrypted: true,
1195
+ direction: "outgoing",
1196
+ forward: mail.forward,
1197
+ group: mail.group ? uuid.stringify(mail.group) : null,
1198
+ mailID: mail.mailID,
1199
+ message: XUtils.encodeUTF8(message),
1200
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1201
+ readerID: mail.readerID,
1202
+ recipient: mail.recipient,
1203
+ sender: mail.sender,
1204
+ timestamp: new Date().toISOString(),
1205
+ };
1206
+ this.emitter.emit("message", emitMsg);
1207
+ // send mail and wait for response
1208
+ await new Promise((res, rej) => {
1209
+ const callback = (packedMsg) => {
1210
+ const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
1211
+ if (receivedMsg.transmissionID === msg.transmissionID) {
1212
+ this.socket.off("message", callback);
1213
+ const parsed = WSMessageSchema.safeParse(receivedMsg);
1214
+ if (parsed.success && parsed.data.type === "success") {
1215
+ res(parsed.data.data);
1216
+ }
1217
+ else {
1218
+ rej(new Error("Mail delivery failed: " +
1219
+ JSON.stringify(receivedMsg)));
1220
+ }
1221
+ }
1222
+ };
1223
+ this.socket.on("message", callback);
1224
+ void this.send(msg, hmac);
1225
+ });
970
1226
  });
971
- this.sending.delete(device.deviceID);
972
1227
  }
973
1228
  async deleteChannel(channelID) {
974
1229
  await this.http.delete(this.getHost() + "/channel/" + channelID);
@@ -1034,6 +1289,9 @@ export class Client {
1034
1289
  }
1035
1290
  }
1036
1291
  async forward(message) {
1292
+ if (this.isManualCloseInFlight()) {
1293
+ return;
1294
+ }
1037
1295
  const copy = { ...message };
1038
1296
  if (this.forwarded.has(copy.mailID)) {
1039
1297
  return;
@@ -1047,19 +1305,17 @@ export class Client {
1047
1305
  }
1048
1306
  const msgBytes = Uint8Array.from(msgpack.encode(copy));
1049
1307
  const devices = await this.fetchUserDeviceListWithBackoff(this.getUser().userID, "own");
1050
- const promises = [];
1051
1308
  for (const device of devices) {
1052
- if (device.deviceID !== this.getDevice().deviceID) {
1053
- promises.push(this.sendMail(device, this.getUser(), msgBytes, null, copy.mailID, true));
1309
+ if (device.deviceID === this.getDevice().deviceID) {
1310
+ continue;
1054
1311
  }
1055
- }
1056
- void Promise.allSettled(promises).then((results) => {
1057
- for (const result of results) {
1058
- const { status } = result;
1059
- if (status === "rejected") {
1060
- }
1312
+ try {
1313
+ await this.sendMail(device, this.getUser(), msgBytes, null, copy.mailID, true);
1061
1314
  }
1062
- });
1315
+ catch {
1316
+ /* best-effort per device; parallel handshakes share ephemeral state */
1317
+ }
1318
+ }
1063
1319
  }
1064
1320
  async getChannelByID(channelID) {
1065
1321
  try {
@@ -1142,18 +1398,45 @@ export class Client {
1142
1398
  .array(mailInboxEntry)
1143
1399
  .parse(msgpack.decode(mailBuffer));
1144
1400
  const inbox = rawInbox.sort((a, b) => b[2].localeCompare(a[2]));
1401
+ if (libvexDebugDmEnabled()) {
1402
+ const did = (() => {
1403
+ try {
1404
+ return this.getDevice().deviceID;
1405
+ }
1406
+ catch {
1407
+ return "(no device)";
1408
+ }
1409
+ })();
1410
+ debugLibvexDm("getMail: inbox", {
1411
+ deviceID: did,
1412
+ count: String(inbox.length),
1413
+ });
1414
+ }
1145
1415
  for (const mailDetails of inbox) {
1146
1416
  const [mailHeader, mailBody, timestamp] = mailDetails;
1147
1417
  try {
1418
+ if (libvexDebugDmEnabled()) {
1419
+ debugLibvexDm("getMail: readMail one", {
1420
+ mailID: mailBody.mailID,
1421
+ type: String(mailBody.mailType),
1422
+ recipient: mailBody.recipient,
1423
+ });
1424
+ }
1148
1425
  await this.readMail(mailHeader, mailBody, timestamp);
1149
1426
  }
1150
- catch (_readMailErr) {
1151
- // non-fatal — inspect _readMailErr in a debugger
1427
+ catch (readMailErr) {
1428
+ if (libvexDebugDmEnabled()) {
1429
+ // eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
1430
+ console.error("[libvex:debug-dm] readMail threw", readMailErr);
1431
+ }
1152
1432
  }
1153
1433
  }
1154
1434
  }
1155
- catch (_fetchErr) {
1156
- // non-fatal — inspect _fetchErr in a debugger
1435
+ catch (fetchErr) {
1436
+ if (libvexDebugDmEnabled()) {
1437
+ // eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
1438
+ console.error("[libvex:debug-dm] getMail fetch failed", fetchErr);
1439
+ }
1157
1440
  }
1158
1441
  this.fetchingMail = false;
1159
1442
  }
@@ -1264,6 +1547,9 @@ export class Client {
1264
1547
  }
1265
1548
  }
1266
1549
  async fetchUserDeviceListOnce(userID) {
1550
+ if (this.isManualCloseInFlight()) {
1551
+ return [];
1552
+ }
1267
1553
  const res = await this.http.get(this.getHost() + "/user/" + userID + "/devices");
1268
1554
  const devices = decodeAxios(DeviceArrayCodec, res.data);
1269
1555
  for (const device of devices) {
@@ -1281,16 +1567,16 @@ export class Client {
1281
1567
  : "Couldn't get device list";
1282
1568
  let lastErr;
1283
1569
  for (let attempt = 0; attempt < 5; attempt++) {
1570
+ if (this.isManualCloseInFlight()) {
1571
+ return [];
1572
+ }
1284
1573
  if (attempt > 0) {
1285
- if (this.isManualCloseInFlight()) {
1286
- throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
1287
- }
1288
1574
  const delayMs = 100 * 2 ** (attempt - 1);
1289
- // Chunk the delay to allow close() to interrupt
1575
+ // Chunk the delay so close() can finish before we retry HTTP.
1290
1576
  const chunkMs = 10;
1291
1577
  for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
1292
1578
  if (this.isManualCloseInFlight()) {
1293
- throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
1579
+ return [];
1294
1580
  }
1295
1581
  await sleep(Math.min(chunkMs, delayMs - elapsed));
1296
1582
  }
@@ -1324,6 +1610,24 @@ export class Client {
1324
1610
  break;
1325
1611
  }
1326
1612
  }
1613
+ /**
1614
+ * Pipeline for decrypted messages — registered in `init`. After `close()` sets
1615
+ * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
1616
+ * race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
1617
+ */
1618
+ onInternalMessage = (message) => {
1619
+ if (this.isManualCloseInFlight()) {
1620
+ return;
1621
+ }
1622
+ if (message.direction === "outgoing" && !message.forward) {
1623
+ void this.forward(message);
1624
+ }
1625
+ if (message.direction === "incoming" &&
1626
+ message.recipient === message.sender) {
1627
+ return;
1628
+ }
1629
+ void this.database.saveMessage(message);
1630
+ };
1327
1631
  /**
1328
1632
  * Initializes the keyring. This must be called before anything else.
1329
1633
  */
@@ -1339,16 +1643,7 @@ export class Client {
1339
1643
  attachNodeAgentsToAxios(this.http, agents);
1340
1644
  }
1341
1645
  await this.populateKeyRing();
1342
- this.emitter.on("message", (message) => {
1343
- if (message.direction === "outgoing" && !message.forward) {
1344
- void this.forward(message);
1345
- }
1346
- if (message.direction === "incoming" &&
1347
- message.recipient === message.sender) {
1348
- return;
1349
- }
1350
- void this.database.saveMessage(message);
1351
- });
1646
+ this.emitter.on("message", this.onInternalMessage);
1352
1647
  this.emitter.emit("ready");
1353
1648
  }
1354
1649
  initSocket() {
@@ -1390,7 +1685,7 @@ export class Client {
1390
1685
  const msg = parseResult.data;
1391
1686
  switch (msg.type) {
1392
1687
  case "challenge":
1393
- this.respond(msg);
1688
+ void this.respond(msg);
1394
1689
  break;
1395
1690
  case "error":
1396
1691
  break;
@@ -1449,14 +1744,14 @@ export class Client {
1449
1744
  }
1450
1745
  await this.submitOTK(needs);
1451
1746
  }
1452
- newEphemeralKeys() {
1747
+ async newEphemeralKeys() {
1453
1748
  if (!this.xKeyRing) {
1454
1749
  if (this.manuallyClosing) {
1455
1750
  return;
1456
1751
  }
1457
1752
  throw new Error("Key ring not initialized.");
1458
1753
  }
1459
- this.xKeyRing.ephemeralKeys = xBoxKeyPair();
1754
+ this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
1460
1755
  }
1461
1756
  ping() {
1462
1757
  if (!this.isAlive) {
@@ -1476,7 +1771,7 @@ export class Client {
1476
1771
  const existingPreKeys = await this.database.getPreKeys();
1477
1772
  const preKeys = existingPreKeys ??
1478
1773
  (await (async () => {
1479
- const unsaved = this.createPreKey();
1774
+ const unsaved = await this.createPreKey();
1480
1775
  const [saved] = await this.database.savePreKeys([unsaved], false);
1481
1776
  if (!saved || saved.index == null)
1482
1777
  throw new Error("Failed to save prekey — no index returned.");
@@ -1487,7 +1782,7 @@ export class Client {
1487
1782
  this.sessionRecords[session.publicKey] =
1488
1783
  sqlSessionToCrypto(session);
1489
1784
  }
1490
- const ephemeralKeys = xBoxKeyPair();
1785
+ const ephemeralKeys = await xBoxKeyPairAsync();
1491
1786
  this.xKeyRing = {
1492
1787
  ephemeralKeys,
1493
1788
  identityKeys,
@@ -1495,11 +1790,15 @@ export class Client {
1495
1790
  };
1496
1791
  }
1497
1792
  async postAuth() {
1793
+ const versionAtStart = this.postAuthVersion;
1498
1794
  let count = 0;
1499
1795
  for (;;) {
1500
1796
  if (this.isManualCloseInFlight()) {
1501
1797
  return;
1502
1798
  }
1799
+ if (this.postAuthVersion !== versionAtStart) {
1800
+ return;
1801
+ }
1503
1802
  try {
1504
1803
  await this.getMail();
1505
1804
  count++;
@@ -1513,12 +1812,18 @@ export class Client {
1513
1812
  if (this.isManualCloseInFlight()) {
1514
1813
  return;
1515
1814
  }
1815
+ if (this.postAuthVersion !== versionAtStart) {
1816
+ return;
1817
+ }
1516
1818
  // Chunk the idle delay so `close()` can unwind instead of waiting
1517
1819
  // out one full 60s timer (which would keep the process alive).
1518
1820
  for (let i = 0; i < 60; i++) {
1519
1821
  if (this.isManualCloseInFlight()) {
1520
1822
  return;
1521
1823
  }
1824
+ if (this.postAuthVersion !== versionAtStart) {
1825
+ return;
1826
+ }
1522
1827
  await sleep(1000);
1523
1828
  }
1524
1829
  }
@@ -1528,10 +1833,28 @@ export class Client {
1528
1833
  }
1529
1834
  async readMail(header, mail, timestamp) {
1530
1835
  if (this.seenMailIDs.has(mail.mailID)) {
1836
+ if (libvexDebugDmEnabled()) {
1837
+ try {
1838
+ debugLibvexDm("readMail: skip (seen mailID)", {
1839
+ mailID: mail.mailID,
1840
+ thisDevice: this.getDevice().deviceID,
1841
+ });
1842
+ }
1843
+ catch {
1844
+ debugLibvexDm("readMail: skip (seen mailID)", {
1845
+ mailID: mail.mailID,
1846
+ });
1847
+ }
1848
+ }
1531
1849
  return;
1532
1850
  }
1533
1851
  this.seenMailIDs.add(mail.mailID);
1534
1852
  if (this.manuallyClosing) {
1853
+ if (libvexDebugDmEnabled()) {
1854
+ debugLibvexDm("readMail: skip (manually closing)", {
1855
+ mailID: mail.mailID,
1856
+ });
1857
+ }
1535
1858
  return;
1536
1859
  }
1537
1860
  this.sendReceipt(new Uint8Array(mail.nonce));
@@ -1542,207 +1865,297 @@ export class Client {
1542
1865
  }
1543
1866
  this.reading = true;
1544
1867
  try {
1545
- const healSession = async () => {
1546
- if (this.manuallyClosing || !this.xKeyRing) {
1547
- return;
1548
- }
1549
- const deviceEntry = await this.getDeviceByID(mail.sender);
1550
- const [user, _err] = await this.fetchUser(mail.authorID);
1551
- if (deviceEntry && user) {
1552
- void this.createSession(deviceEntry, user, XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`), mail.group, uuid.v4(), false);
1553
- }
1554
- };
1555
- switch (mail.mailType) {
1556
- case MailType.initial:
1557
- const extraParts = Client.deserializeExtra(MailType.initial, new Uint8Array(mail.extra));
1558
- const signKey = extraParts[0];
1559
- const ephKey = extraParts[1];
1560
- const indexBytes = extraParts[3];
1561
- if (!signKey || !ephKey || !indexBytes) {
1562
- throw new Error("Malformed initial mail extra: missing signKey, ephKey, or indexBytes");
1563
- }
1564
- const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
1565
- const otk = preKeyIndex === 0
1566
- ? null
1567
- : await this.database.getOneTimeKey(preKeyIndex);
1568
- if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
1868
+ await this.runWithThisCryptoProfile(async () => {
1869
+ const healSession = async () => {
1870
+ if (this.manuallyClosing || !this.xKeyRing) {
1569
1871
  return;
1570
1872
  }
1571
- // their public keys
1572
- const IK_A_raw = XKeyConvert.convertPublicKey(signKey);
1573
- if (!IK_A_raw) {
1574
- return;
1575
- }
1576
- const IK_A = IK_A_raw;
1577
- const EK_A = ephKey;
1578
- if (!this.xKeyRing) {
1579
- return;
1580
- }
1581
- // my private keys
1582
- const IK_B = this.xKeyRing.identityKeys.secretKey;
1583
- const IK_BP = this.xKeyRing.identityKeys.publicKey;
1584
- const SPK_B = this.xKeyRing.preKeys.keyPair.secretKey;
1585
- const OPK_B = otk ? otk.keyPair.secretKey : null;
1586
- // diffie hellman functions
1587
- const DH1 = xDH(SPK_B, IK_A);
1588
- const DH2 = xDH(IK_B, EK_A);
1589
- const DH3 = xDH(SPK_B, EK_A);
1590
- const DH4 = OPK_B ? xDH(OPK_B, EK_A) : null;
1591
- // initial key material
1592
- const IKM = DH4
1593
- ? xConcat(DH1, DH2, DH3, DH4)
1594
- : xConcat(DH1, DH2, DH3);
1595
- // shared secret key
1596
- const SK = xKDF(IKM);
1597
- const PK = xBoxKeyPairFromSecret(SK).publicKey;
1598
- const hmac = xHMAC(mail, SK);
1599
- // associated data
1600
- const AD = xConcat(xEncode(xConstants.CURVE, IK_A), xEncode(xConstants.CURVE, IK_BP));
1601
- if (!XUtils.bytesEqual(hmac, header)) {
1602
- return;
1873
+ const deviceEntry = await this.getDeviceByID(mail.sender);
1874
+ const [user, _err] = await this.fetchUser(mail.authorID);
1875
+ if (deviceEntry && user) {
1876
+ void this.createSession(deviceEntry, user, XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`), mail.group, uuid.v4(), false, true);
1603
1877
  }
1604
- const unsealed = xSecretboxOpen(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), SK);
1605
- if (unsealed) {
1606
- let plaintext = "";
1607
- if (!mail.forward) {
1608
- plaintext = XUtils.encodeUTF8(unsealed);
1878
+ };
1879
+ switch (mail.mailType) {
1880
+ case MailType.initial:
1881
+ const extraParts = Client.deserializeExtra(MailType.initial, new Uint8Array(mail.extra));
1882
+ const signKey = extraParts[0];
1883
+ const ephKey = extraParts[1];
1884
+ const indexBytes = extraParts[3];
1885
+ if (!signKey || !ephKey || !indexBytes) {
1886
+ throw new Error("Malformed initial mail extra: missing signKey, ephKey, or indexBytes");
1609
1887
  }
1610
- // emit the message
1611
- const fwdMsg1 = mail.forward
1612
- ? messageSchema.parse(msgpack.decode(unsealed))
1613
- : null;
1614
- const message = fwdMsg1
1615
- ? { ...fwdMsg1, forward: true }
1616
- : {
1617
- authorID: mail.authorID,
1618
- decrypted: true,
1619
- direction: "incoming",
1620
- forward: mail.forward,
1621
- group: mail.group
1622
- ? uuid.stringify(mail.group)
1623
- : null,
1624
- mailID: mail.mailID,
1625
- message: plaintext,
1626
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1627
- readerID: mail.readerID,
1628
- recipient: mail.recipient,
1629
- sender: mail.sender,
1630
- timestamp: timestamp,
1631
- };
1632
- this.emitter.emit("message", message);
1633
- // discard onetimekey
1634
- await this.database.deleteOneTimeKey(preKeyIndex);
1635
- const deviceEntry = await this.getDeviceByID(mail.sender);
1636
- if (!deviceEntry) {
1637
- throw new Error("Couldn't get device entry.");
1888
+ const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
1889
+ const otk = preKeyIndex === 0
1890
+ ? null
1891
+ : await this.database.getOneTimeKey(preKeyIndex);
1892
+ if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
1893
+ if (libvexDebugDmEnabled()) {
1894
+ try {
1895
+ debugLibvexDm("readMail initial: abort (otk index mismatch)", {
1896
+ mailID: mail.mailID,
1897
+ preKeyIndex: String(preKeyIndex),
1898
+ otkIndex: String(otk?.index ?? "null"),
1899
+ thisDevice: this.getDevice().deviceID,
1900
+ });
1901
+ }
1902
+ catch {
1903
+ debugLibvexDm("readMail initial: abort (otk index mismatch)", {
1904
+ mailID: mail.mailID,
1905
+ });
1906
+ }
1907
+ }
1908
+ return;
1638
1909
  }
1639
- const [userEntry, _userErr] = await this.fetchUser(deviceEntry.owner);
1640
- if (!userEntry) {
1641
- throw new Error("Couldn't get user entry.");
1910
+ // their public keys
1911
+ const fipsRead = isFipsInitialExtraV1(new Uint8Array(mail.extra));
1912
+ const IK_A = fipsRead
1913
+ ? signKey
1914
+ : (() => {
1915
+ const c = XKeyConvert.convertPublicKey(signKey);
1916
+ if (!c) {
1917
+ return null;
1918
+ }
1919
+ return c;
1920
+ })();
1921
+ if (!IK_A) {
1922
+ if (libvexDebugDmEnabled()) {
1923
+ try {
1924
+ debugLibvexDm("readMail initial: abort (IK_A null, Ed→X25519?)", {
1925
+ mailID: mail.mailID,
1926
+ fips: String(fipsRead),
1927
+ thisDevice: this.getDevice().deviceID,
1928
+ });
1929
+ }
1930
+ catch {
1931
+ debugLibvexDm("readMail initial: abort (IK_A null)", {
1932
+ mailID: mail.mailID,
1933
+ });
1934
+ }
1935
+ }
1936
+ return;
1642
1937
  }
1643
- this.userRecords[userEntry.userID] = userEntry;
1644
- this.deviceRecords[deviceEntry.deviceID] = deviceEntry;
1645
- // save session
1646
- const newSession = {
1647
- deviceID: mail.sender,
1648
- fingerprint: XUtils.encodeHex(AD),
1649
- lastUsed: new Date().toISOString(),
1650
- mode: "receiver",
1651
- publicKey: XUtils.encodeHex(PK),
1652
- sessionID: uuid.v4(),
1653
- SK: XUtils.encodeHex(SK),
1654
- userID: userEntry.userID,
1655
- verified: false,
1656
- };
1657
- await this.database.saveSession(newSession);
1658
- const [user] = await this.fetchUser(newSession.userID);
1659
- if (user) {
1660
- this.emitter.emit("session", newSession, user);
1938
+ const EK_A = ephKey;
1939
+ if (!this.xKeyRing) {
1940
+ if (libvexDebugDmEnabled()) {
1941
+ debugLibvexDm("readMail initial: abort (no xKeyRing)", {
1942
+ mailID: mail.mailID,
1943
+ });
1944
+ }
1945
+ return;
1946
+ }
1947
+ // my private keys
1948
+ const IK_B = this.xKeyRing.identityKeys.secretKey;
1949
+ const IK_BP = this.xKeyRing.identityKeys.publicKey;
1950
+ const SPK_B = this.xKeyRing.preKeys.keyPair.secretKey;
1951
+ const OPK_B = otk ? otk.keyPair.secretKey : null;
1952
+ // diffie hellman functions
1953
+ const DH1 = await xDHAsync(SPK_B, IK_A);
1954
+ const DH2 = await xDHAsync(IK_B, EK_A);
1955
+ const DH3 = await xDHAsync(SPK_B, EK_A);
1956
+ const DH4 = OPK_B ? await xDHAsync(OPK_B, EK_A) : null;
1957
+ // initial key material
1958
+ const IKM = DH4
1959
+ ? xConcat(DH1, DH2, DH3, DH4)
1960
+ : xConcat(DH1, DH2, DH3);
1961
+ // shared secret key
1962
+ const SK = xKDF(IKM);
1963
+ const PK = (await xBoxKeyPairFromSecretAsync(SK))
1964
+ .publicKey;
1965
+ const hmac = xHMAC(mail, SK);
1966
+ // associated data
1967
+ const AD = fipsRead
1968
+ ? fipsP256AdFromIdentityPubs(IK_A, IK_BP)
1969
+ : xConcat(xEncode(xConstants.CURVE, IK_A), xEncode(xConstants.CURVE, IK_BP));
1970
+ if (!XUtils.bytesEqual(hmac, header)) {
1971
+ if (libvexDebugDmEnabled()) {
1972
+ try {
1973
+ debugLibvexDm("readMail initial: abort (HMAC mismatch)", {
1974
+ mailID: mail.mailID,
1975
+ preKeyIndex: String(preKeyIndex),
1976
+ thisDevice: this.getDevice().deviceID,
1977
+ });
1978
+ }
1979
+ catch {
1980
+ debugLibvexDm("readMail initial: abort (HMAC mismatch)", {
1981
+ mailID: mail.mailID,
1982
+ });
1983
+ }
1984
+ }
1985
+ return;
1986
+ }
1987
+ const unsealed = await xSecretboxOpenAsync(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), SK);
1988
+ if (unsealed) {
1989
+ let plaintext = "";
1990
+ if (!mail.forward) {
1991
+ plaintext = XUtils.encodeUTF8(unsealed);
1992
+ }
1993
+ // emit the message
1994
+ const fwdMsg1 = mail.forward
1995
+ ? messageSchema.parse(msgpack.decode(unsealed))
1996
+ : null;
1997
+ const message = fwdMsg1
1998
+ ? { ...fwdMsg1, forward: true }
1999
+ : {
2000
+ authorID: mail.authorID,
2001
+ decrypted: true,
2002
+ direction: "incoming",
2003
+ forward: mail.forward,
2004
+ group: mail.group
2005
+ ? uuid.stringify(mail.group)
2006
+ : null,
2007
+ mailID: mail.mailID,
2008
+ message: plaintext,
2009
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
2010
+ readerID: mail.readerID,
2011
+ recipient: mail.recipient,
2012
+ sender: mail.sender,
2013
+ timestamp: timestamp,
2014
+ };
2015
+ this.emitter.emit("message", message);
2016
+ if (libvexDebugDmEnabled()) {
2017
+ try {
2018
+ debugLibvexDm("readMail initial: ok (emit message)", {
2019
+ mailID: mail.mailID,
2020
+ preKeyIndex: String(preKeyIndex),
2021
+ thisDevice: this.getDevice().deviceID,
2022
+ plaintextLen: String(plaintext.length),
2023
+ });
2024
+ }
2025
+ catch {
2026
+ debugLibvexDm("readMail initial: ok (emit message)", {
2027
+ mailID: mail.mailID,
2028
+ });
2029
+ }
2030
+ }
2031
+ // preKeyIndex 0 = med prekey only (no OTK in the X3DH path). Do
2032
+ // not call deleteOneTimeKey(0) — that is not "remove OTK row 0".
2033
+ if (preKeyIndex !== 0) {
2034
+ await this.database.deleteOneTimeKey(preKeyIndex);
2035
+ }
2036
+ const deviceEntry = await this.getDeviceByID(mail.sender);
2037
+ if (!deviceEntry) {
2038
+ throw new Error("Couldn't get device entry.");
2039
+ }
2040
+ const [userEntry, _userErr] = await this.fetchUser(deviceEntry.owner);
2041
+ if (!userEntry) {
2042
+ throw new Error("Couldn't get user entry.");
2043
+ }
2044
+ this.userRecords[userEntry.userID] = userEntry;
2045
+ this.deviceRecords[deviceEntry.deviceID] =
2046
+ deviceEntry;
2047
+ // save session
2048
+ const newSession = {
2049
+ deviceID: mail.sender,
2050
+ fingerprint: XUtils.encodeHex(AD),
2051
+ lastUsed: new Date().toISOString(),
2052
+ mode: "receiver",
2053
+ publicKey: XUtils.encodeHex(PK),
2054
+ sessionID: uuid.v4(),
2055
+ SK: XUtils.encodeHex(SK),
2056
+ userID: userEntry.userID,
2057
+ verified: false,
2058
+ };
2059
+ await this.database.saveSession(newSession);
2060
+ const [user] = await this.fetchUser(newSession.userID);
2061
+ if (user) {
2062
+ this.emitter.emit("session", newSession, user);
2063
+ }
2064
+ else {
2065
+ }
1661
2066
  }
1662
2067
  else {
2068
+ if (libvexDebugDmEnabled()) {
2069
+ debugLibvexDm("readMail initial: abort (xSecretboxOpen null)", {
2070
+ mailID: mail.mailID,
2071
+ preKeyIndex: String(preKeyIndex),
2072
+ });
2073
+ }
1663
2074
  }
1664
- }
1665
- else {
1666
- }
1667
- break;
1668
- case MailType.subsequent:
1669
- const publicKey = Client.deserializeExtra(mail.mailType, new Uint8Array(mail.extra))[0];
1670
- if (!publicKey) {
1671
- throw new Error("Malformed subsequent mail extra: missing publicKey");
1672
- }
1673
- let session = await this.getSessionByPubkey(publicKey);
1674
- let retries = 0;
1675
- while (!session) {
1676
- if (retries >= 3) {
1677
- break;
2075
+ break;
2076
+ case MailType.subsequent: {
2077
+ const extraBuf = new Uint8Array(mail.extra);
2078
+ const publicKey = isFipsSubsequentExtraV1(extraBuf)
2079
+ ? decodeFipsSubsequentExtraV1(extraBuf)
2080
+ : Client.deserializeExtra(mail.mailType, extraBuf)[0];
2081
+ if (!publicKey) {
2082
+ throw new Error("Malformed subsequent mail extra: missing publicKey");
1678
2083
  }
1679
- await sleep(100 * 2 ** retries);
1680
- retries++;
1681
- session = await this.getSessionByPubkey(publicKey);
1682
- }
1683
- if (!session) {
1684
- void healSession();
1685
- return;
1686
- }
1687
- const HMAC = xHMAC(mail, session.SK);
1688
- if (!XUtils.bytesEqual(HMAC, header)) {
1689
- void healSession();
1690
- return;
1691
- }
1692
- const decrypted = xSecretboxOpen(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), session.SK);
1693
- if (decrypted) {
1694
- const fwdMsg2 = mail.forward
1695
- ? messageSchema.parse(msgpack.decode(decrypted))
1696
- : null;
1697
- const message = fwdMsg2
1698
- ? {
1699
- ...fwdMsg2,
1700
- forward: true,
2084
+ let session = await this.getSessionByPubkey(publicKey);
2085
+ let retries = 0;
2086
+ while (!session) {
2087
+ if (retries >= 3) {
2088
+ break;
1701
2089
  }
1702
- : {
2090
+ await sleep(100 * 2 ** retries);
2091
+ retries++;
2092
+ session = await this.getSessionByPubkey(publicKey);
2093
+ }
2094
+ if (!session) {
2095
+ void healSession();
2096
+ return;
2097
+ }
2098
+ const HMAC = xHMAC(mail, session.SK);
2099
+ if (!XUtils.bytesEqual(HMAC, header)) {
2100
+ void healSession();
2101
+ return;
2102
+ }
2103
+ const decrypted = await xSecretboxOpenAsync(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), session.SK);
2104
+ if (decrypted) {
2105
+ const fwdMsg2 = mail.forward
2106
+ ? messageSchema.parse(msgpack.decode(decrypted))
2107
+ : null;
2108
+ const message = fwdMsg2
2109
+ ? {
2110
+ ...fwdMsg2,
2111
+ forward: true,
2112
+ }
2113
+ : {
2114
+ authorID: mail.authorID,
2115
+ decrypted: true,
2116
+ direction: "incoming",
2117
+ forward: mail.forward,
2118
+ group: mail.group
2119
+ ? uuid.stringify(mail.group)
2120
+ : null,
2121
+ mailID: mail.mailID,
2122
+ message: XUtils.encodeUTF8(decrypted),
2123
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
2124
+ readerID: mail.readerID,
2125
+ recipient: mail.recipient,
2126
+ sender: mail.sender,
2127
+ timestamp: timestamp,
2128
+ };
2129
+ this.emitter.emit("message", message);
2130
+ void this.database.markSessionUsed(session.sessionID);
2131
+ }
2132
+ else {
2133
+ void healSession();
2134
+ // emit the message
2135
+ const message = {
1703
2136
  authorID: mail.authorID,
1704
- decrypted: true,
2137
+ decrypted: false,
1705
2138
  direction: "incoming",
1706
2139
  forward: mail.forward,
1707
2140
  group: mail.group
1708
2141
  ? uuid.stringify(mail.group)
1709
2142
  : null,
1710
2143
  mailID: mail.mailID,
1711
- message: XUtils.encodeUTF8(decrypted),
2144
+ message: "",
1712
2145
  nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1713
2146
  readerID: mail.readerID,
1714
2147
  recipient: mail.recipient,
1715
2148
  sender: mail.sender,
1716
2149
  timestamp: timestamp,
1717
2150
  };
1718
- this.emitter.emit("message", message);
1719
- void this.database.markSessionUsed(session.sessionID);
1720
- }
1721
- else {
1722
- void healSession();
1723
- // emit the message
1724
- const message = {
1725
- authorID: mail.authorID,
1726
- decrypted: false,
1727
- direction: "incoming",
1728
- forward: mail.forward,
1729
- group: mail.group
1730
- ? uuid.stringify(mail.group)
1731
- : null,
1732
- mailID: mail.mailID,
1733
- message: "",
1734
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1735
- readerID: mail.readerID,
1736
- recipient: mail.recipient,
1737
- sender: mail.sender,
1738
- timestamp: timestamp,
1739
- };
1740
- this.emitter.emit("message", message);
2151
+ this.emitter.emit("message", message);
2152
+ }
2153
+ break;
1741
2154
  }
1742
- break;
1743
- default:
1744
- break;
1745
- }
2155
+ default:
2156
+ break;
2157
+ }
2158
+ });
1746
2159
  }
1747
2160
  finally {
1748
2161
  this.reading = false;
@@ -1771,8 +2184,11 @@ export class Client {
1771
2184
  if (!token) {
1772
2185
  throw new Error("Couldn't fetch token.");
1773
2186
  }
2187
+ // Stored on Spire for signature verification: Ed25519 (hex) in tweetnacl;
2188
+ // P-256 ECDSA SPKI (hex) in FIPS. The server maps this to a raw ECDH
2189
+ // identity in `getKeyBundle` for X3DH; see spire `Database.getKeyBundle`.
1774
2190
  const signKey = this.getKeys().public;
1775
- const signed = XUtils.encodeHex(xSign(Uint8Array.from(uuid.parse(token.key)), this.signKeys.secretKey));
2191
+ const signed = XUtils.encodeHex(await xSignAsync(Uint8Array.from(uuid.parse(token.key)), this.signKeys.secretKey));
1776
2192
  const devPreKeyIndex = this.xKeyRing.preKeys.index;
1777
2193
  const devMsg = {
1778
2194
  deviceName: this.options?.deviceName ?? "unknown",
@@ -1790,9 +2206,9 @@ export class Client {
1790
2206
  "/devices", msgpack.encode(devMsg), { headers: { "Content-Type": "application/msgpack" } });
1791
2207
  return decodeAxios(DeviceCodec, res.data);
1792
2208
  }
1793
- respond(msg) {
2209
+ async respond(msg) {
1794
2210
  const response = {
1795
- signed: xSign(new Uint8Array(msg.challenge), this.signKeys.secretKey),
2211
+ signed: await xSignAsync(new Uint8Array(msg.challenge), this.signKeys.secretKey),
1796
2212
  transmissionID: msg.transmissionID,
1797
2213
  type: "response",
1798
2214
  };
@@ -1828,7 +2244,7 @@ export class Client {
1828
2244
  },
1829
2245
  });
1830
2246
  const fileData = res.data;
1831
- const decrypted = xSecretboxOpen(new Uint8Array(fileData), XUtils.decodeHex(details.nonce), XUtils.decodeHex(key));
2247
+ const decrypted = await xSecretboxOpenAsync(new Uint8Array(fileData), XUtils.decodeHex(details.nonce), XUtils.decodeHex(key));
1832
2248
  if (decrypted) {
1833
2249
  return {
1834
2250
  data: new Uint8Array(decrypted),
@@ -1895,7 +2311,6 @@ export class Client {
1895
2311
  this.userRecords[user.userID] = user;
1896
2312
  }
1897
2313
  const mailID = uuid.v4();
1898
- const promises = [];
1899
2314
  const userIDs = [...new Set(userList.map((user) => user.userID))];
1900
2315
  const devices = await this.getMultiUserDeviceList(userIDs);
1901
2316
  for (const device of devices) {
@@ -1903,15 +2318,13 @@ export class Client {
1903
2318
  if (!ownerRecord) {
1904
2319
  continue;
1905
2320
  }
1906
- promises.push(this.sendMail(device, ownerRecord, XUtils.decodeUTF8(message), uuidToUint8(channelID), mailID, false));
1907
- }
1908
- void Promise.allSettled(promises).then((results) => {
1909
- for (const result of results) {
1910
- const { status } = result;
1911
- if (status === "rejected") {
1912
- }
2321
+ try {
2322
+ await this.sendMail(device, ownerRecord, XUtils.decodeUTF8(message), uuidToUint8(channelID), mailID, false);
1913
2323
  }
1914
- });
2324
+ catch {
2325
+ /* best-effort; each device needs its own X3DH handshake (sequential) */
2326
+ }
2327
+ }
1915
2328
  }
1916
2329
  /* Sends encrypted mail to a user. */
1917
2330
  async sendMail(device, user, msg, group, mailID, forward, retry = false) {
@@ -1919,74 +2332,97 @@ export class Client {
1919
2332
  await sleep(100);
1920
2333
  }
1921
2334
  this.sending.set(device.deviceID, device);
1922
- const session = await this.database.getSessionByDeviceID(device.deviceID);
1923
- if (!session || retry) {
1924
- await this.createSession(device, user, msg, group, mailID, forward);
1925
- return;
1926
- }
1927
- const nonce = xMakeNonce();
1928
- const cipher = xSecretbox(msg, nonce, session.SK);
1929
- const extra = session.publicKey;
1930
- const mail = {
1931
- authorID: this.getUser().userID,
1932
- cipher,
1933
- extra,
1934
- forward,
1935
- group,
1936
- mailID: mailID || uuid.v4(),
1937
- mailType: MailType.subsequent,
1938
- nonce,
1939
- readerID: session.userID,
1940
- recipient: device.deviceID,
1941
- sender: this.getDevice().deviceID,
1942
- };
1943
- const msgb = {
1944
- action: "CREATE",
1945
- data: mail,
1946
- resourceType: "mail",
1947
- transmissionID: uuid.v4(),
1948
- type: "resource",
1949
- };
1950
- const hmac = xHMAC(mail, session.SK);
1951
- const fwdOut = forward
1952
- ? messageSchema.parse(msgpack.decode(msg))
1953
- : null;
1954
- const outMsg = fwdOut
1955
- ? { ...fwdOut, forward: true }
1956
- : {
1957
- authorID: mail.authorID,
1958
- decrypted: true,
1959
- direction: "outgoing",
1960
- forward: mail.forward,
1961
- group: mail.group ? uuid.stringify(mail.group) : null,
1962
- mailID: mail.mailID,
1963
- message: XUtils.encodeUTF8(msg),
1964
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1965
- readerID: mail.readerID,
1966
- recipient: mail.recipient,
1967
- sender: mail.sender,
1968
- timestamp: new Date().toISOString(),
1969
- };
1970
- this.emitter.emit("message", outMsg);
1971
- await new Promise((res, rej) => {
1972
- const callback = (packedMsg) => {
1973
- const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
1974
- if (receivedMsg.transmissionID === msgb.transmissionID) {
1975
- this.socket.off("message", callback);
1976
- const parsed = WSMessageSchema.safeParse(receivedMsg);
1977
- if (parsed.success && parsed.data.type === "success") {
1978
- res(parsed.data.data);
1979
- }
1980
- else {
1981
- rej(new Error("Mail delivery failed: " +
1982
- JSON.stringify(receivedMsg)));
1983
- }
2335
+ try {
2336
+ const session = await this.database.getSessionByDeviceID(device.deviceID);
2337
+ if (!session || retry) {
2338
+ if (libvexDebugDmEnabled()) {
2339
+ debugLibvexDm("sendMail: createSession path", {
2340
+ peerDevice: device.deviceID,
2341
+ retry: String(retry),
2342
+ hasSession: String(!!session),
2343
+ });
1984
2344
  }
2345
+ await this.createSession(device, user, msg, group, mailID, forward, false);
2346
+ if (libvexDebugDmEnabled()) {
2347
+ debugLibvexDm("sendMail: createSession returned", {
2348
+ peerDevice: device.deviceID,
2349
+ });
2350
+ }
2351
+ return;
2352
+ }
2353
+ if (libvexDebugDmEnabled()) {
2354
+ debugLibvexDm("sendMail: subsequent path", {
2355
+ peerDevice: device.deviceID,
2356
+ });
2357
+ }
2358
+ const nonce = xMakeNonce();
2359
+ const cipher = await xSecretboxAsync(msg, nonce, session.SK);
2360
+ const extra = this.cryptoProfile === "fips"
2361
+ ? encodeFipsSubsequentExtraV1(session.publicKey)
2362
+ : session.publicKey;
2363
+ const mail = {
2364
+ authorID: this.getUser().userID,
2365
+ cipher,
2366
+ extra,
2367
+ forward,
2368
+ group,
2369
+ mailID: mailID || uuid.v4(),
2370
+ mailType: MailType.subsequent,
2371
+ nonce,
2372
+ readerID: session.userID,
2373
+ recipient: device.deviceID,
2374
+ sender: this.getDevice().deviceID,
1985
2375
  };
1986
- this.socket.on("message", callback);
1987
- void this.send(msgb, hmac);
1988
- });
1989
- this.sending.delete(device.deviceID);
2376
+ const msgb = {
2377
+ action: "CREATE",
2378
+ data: mail,
2379
+ resourceType: "mail",
2380
+ transmissionID: uuid.v4(),
2381
+ type: "resource",
2382
+ };
2383
+ const hmac = xHMAC(mail, session.SK);
2384
+ const fwdOut = forward
2385
+ ? messageSchema.parse(msgpack.decode(msg))
2386
+ : null;
2387
+ const outMsg = fwdOut
2388
+ ? { ...fwdOut, forward: true }
2389
+ : {
2390
+ authorID: mail.authorID,
2391
+ decrypted: true,
2392
+ direction: "outgoing",
2393
+ forward: mail.forward,
2394
+ group: mail.group ? uuid.stringify(mail.group) : null,
2395
+ mailID: mail.mailID,
2396
+ message: XUtils.encodeUTF8(msg),
2397
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
2398
+ readerID: mail.readerID,
2399
+ recipient: mail.recipient,
2400
+ sender: mail.sender,
2401
+ timestamp: new Date().toISOString(),
2402
+ };
2403
+ this.emitter.emit("message", outMsg);
2404
+ await new Promise((res, rej) => {
2405
+ const callback = (packedMsg) => {
2406
+ const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
2407
+ if (receivedMsg.transmissionID === msgb.transmissionID) {
2408
+ this.socket.off("message", callback);
2409
+ const parsed = WSMessageSchema.safeParse(receivedMsg);
2410
+ if (parsed.success && parsed.data.type === "success") {
2411
+ res(parsed.data.data);
2412
+ }
2413
+ else {
2414
+ rej(new Error("Mail delivery failed: " +
2415
+ JSON.stringify(receivedMsg)));
2416
+ }
2417
+ }
2418
+ };
2419
+ this.socket.on("message", callback);
2420
+ void this.send(msgb, hmac);
2421
+ });
2422
+ }
2423
+ finally {
2424
+ this.sending.delete(device.deviceID);
2425
+ }
1990
2426
  }
1991
2427
  async sendMessage(userID, message) {
1992
2428
  try {
@@ -1997,19 +2433,85 @@ export class Client {
1997
2433
  if (!userEntry) {
1998
2434
  throw new Error("Couldn't get user entry.");
1999
2435
  }
2000
- const deviceList = await this.fetchUserDeviceListWithBackoff(userID, "peer");
2001
- const mailID = uuid.v4();
2002
- const promises = [];
2003
- for (const device of deviceList) {
2004
- promises.push(this.sendMail(device, userEntry, XUtils.decodeUTF8(message), null, mailID, false));
2436
+ const afterBackoff = await this.fetchUserDeviceListWithBackoff(userID, "peer");
2437
+ // Back-to-back GETs, merged by deviceID: a second read can list a device
2438
+ // that was not visible in the first snapshot (automation + multi-device)
2439
+ // without adding a fixed sleep.
2440
+ let deviceListRaw = afterBackoff;
2441
+ try {
2442
+ const again = await this.fetchUserDeviceListOnce(userID);
2443
+ const byId = new Map();
2444
+ for (const d of afterBackoff) {
2445
+ byId.set(d.deviceID, d);
2446
+ }
2447
+ for (const d of again) {
2448
+ byId.set(d.deviceID, d);
2449
+ }
2450
+ deviceListRaw = [...byId.values()];
2451
+ }
2452
+ catch {
2453
+ deviceListRaw = afterBackoff;
2454
+ }
2455
+ if (deviceListRaw.length === 0) {
2456
+ throw new Error("No devices for user — cannot send direct message.");
2457
+ }
2458
+ // Stable order (Peer device list is otherwise DB-order dependent).
2459
+ const deviceList = [...deviceListRaw].sort((a, b) => a.deviceID.localeCompare(b.deviceID, "en"));
2460
+ if (libvexDebugDmEnabled()) {
2461
+ debugLibvexDm("sendMessage: peer device list (merged, sorted)", {
2462
+ userID,
2463
+ nAfterBackoff: String(afterBackoff.length),
2464
+ nMerged: String(deviceListRaw.length),
2465
+ nSorted: String(deviceList.length),
2466
+ ourDevice: this.getDevice().deviceID,
2467
+ });
2468
+ for (const [i, d] of deviceList.entries()) {
2469
+ debugLibvexDm(`sendMessage: device[${String(i)}]`, {
2470
+ deviceID: d.deviceID,
2471
+ });
2472
+ }
2005
2473
  }
2006
- void Promise.allSettled(promises).then((results) => {
2007
- for (const result of results) {
2008
- const { status } = result;
2009
- if (status === "rejected") {
2474
+ let lastErr;
2475
+ let failCount = 0;
2476
+ for (const device of deviceList) {
2477
+ const mailID = uuid.v4();
2478
+ try {
2479
+ if (libvexDebugDmEnabled()) {
2480
+ debugLibvexDm("sendMessage: sendMail start", {
2481
+ recipientDevice: device.deviceID,
2482
+ mailID,
2483
+ });
2484
+ }
2485
+ await this.sendMail(device, userEntry, XUtils.decodeUTF8(message), null, mailID, false);
2486
+ if (libvexDebugDmEnabled()) {
2487
+ debugLibvexDm("sendMessage: sendMail ok", {
2488
+ recipientDevice: device.deviceID,
2489
+ });
2010
2490
  }
2011
2491
  }
2012
- });
2492
+ catch (e) {
2493
+ if (libvexDebugDmEnabled()) {
2494
+ // eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
2495
+ console.error("[libvex:debug-dm] sendMessage: sendMail failed for device", device.deviceID, e);
2496
+ }
2497
+ lastErr = e;
2498
+ failCount += 1;
2499
+ }
2500
+ }
2501
+ if (failCount > 0) {
2502
+ const base = lastErr instanceof Error
2503
+ ? lastErr
2504
+ : new Error(String(lastErr));
2505
+ if (failCount === deviceList.length) {
2506
+ throw base;
2507
+ }
2508
+ // Multi-device: do not “succeed” when only one device of several got mail —
2509
+ // callers and tests have no per-device result and the other copy times out.
2510
+ const partial = new Error(`Direct message failed to reach ${String(failCount)} of ` +
2511
+ `${String(deviceList.length)} peer device(s) (X3DH/post).`);
2512
+ partial.cause = base;
2513
+ throw partial;
2514
+ }
2013
2515
  }
2014
2516
  catch (err) {
2015
2517
  throw err;
@@ -2032,7 +2534,7 @@ export class Client {
2032
2534
  async submitOTK(amount) {
2033
2535
  const otks = [];
2034
2536
  for (let i = 0; i < amount; i++) {
2035
- otks[i] = this.createPreKey();
2537
+ otks.push(await this.createPreKey());
2036
2538
  }
2037
2539
  const savedKeys = await this.database.savePreKeys(otks, true);
2038
2540
  await this.http.post(this.getHost() + "/device/" + this.getDevice().deviceID + "/otk", msgpack.encode(savedKeys.map((key) => this.censorPreKey(key))), {