@vex-chat/libvex 5.2.0 → 5.3.1

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 +39 -3
  6. package/dist/Client.d.ts.map +1 -1
  7. package/dist/Client.js +961 -480
  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 +14 -4
  110. package/src/Client.ts +1239 -619
  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.
@@ -319,6 +419,11 @@ export class Client {
319
419
  isAlive = true;
320
420
  mailInterval;
321
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;
322
427
  /* Retrieves the userID with the user identifier.
323
428
  user identifier is checked for userID, then signkey,
324
429
  and finally falls back to username. */
@@ -337,9 +442,12 @@ export class Client {
337
442
  user;
338
443
  userRecords = {};
339
444
  xKeyRing;
340
- constructor(privateKey, options, storage) {
341
- // (no super — composition, not inheritance)
445
+ cryptoProfile;
446
+ constructor(material, options, storage) {
342
447
  this.options = options;
448
+ this.cryptoProfile = material.cryptoProfile;
449
+ this.signKeys = material.signKeys;
450
+ this.idKeys = material.idKeys;
343
451
  if (options?.unsafeHttp) {
344
452
  const env = Client.getNodeEnv();
345
453
  if (env !== "development" && env !== "test") {
@@ -351,13 +459,6 @@ export class Client {
351
459
  else {
352
460
  this.prefixes = { HTTP: "https://", WS: "wss://" };
353
461
  }
354
- this.signKeys = privateKey
355
- ? xSignKeyPairFromSecret(XUtils.decodeHex(privateKey))
356
- : xSignKeyPair();
357
- this.idKeys = XKeyConvert.convertKeyPair(this.signKeys);
358
- if (!this.idKeys) {
359
- throw new Error("Could not convert key to X25519!");
360
- }
361
462
  this.host = options?.host || "api.vex.wtf";
362
463
  const dbFileName = options?.inMemoryDb
363
464
  ? ":memory:"
@@ -396,29 +497,73 @@ export class Client {
396
497
  * ```
397
498
  */
398
499
  static create = async (privateKey, options, storage) => {
399
- const opts = options;
400
- 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);
401
530
  let resolvedStorage = storage;
402
531
  if (!resolvedStorage) {
403
532
  const { createNodeStorage } = await import("./storage/node.js");
404
- const dbFileName = opts?.inMemoryDb
533
+ const dbFileName = options?.inMemoryDb
405
534
  ? ":memory:"
406
- : XUtils.encodeHex(xSignKeyPairFromSecret(XUtils.decodeHex(sk)).publicKey) + ".sqlite";
407
- const dbPath = opts?.dbFolder
408
- ? opts.dbFolder + "/" + dbFileName
535
+ : XUtils.encodeHex(signKeys.publicKey) + ".sqlite";
536
+ const dbPath = options?.dbFolder
537
+ ? options.dbFolder + "/" + dbFileName
409
538
  : dbFileName;
410
- resolvedStorage = createNodeStorage(dbPath, sk);
411
- }
412
- 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);
413
547
  await client.init();
414
548
  return client;
415
549
  };
416
550
  /**
417
- * Generates an ed25519 secret key as a hex string.
418
- *
419
- * @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).
420
553
  */
421
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
+ }
422
567
  return XUtils.encodeHex(xSignKeyPair().secretKey);
423
568
  }
424
569
  /**
@@ -436,17 +581,23 @@ export class Client {
436
581
  }
437
582
  static deserializeExtra(type, extra) {
438
583
  switch (type) {
439
- case MailType.initial:
440
- /* 32 bytes for signkey, 32 bytes for ephemeral key,
441
- 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) */
442
590
  const signKey = extra.slice(0, 32);
443
591
  const ephKey = extra.slice(32, 64);
444
592
  const ad = extra.slice(96, 164);
445
593
  const index = extra.slice(164, 170);
446
594
  return [signKey, ephKey, ad, index];
595
+ }
447
596
  case MailType.subsequent:
448
- const publicKey = extra;
449
- return [publicKey];
597
+ if (isFipsSubsequentExtraV1(extra)) {
598
+ return [decodeFipsSubsequentExtraV1(extra)];
599
+ }
600
+ return [extra];
450
601
  default:
451
602
  return [];
452
603
  }
@@ -562,8 +713,8 @@ export class Client {
562
713
  if (!connectToken) {
563
714
  throw new Error("Couldn't get connect token.");
564
715
  }
565
- const signed = xSign(Uint8Array.from(uuid.parse(connectToken.key)), this.signKeys.secretKey);
566
- 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" } });
567
718
  const { deviceToken } = decodeAxios(ConnectResponseCodec, res.data);
568
719
  this.http.defaults.headers.common["X-Device-Token"] = deviceToken;
569
720
  this.initSocket();
@@ -572,6 +723,50 @@ export class Client {
572
723
  await new Promise((r) => setTimeout(r, 0));
573
724
  await this.negotiateOTK();
574
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
+ }
575
770
  /**
576
771
  * Delete all local data — message history, encryption sessions, and prekeys.
577
772
  * Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
@@ -629,6 +824,12 @@ export class Client {
629
824
  return { ok: true };
630
825
  }
631
826
  catch (err) {
827
+ if (isAxiosError(err) && err.response) {
828
+ return {
829
+ error: spireErrorBodyMessage(err.response.data),
830
+ ok: false,
831
+ };
832
+ }
632
833
  const error = err instanceof Error ? err.message : String(err);
633
834
  return { error, ok: false };
634
835
  }
@@ -653,7 +854,7 @@ export class Client {
653
854
  signKey: signKeyHex,
654
855
  }), { headers: { "Content-Type": "application/msgpack" } });
655
856
  const { challenge, challengeID } = decodeAxios(DeviceChallengeCodec, challengeRes.data);
656
- const signed = XUtils.encodeHex(xSign(XUtils.decodeHex(challenge), this.signKeys.secretKey));
857
+ const signed = XUtils.encodeHex(await xSignAsync(XUtils.decodeHex(challenge), this.signKeys.secretKey));
657
858
  const verifyRes = await this.http.post(this.getHost() + "/auth/device/verify", msgpack.encode({ challengeID, signed }), { headers: { "Content-Type": "application/msgpack" } });
658
859
  const { token, user } = decodeAxios(AuthResponseCodec, verifyRes.data);
659
860
  this.setUser(user);
@@ -710,7 +911,7 @@ export class Client {
710
911
  const regKey = await this.getToken("register");
711
912
  if (regKey) {
712
913
  const signKey = XUtils.encodeHex(this.signKeys.publicKey);
713
- 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));
714
915
  const preKeyIndex = this.xKeyRing.preKeys.index;
715
916
  const regMsg = {
716
917
  deviceName: this.options?.deviceName ?? "unknown",
@@ -729,11 +930,10 @@ export class Client {
729
930
  }
730
931
  catch (err) {
731
932
  if (isAxiosError(err) && err.response) {
732
- const raw = err.response.data;
733
- const msg = raw instanceof ArrayBuffer || raw instanceof Uint8Array
734
- ? new TextDecoder().decode(raw)
735
- : String(raw);
736
- return [null, new Error(msg)];
933
+ return [
934
+ null,
935
+ new Error(spireErrorBodyMessage(err.response.data)),
936
+ ];
737
937
  }
738
938
  return [
739
939
  null,
@@ -792,41 +992,45 @@ export class Client {
792
992
  }
793
993
  // returns the file details and the encryption key
794
994
  async createFile(file) {
795
- const nonce = xMakeNonce();
796
- const key = xBoxKeyPair();
797
- const box = xSecretbox(Uint8Array.from(file), nonce, key.secretKey);
798
- if (typeof FormData !== "undefined") {
799
- const fpayload = new FormData();
800
- fpayload.set("owner", this.getDevice().deviceID);
801
- fpayload.set("nonce", XUtils.encodeHex(nonce));
802
- fpayload.set("file", new Blob([new Uint8Array(box)]));
803
- const fres = await this.http.post(this.getHost() + "/file", fpayload, {
804
- headers: { "Content-Type": "multipart/form-data" },
805
- onUploadProgress: (progressEvent) => {
806
- const percentCompleted = Math.round((progressEvent.loaded * 100) /
807
- (progressEvent.total ?? 1));
808
- const { loaded, total = 0 } = progressEvent;
809
- const progress = {
810
- direction: "upload",
811
- loaded,
812
- progress: percentCompleted,
813
- token: XUtils.encodeHex(nonce),
814
- total,
815
- };
816
- this.emitter.emit("fileProgress", progress);
817
- },
818
- });
819
- const fcreatedFile = decodeAxios(FileSQLCodec, fres.data);
820
- return [fcreatedFile, XUtils.encodeHex(key.secretKey)];
821
- }
822
- const payload = {
823
- file: XUtils.encodeBase64(box),
824
- nonce: XUtils.encodeHex(nonce),
825
- owner: this.getDevice().deviceID,
826
- };
827
- const res = await this.http.post(this.getHost() + "/file/json", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
828
- const createdFile = decodeAxios(FileSQLCodec, res.data);
829
- 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
+ });
830
1034
  }
831
1035
  async createInvite(serverID, duration) {
832
1036
  const payload = {
@@ -836,145 +1040,190 @@ export class Client {
836
1040
  const res = await this.http.post(this.getHost() + "/server/" + serverID + "/invites", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
837
1041
  return decodeAxios(InviteCodec, res.data);
838
1042
  }
839
- createPreKey() {
840
- 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);
841
1048
  return {
842
1049
  keyPair: preKeyPair,
843
- signature: xSign(xEncode(xConstants.CURVE, preKeyPair.publicKey), this.signKeys.secretKey),
1050
+ signature: await xSignAsync(toSign, this.signKeys.secretKey),
844
1051
  };
845
1052
  }
846
1053
  async createServer(name) {
847
1054
  const res = await this.http.post(this.getHost() + "/server/" + globalThis.btoa(name));
848
1055
  return decodeAxios(ServerCodec, res.data);
849
1056
  }
850
- async createSession(device, user, message, group,
851
- /* this is passed through if the first message is
852
- part of a group message */
853
- mailID, forward) {
854
- 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);
855
1069
  try {
856
- keyBundle = await this.retrieveKeyBundle(device.deviceID);
1070
+ return await fn();
857
1071
  }
858
- catch {
859
- return;
1072
+ finally {
1073
+ setCryptoProfile(prev);
860
1074
  }
861
- if (!this.xKeyRing) {
862
- if (this.manuallyClosing) {
863
- 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);
864
1089
  }
865
- throw new Error("Key ring not initialized.");
866
- }
867
- // my keys
868
- const IK_A = this.xKeyRing.identityKeys.secretKey;
869
- const IK_AP = this.xKeyRing.identityKeys.publicKey;
870
- const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
871
- // their keys
872
- const IK_B_raw = XKeyConvert.convertPublicKey(new Uint8Array(keyBundle.signKey));
873
- if (!IK_B_raw) {
874
- throw new Error("Could not convert sign key to X25519.");
875
- }
876
- const IK_B = IK_B_raw;
877
- const SPK_B = new Uint8Array(keyBundle.preKey.publicKey);
878
- const OPK_B = keyBundle.otk
879
- ? new Uint8Array(keyBundle.otk.publicKey)
880
- : null;
881
- // diffie hellman functions
882
- const DH1 = xDH(new Uint8Array(IK_A), SPK_B);
883
- const DH2 = xDH(new Uint8Array(EK_A), IK_B);
884
- const DH3 = xDH(new Uint8Array(EK_A), SPK_B);
885
- const DH4 = OPK_B ? xDH(new Uint8Array(EK_A), OPK_B) : null;
886
- // initial key material
887
- const IKM = DH4 ? xConcat(DH1, DH2, DH3, DH4) : xConcat(DH1, DH2, DH3);
888
- // one time key index
889
- const IDX = keyBundle.otk
890
- ? XUtils.numberToUint8Arr(keyBundle.otk.index ?? 0)
891
- : XUtils.numberToUint8Arr(0);
892
- // shared secret key
893
- const SK = xKDF(IKM);
894
- const PK = xBoxKeyPairFromSecret(SK).publicKey;
895
- const AD = xConcat(xEncode(xConstants.CURVE, IK_AP), xEncode(xConstants.CURVE, IK_B));
896
- const nonce = xMakeNonce();
897
- const cipher = xSecretbox(message, nonce, SK);
898
- /* 32 bytes for signkey, 32 bytes for ephemeral key,
899
- 68 bytes for AD, 6 bytes for otk index (empty for no otk) */
900
- const extra = xConcat(this.signKeys.publicKey, this.xKeyRing.ephemeralKeys.publicKey, PK, AD, IDX);
901
- const mail = {
902
- authorID: this.getUser().userID,
903
- cipher,
904
- extra,
905
- forward,
906
- group,
907
- mailID: mailID || uuid.v4(),
908
- mailType: MailType.initial,
909
- nonce,
910
- readerID: user.userID,
911
- recipient: device.deviceID,
912
- sender: this.getDevice().deviceID,
913
- };
914
- const hmac = xHMAC(mail, SK);
915
- const msg = {
916
- action: "CREATE",
917
- data: mail,
918
- resourceType: "mail",
919
- transmissionID: uuid.v4(),
920
- type: "resource",
921
- };
922
- // discard the ephemeral keys
923
- this.newEphemeralKeys();
924
- const sessionEntry = {
925
- deviceID: device.deviceID,
926
- fingerprint: XUtils.encodeHex(AD),
927
- lastUsed: new Date().toISOString(),
928
- mode: "initiator",
929
- publicKey: XUtils.encodeHex(PK),
930
- sessionID: uuid.v4(),
931
- SK: XUtils.encodeHex(SK),
932
- userID: user.userID,
933
- verified: false,
934
- };
935
- await this.database.saveSession(sessionEntry);
936
- this.emitter.emit("session", sessionEntry, user);
937
- // emit the message
938
- const forwardedMsg = forward
939
- ? messageSchema.parse(msgpack.decode(message))
940
- : null;
941
- const emitMsg = forwardedMsg
942
- ? { ...forwardedMsg, forward: true }
943
- : {
944
- authorID: mail.authorID,
945
- decrypted: true,
946
- direction: "outgoing",
947
- forward: mail.forward,
948
- group: mail.group ? uuid.stringify(mail.group) : null,
949
- mailID: mail.mailID,
950
- message: XUtils.encodeUTF8(message),
951
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
952
- readerID: mail.readerID,
953
- recipient: mail.recipient,
954
- sender: mail.sender,
955
- timestamp: new Date().toISOString(),
956
- };
957
- this.emitter.emit("message", emitMsg);
958
- // send mail and wait for response
959
- await new Promise((res, rej) => {
960
- const callback = (packedMsg) => {
961
- const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
962
- if (receivedMsg.transmissionID === msg.transmissionID) {
963
- this.socket.off("message", callback);
964
- const parsed = WSMessageSchema.safeParse(receivedMsg);
965
- if (parsed.success && parsed.data.type === "success") {
966
- res(parsed.data.data);
967
- }
968
- else {
969
- rej(new Error("Mail delivery failed: " +
970
- JSON.stringify(receivedMsg)));
971
- }
1090
+ catch (e) {
1091
+ if (allowKeyBundleFailure) {
1092
+ return;
972
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;
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",
973
1170
  };
974
- this.socket.on("message", callback);
975
- 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
+ });
976
1226
  });
977
- this.sending.delete(device.deviceID);
978
1227
  }
979
1228
  async deleteChannel(channelID) {
980
1229
  await this.http.delete(this.getHost() + "/channel/" + channelID);
@@ -1056,19 +1305,17 @@ export class Client {
1056
1305
  }
1057
1306
  const msgBytes = Uint8Array.from(msgpack.encode(copy));
1058
1307
  const devices = await this.fetchUserDeviceListWithBackoff(this.getUser().userID, "own");
1059
- const promises = [];
1060
1308
  for (const device of devices) {
1061
- if (device.deviceID !== this.getDevice().deviceID) {
1062
- promises.push(this.sendMail(device, this.getUser(), msgBytes, null, copy.mailID, true));
1309
+ if (device.deviceID === this.getDevice().deviceID) {
1310
+ continue;
1063
1311
  }
1064
- }
1065
- void Promise.allSettled(promises).then((results) => {
1066
- for (const result of results) {
1067
- const { status } = result;
1068
- if (status === "rejected") {
1069
- }
1312
+ try {
1313
+ await this.sendMail(device, this.getUser(), msgBytes, null, copy.mailID, true);
1070
1314
  }
1071
- });
1315
+ catch {
1316
+ /* best-effort per device; parallel handshakes share ephemeral state */
1317
+ }
1318
+ }
1072
1319
  }
1073
1320
  async getChannelByID(channelID) {
1074
1321
  try {
@@ -1151,18 +1398,45 @@ export class Client {
1151
1398
  .array(mailInboxEntry)
1152
1399
  .parse(msgpack.decode(mailBuffer));
1153
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
+ }
1154
1415
  for (const mailDetails of inbox) {
1155
1416
  const [mailHeader, mailBody, timestamp] = mailDetails;
1156
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
+ }
1157
1425
  await this.readMail(mailHeader, mailBody, timestamp);
1158
1426
  }
1159
- catch (_readMailErr) {
1160
- // 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
+ }
1161
1432
  }
1162
1433
  }
1163
1434
  }
1164
- catch (_fetchErr) {
1165
- // 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
+ }
1166
1440
  }
1167
1441
  this.fetchingMail = false;
1168
1442
  }
@@ -1411,7 +1685,7 @@ export class Client {
1411
1685
  const msg = parseResult.data;
1412
1686
  switch (msg.type) {
1413
1687
  case "challenge":
1414
- this.respond(msg);
1688
+ void this.respond(msg);
1415
1689
  break;
1416
1690
  case "error":
1417
1691
  break;
@@ -1470,14 +1744,14 @@ export class Client {
1470
1744
  }
1471
1745
  await this.submitOTK(needs);
1472
1746
  }
1473
- newEphemeralKeys() {
1747
+ async newEphemeralKeys() {
1474
1748
  if (!this.xKeyRing) {
1475
1749
  if (this.manuallyClosing) {
1476
1750
  return;
1477
1751
  }
1478
1752
  throw new Error("Key ring not initialized.");
1479
1753
  }
1480
- this.xKeyRing.ephemeralKeys = xBoxKeyPair();
1754
+ this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
1481
1755
  }
1482
1756
  ping() {
1483
1757
  if (!this.isAlive) {
@@ -1497,7 +1771,7 @@ export class Client {
1497
1771
  const existingPreKeys = await this.database.getPreKeys();
1498
1772
  const preKeys = existingPreKeys ??
1499
1773
  (await (async () => {
1500
- const unsaved = this.createPreKey();
1774
+ const unsaved = await this.createPreKey();
1501
1775
  const [saved] = await this.database.savePreKeys([unsaved], false);
1502
1776
  if (!saved || saved.index == null)
1503
1777
  throw new Error("Failed to save prekey — no index returned.");
@@ -1508,7 +1782,7 @@ export class Client {
1508
1782
  this.sessionRecords[session.publicKey] =
1509
1783
  sqlSessionToCrypto(session);
1510
1784
  }
1511
- const ephemeralKeys = xBoxKeyPair();
1785
+ const ephemeralKeys = await xBoxKeyPairAsync();
1512
1786
  this.xKeyRing = {
1513
1787
  ephemeralKeys,
1514
1788
  identityKeys,
@@ -1516,11 +1790,15 @@ export class Client {
1516
1790
  };
1517
1791
  }
1518
1792
  async postAuth() {
1793
+ const versionAtStart = this.postAuthVersion;
1519
1794
  let count = 0;
1520
1795
  for (;;) {
1521
1796
  if (this.isManualCloseInFlight()) {
1522
1797
  return;
1523
1798
  }
1799
+ if (this.postAuthVersion !== versionAtStart) {
1800
+ return;
1801
+ }
1524
1802
  try {
1525
1803
  await this.getMail();
1526
1804
  count++;
@@ -1534,12 +1812,18 @@ export class Client {
1534
1812
  if (this.isManualCloseInFlight()) {
1535
1813
  return;
1536
1814
  }
1815
+ if (this.postAuthVersion !== versionAtStart) {
1816
+ return;
1817
+ }
1537
1818
  // Chunk the idle delay so `close()` can unwind instead of waiting
1538
1819
  // out one full 60s timer (which would keep the process alive).
1539
1820
  for (let i = 0; i < 60; i++) {
1540
1821
  if (this.isManualCloseInFlight()) {
1541
1822
  return;
1542
1823
  }
1824
+ if (this.postAuthVersion !== versionAtStart) {
1825
+ return;
1826
+ }
1543
1827
  await sleep(1000);
1544
1828
  }
1545
1829
  }
@@ -1549,10 +1833,28 @@ export class Client {
1549
1833
  }
1550
1834
  async readMail(header, mail, timestamp) {
1551
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
+ }
1552
1849
  return;
1553
1850
  }
1554
1851
  this.seenMailIDs.add(mail.mailID);
1555
1852
  if (this.manuallyClosing) {
1853
+ if (libvexDebugDmEnabled()) {
1854
+ debugLibvexDm("readMail: skip (manually closing)", {
1855
+ mailID: mail.mailID,
1856
+ });
1857
+ }
1556
1858
  return;
1557
1859
  }
1558
1860
  this.sendReceipt(new Uint8Array(mail.nonce));
@@ -1563,207 +1865,297 @@ export class Client {
1563
1865
  }
1564
1866
  this.reading = true;
1565
1867
  try {
1566
- const healSession = async () => {
1567
- if (this.manuallyClosing || !this.xKeyRing) {
1568
- return;
1569
- }
1570
- const deviceEntry = await this.getDeviceByID(mail.sender);
1571
- const [user, _err] = await this.fetchUser(mail.authorID);
1572
- if (deviceEntry && user) {
1573
- void this.createSession(deviceEntry, user, XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`), mail.group, uuid.v4(), false);
1574
- }
1575
- };
1576
- switch (mail.mailType) {
1577
- case MailType.initial:
1578
- const extraParts = Client.deserializeExtra(MailType.initial, new Uint8Array(mail.extra));
1579
- const signKey = extraParts[0];
1580
- const ephKey = extraParts[1];
1581
- const indexBytes = extraParts[3];
1582
- if (!signKey || !ephKey || !indexBytes) {
1583
- throw new Error("Malformed initial mail extra: missing signKey, ephKey, or indexBytes");
1584
- }
1585
- const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
1586
- const otk = preKeyIndex === 0
1587
- ? null
1588
- : await this.database.getOneTimeKey(preKeyIndex);
1589
- if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
1590
- return;
1591
- }
1592
- // their public keys
1593
- const IK_A_raw = XKeyConvert.convertPublicKey(signKey);
1594
- if (!IK_A_raw) {
1868
+ await this.runWithThisCryptoProfile(async () => {
1869
+ const healSession = async () => {
1870
+ if (this.manuallyClosing || !this.xKeyRing) {
1595
1871
  return;
1596
1872
  }
1597
- const IK_A = IK_A_raw;
1598
- const EK_A = ephKey;
1599
- if (!this.xKeyRing) {
1600
- 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);
1601
1877
  }
1602
- // my private keys
1603
- const IK_B = this.xKeyRing.identityKeys.secretKey;
1604
- const IK_BP = this.xKeyRing.identityKeys.publicKey;
1605
- const SPK_B = this.xKeyRing.preKeys.keyPair.secretKey;
1606
- const OPK_B = otk ? otk.keyPair.secretKey : null;
1607
- // diffie hellman functions
1608
- const DH1 = xDH(SPK_B, IK_A);
1609
- const DH2 = xDH(IK_B, EK_A);
1610
- const DH3 = xDH(SPK_B, EK_A);
1611
- const DH4 = OPK_B ? xDH(OPK_B, EK_A) : null;
1612
- // initial key material
1613
- const IKM = DH4
1614
- ? xConcat(DH1, DH2, DH3, DH4)
1615
- : xConcat(DH1, DH2, DH3);
1616
- // shared secret key
1617
- const SK = xKDF(IKM);
1618
- const PK = xBoxKeyPairFromSecret(SK).publicKey;
1619
- const hmac = xHMAC(mail, SK);
1620
- // associated data
1621
- const AD = xConcat(xEncode(xConstants.CURVE, IK_A), xEncode(xConstants.CURVE, IK_BP));
1622
- if (!XUtils.bytesEqual(hmac, header)) {
1623
- return;
1624
- }
1625
- const unsealed = xSecretboxOpen(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), SK);
1626
- if (unsealed) {
1627
- let plaintext = "";
1628
- if (!mail.forward) {
1629
- 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");
1630
1887
  }
1631
- // emit the message
1632
- const fwdMsg1 = mail.forward
1633
- ? messageSchema.parse(msgpack.decode(unsealed))
1634
- : null;
1635
- const message = fwdMsg1
1636
- ? { ...fwdMsg1, forward: true }
1637
- : {
1638
- authorID: mail.authorID,
1639
- decrypted: true,
1640
- direction: "incoming",
1641
- forward: mail.forward,
1642
- group: mail.group
1643
- ? uuid.stringify(mail.group)
1644
- : null,
1645
- mailID: mail.mailID,
1646
- message: plaintext,
1647
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1648
- readerID: mail.readerID,
1649
- recipient: mail.recipient,
1650
- sender: mail.sender,
1651
- timestamp: timestamp,
1652
- };
1653
- this.emitter.emit("message", message);
1654
- // discard onetimekey
1655
- await this.database.deleteOneTimeKey(preKeyIndex);
1656
- const deviceEntry = await this.getDeviceByID(mail.sender);
1657
- if (!deviceEntry) {
1658
- 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;
1659
1909
  }
1660
- const [userEntry, _userErr] = await this.fetchUser(deviceEntry.owner);
1661
- if (!userEntry) {
1662
- 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;
1663
1937
  }
1664
- this.userRecords[userEntry.userID] = userEntry;
1665
- this.deviceRecords[deviceEntry.deviceID] = deviceEntry;
1666
- // save session
1667
- const newSession = {
1668
- deviceID: mail.sender,
1669
- fingerprint: XUtils.encodeHex(AD),
1670
- lastUsed: new Date().toISOString(),
1671
- mode: "receiver",
1672
- publicKey: XUtils.encodeHex(PK),
1673
- sessionID: uuid.v4(),
1674
- SK: XUtils.encodeHex(SK),
1675
- userID: userEntry.userID,
1676
- verified: false,
1677
- };
1678
- await this.database.saveSession(newSession);
1679
- const [user] = await this.fetchUser(newSession.userID);
1680
- if (user) {
1681
- 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
+ }
1682
2066
  }
1683
2067
  else {
2068
+ if (libvexDebugDmEnabled()) {
2069
+ debugLibvexDm("readMail initial: abort (xSecretboxOpen null)", {
2070
+ mailID: mail.mailID,
2071
+ preKeyIndex: String(preKeyIndex),
2072
+ });
2073
+ }
1684
2074
  }
1685
- }
1686
- else {
1687
- }
1688
- break;
1689
- case MailType.subsequent:
1690
- const publicKey = Client.deserializeExtra(mail.mailType, new Uint8Array(mail.extra))[0];
1691
- if (!publicKey) {
1692
- throw new Error("Malformed subsequent mail extra: missing publicKey");
1693
- }
1694
- let session = await this.getSessionByPubkey(publicKey);
1695
- let retries = 0;
1696
- while (!session) {
1697
- if (retries >= 3) {
1698
- 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");
1699
2083
  }
1700
- await sleep(100 * 2 ** retries);
1701
- retries++;
1702
- session = await this.getSessionByPubkey(publicKey);
1703
- }
1704
- if (!session) {
1705
- void healSession();
1706
- return;
1707
- }
1708
- const HMAC = xHMAC(mail, session.SK);
1709
- if (!XUtils.bytesEqual(HMAC, header)) {
1710
- void healSession();
1711
- return;
1712
- }
1713
- const decrypted = xSecretboxOpen(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), session.SK);
1714
- if (decrypted) {
1715
- const fwdMsg2 = mail.forward
1716
- ? messageSchema.parse(msgpack.decode(decrypted))
1717
- : null;
1718
- const message = fwdMsg2
1719
- ? {
1720
- ...fwdMsg2,
1721
- forward: true,
2084
+ let session = await this.getSessionByPubkey(publicKey);
2085
+ let retries = 0;
2086
+ while (!session) {
2087
+ if (retries >= 3) {
2088
+ break;
1722
2089
  }
1723
- : {
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 = {
1724
2136
  authorID: mail.authorID,
1725
- decrypted: true,
2137
+ decrypted: false,
1726
2138
  direction: "incoming",
1727
2139
  forward: mail.forward,
1728
2140
  group: mail.group
1729
2141
  ? uuid.stringify(mail.group)
1730
2142
  : null,
1731
2143
  mailID: mail.mailID,
1732
- message: XUtils.encodeUTF8(decrypted),
2144
+ message: "",
1733
2145
  nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1734
2146
  readerID: mail.readerID,
1735
2147
  recipient: mail.recipient,
1736
2148
  sender: mail.sender,
1737
2149
  timestamp: timestamp,
1738
2150
  };
1739
- this.emitter.emit("message", message);
1740
- void this.database.markSessionUsed(session.sessionID);
1741
- }
1742
- else {
1743
- void healSession();
1744
- // emit the message
1745
- const message = {
1746
- authorID: mail.authorID,
1747
- decrypted: false,
1748
- direction: "incoming",
1749
- forward: mail.forward,
1750
- group: mail.group
1751
- ? uuid.stringify(mail.group)
1752
- : null,
1753
- mailID: mail.mailID,
1754
- message: "",
1755
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1756
- readerID: mail.readerID,
1757
- recipient: mail.recipient,
1758
- sender: mail.sender,
1759
- timestamp: timestamp,
1760
- };
1761
- this.emitter.emit("message", message);
2151
+ this.emitter.emit("message", message);
2152
+ }
2153
+ break;
1762
2154
  }
1763
- break;
1764
- default:
1765
- break;
1766
- }
2155
+ default:
2156
+ break;
2157
+ }
2158
+ });
1767
2159
  }
1768
2160
  finally {
1769
2161
  this.reading = false;
@@ -1792,8 +2184,11 @@ export class Client {
1792
2184
  if (!token) {
1793
2185
  throw new Error("Couldn't fetch token.");
1794
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`.
1795
2190
  const signKey = this.getKeys().public;
1796
- 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));
1797
2192
  const devPreKeyIndex = this.xKeyRing.preKeys.index;
1798
2193
  const devMsg = {
1799
2194
  deviceName: this.options?.deviceName ?? "unknown",
@@ -1811,9 +2206,9 @@ export class Client {
1811
2206
  "/devices", msgpack.encode(devMsg), { headers: { "Content-Type": "application/msgpack" } });
1812
2207
  return decodeAxios(DeviceCodec, res.data);
1813
2208
  }
1814
- respond(msg) {
2209
+ async respond(msg) {
1815
2210
  const response = {
1816
- signed: xSign(new Uint8Array(msg.challenge), this.signKeys.secretKey),
2211
+ signed: await xSignAsync(new Uint8Array(msg.challenge), this.signKeys.secretKey),
1817
2212
  transmissionID: msg.transmissionID,
1818
2213
  type: "response",
1819
2214
  };
@@ -1849,7 +2244,7 @@ export class Client {
1849
2244
  },
1850
2245
  });
1851
2246
  const fileData = res.data;
1852
- 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));
1853
2248
  if (decrypted) {
1854
2249
  return {
1855
2250
  data: new Uint8Array(decrypted),
@@ -1916,7 +2311,6 @@ export class Client {
1916
2311
  this.userRecords[user.userID] = user;
1917
2312
  }
1918
2313
  const mailID = uuid.v4();
1919
- const promises = [];
1920
2314
  const userIDs = [...new Set(userList.map((user) => user.userID))];
1921
2315
  const devices = await this.getMultiUserDeviceList(userIDs);
1922
2316
  for (const device of devices) {
@@ -1924,15 +2318,13 @@ export class Client {
1924
2318
  if (!ownerRecord) {
1925
2319
  continue;
1926
2320
  }
1927
- promises.push(this.sendMail(device, ownerRecord, XUtils.decodeUTF8(message), uuidToUint8(channelID), mailID, false));
1928
- }
1929
- void Promise.allSettled(promises).then((results) => {
1930
- for (const result of results) {
1931
- const { status } = result;
1932
- if (status === "rejected") {
1933
- }
2321
+ try {
2322
+ await this.sendMail(device, ownerRecord, XUtils.decodeUTF8(message), uuidToUint8(channelID), mailID, false);
1934
2323
  }
1935
- });
2324
+ catch {
2325
+ /* best-effort; each device needs its own X3DH handshake (sequential) */
2326
+ }
2327
+ }
1936
2328
  }
1937
2329
  /* Sends encrypted mail to a user. */
1938
2330
  async sendMail(device, user, msg, group, mailID, forward, retry = false) {
@@ -1940,74 +2332,97 @@ export class Client {
1940
2332
  await sleep(100);
1941
2333
  }
1942
2334
  this.sending.set(device.deviceID, device);
1943
- const session = await this.database.getSessionByDeviceID(device.deviceID);
1944
- if (!session || retry) {
1945
- await this.createSession(device, user, msg, group, mailID, forward);
1946
- return;
1947
- }
1948
- const nonce = xMakeNonce();
1949
- const cipher = xSecretbox(msg, nonce, session.SK);
1950
- const extra = session.publicKey;
1951
- const mail = {
1952
- authorID: this.getUser().userID,
1953
- cipher,
1954
- extra,
1955
- forward,
1956
- group,
1957
- mailID: mailID || uuid.v4(),
1958
- mailType: MailType.subsequent,
1959
- nonce,
1960
- readerID: session.userID,
1961
- recipient: device.deviceID,
1962
- sender: this.getDevice().deviceID,
1963
- };
1964
- const msgb = {
1965
- action: "CREATE",
1966
- data: mail,
1967
- resourceType: "mail",
1968
- transmissionID: uuid.v4(),
1969
- type: "resource",
1970
- };
1971
- const hmac = xHMAC(mail, session.SK);
1972
- const fwdOut = forward
1973
- ? messageSchema.parse(msgpack.decode(msg))
1974
- : null;
1975
- const outMsg = fwdOut
1976
- ? { ...fwdOut, forward: true }
1977
- : {
1978
- authorID: mail.authorID,
1979
- decrypted: true,
1980
- direction: "outgoing",
1981
- forward: mail.forward,
1982
- group: mail.group ? uuid.stringify(mail.group) : null,
1983
- mailID: mail.mailID,
1984
- message: XUtils.encodeUTF8(msg),
1985
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1986
- readerID: mail.readerID,
1987
- recipient: mail.recipient,
1988
- sender: mail.sender,
1989
- timestamp: new Date().toISOString(),
1990
- };
1991
- this.emitter.emit("message", outMsg);
1992
- await new Promise((res, rej) => {
1993
- const callback = (packedMsg) => {
1994
- const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
1995
- if (receivedMsg.transmissionID === msgb.transmissionID) {
1996
- this.socket.off("message", callback);
1997
- const parsed = WSMessageSchema.safeParse(receivedMsg);
1998
- if (parsed.success && parsed.data.type === "success") {
1999
- res(parsed.data.data);
2000
- }
2001
- else {
2002
- rej(new Error("Mail delivery failed: " +
2003
- JSON.stringify(receivedMsg)));
2004
- }
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
+ });
2005
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,
2006
2375
  };
2007
- this.socket.on("message", callback);
2008
- void this.send(msgb, hmac);
2009
- });
2010
- 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
+ }
2011
2426
  }
2012
2427
  async sendMessage(userID, message) {
2013
2428
  try {
@@ -2018,19 +2433,85 @@ export class Client {
2018
2433
  if (!userEntry) {
2019
2434
  throw new Error("Couldn't get user entry.");
2020
2435
  }
2021
- const deviceList = await this.fetchUserDeviceListWithBackoff(userID, "peer");
2022
- const mailID = uuid.v4();
2023
- const promises = [];
2024
- for (const device of deviceList) {
2025
- 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.");
2026
2457
  }
2027
- void Promise.allSettled(promises).then((results) => {
2028
- for (const result of results) {
2029
- const { status } = result;
2030
- if (status === "rejected") {
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
+ }
2473
+ }
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
+ });
2031
2490
  }
2032
2491
  }
2033
- });
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
+ }
2034
2515
  }
2035
2516
  catch (err) {
2036
2517
  throw err;
@@ -2053,7 +2534,7 @@ export class Client {
2053
2534
  async submitOTK(amount) {
2054
2535
  const otks = [];
2055
2536
  for (let i = 0; i < amount; i++) {
2056
- otks[i] = this.createPreKey();
2537
+ otks.push(await this.createPreKey());
2057
2538
  }
2058
2539
  const savedKeys = await this.database.savePreKeys(otks, true);
2059
2540
  await this.http.post(this.getHost() + "/device/" + this.getDevice().deviceID + "/otk", msgpack.encode(savedKeys.map((key) => this.censorPreKey(key))), {