@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/src/Client.ts CHANGED
@@ -1,3 +1,9 @@
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
+
1
7
  import type { Storage } from "./Storage.js";
2
8
  import type { WebSocketLike } from "./transport/types.js";
3
9
  import type {
@@ -34,11 +40,15 @@ import type { ClientMessage } from "@vex-chat/types";
34
40
  import type { AxiosInstance } from "axios";
35
41
 
36
42
  import {
37
- xBoxKeyPair,
38
- xBoxKeyPairFromSecret,
43
+ type CryptoProfile,
44
+ getCryptoProfile,
45
+ setCryptoProfile,
46
+ xBoxKeyPairAsync,
47
+ xBoxKeyPairFromSecretAsync,
39
48
  xConcat,
40
49
  xConstants,
41
- xDH,
50
+ xDHAsync,
51
+ xEcdhKeyPairFromEcdsaKeyPairAsync,
42
52
  xEncode,
43
53
  xHMAC,
44
54
  xKDF,
@@ -46,11 +56,13 @@ import {
46
56
  xMakeNonce,
47
57
  xMnemonic,
48
58
  xRandomBytes,
49
- xSecretbox,
50
- xSecretboxOpen,
51
- xSign,
59
+ xSecretboxAsync,
60
+ xSecretboxOpenAsync,
61
+ xSignAsync,
52
62
  xSignKeyPair,
63
+ xSignKeyPairAsync,
53
64
  xSignKeyPairFromSecret,
65
+ xSignKeyPairFromSecretAsync,
54
66
  XUtils,
55
67
  } from "@vex-chat/crypto";
56
68
  import {
@@ -66,11 +78,119 @@ import * as uuid from "uuid";
66
78
  import { z } from "zod/v4";
67
79
 
68
80
  import { WebSocketAdapter } from "./transport/websocket.js";
81
+ import {
82
+ decodeFipsInitialExtraV1,
83
+ decodeFipsSubsequentExtraV1,
84
+ encodeFipsInitialExtraV1,
85
+ encodeFipsSubsequentExtraV1,
86
+ fipsP256AdFromIdentityPubs,
87
+ fipsP256PreKeySignPayload,
88
+ isFipsInitialExtraV1,
89
+ isFipsSubsequentExtraV1,
90
+ } from "./utils/fipsMailExtra.js";
69
91
 
70
92
  function sleep(ms: number): Promise<void> {
71
93
  return new Promise((resolve) => setTimeout(resolve, ms));
72
94
  }
73
95
 
96
+ function isRecord(x: unknown): x is Record<string, unknown> {
97
+ return typeof x === "object" && x !== null;
98
+ }
99
+
100
+ /**
101
+ * Spire 5+ JSON error bodies use `{ "error": { "message", "requestId"?, "details"? } }`.
102
+ * Responses are `arraybuffer` — decode UTF-8 and parse for a one-line `Error` message
103
+ * (plus requestId) instead of a raw JSON blob.
104
+ */
105
+ function spireErrorBodyMessage(data: unknown, max = 8_000): string {
106
+ let text: string;
107
+ if (data instanceof ArrayBuffer) {
108
+ text = new TextDecoder("utf-8", { fatal: false }).decode(
109
+ new Uint8Array(data),
110
+ );
111
+ } else if (data instanceof Uint8Array) {
112
+ text = new TextDecoder("utf-8", { fatal: false }).decode(data);
113
+ } else {
114
+ return String(data).slice(0, max);
115
+ }
116
+ const t = text.trim();
117
+ if (t.startsWith("{")) {
118
+ try {
119
+ // JSON.parse is typed as any; assign into unknown for safe narrowing.
120
+ const parsed: unknown = JSON.parse(t);
121
+ if (!isRecord(parsed)) {
122
+ return t.length > max ? t.slice(0, max) + "…" : t;
123
+ }
124
+ const errField = parsed["error"];
125
+ if (!isRecord(errField)) {
126
+ return t.length > max ? t.slice(0, max) + "…" : t;
127
+ }
128
+ const message = errField["message"];
129
+ if (typeof message !== "string") {
130
+ return t.length > max ? t.slice(0, max) + "…" : t;
131
+ }
132
+ const parts: string[] = [message];
133
+ const requestId = errField["requestId"];
134
+ if (typeof requestId === "string" && requestId.length > 0) {
135
+ parts.push(`(requestId: ${requestId})`);
136
+ }
137
+ if (errField["details"] !== undefined) {
138
+ let d = JSON.stringify(errField["details"]);
139
+ if (d.length > 500) {
140
+ d = d.slice(0, 500) + "…";
141
+ }
142
+ parts.push(d);
143
+ }
144
+ return parts.join(" ");
145
+ } catch {
146
+ /* fall through to raw */
147
+ }
148
+ }
149
+ return t.length > max ? t.slice(0, max) + "…" : t;
150
+ }
151
+
152
+ /**
153
+ * Set `LIBVEX_DEBUG_DM=1` (e.g. in vitest / shell) to log DM multi-device / X3DH paths.
154
+ * Uses indirect `globalThis` lookup so the bare `process` global never appears in
155
+ * source that the platform-guard plugin scans (browser/RN/Tauri).
156
+ */
157
+ function libvexDebugDmEnabled(): boolean {
158
+ try {
159
+ const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
160
+ if (!g) {
161
+ return false;
162
+ }
163
+ const proc: unknown = typeof g.get === "function" ? g.get() : g.value;
164
+ if (typeof proc !== "object" || proc === null) {
165
+ return false;
166
+ }
167
+ const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
168
+ if (!envDesc) {
169
+ return false;
170
+ }
171
+ const env: unknown =
172
+ typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
173
+ if (typeof env !== "object" || env === null) {
174
+ return false;
175
+ }
176
+ return Reflect.get(env, "LIBVEX_DEBUG_DM") === "1";
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ function debugLibvexDm(
183
+ msg: string,
184
+ data?: Record<string, string | number | boolean | null | undefined>,
185
+ ): void {
186
+ if (!libvexDebugDmEnabled()) {
187
+ return;
188
+ }
189
+ const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
190
+ // eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
191
+ console.error(`[libvex:debug-dm] ${payload}`);
192
+ }
193
+
74
194
  import { msgpack } from "./codec.js";
75
195
  import {
76
196
  ActionTokenCodec,
@@ -148,6 +268,12 @@ export type { Device } from "@vex-chat/types";
148
268
  * ClientOptions are the options you can pass into the client.
149
269
  */
150
270
  export interface ClientOptions {
271
+ /**
272
+ * Select crypto profile from `@vex-chat/crypto` (`setCryptoProfile`):
273
+ * `tweetnacl` (Ed25519 / X25519) or `fips` (P-256 + Web Crypto, separate wire
274
+ * layout). Deployments do not interop across profiles; pick one for all peers and server.
275
+ */
276
+ cryptoProfile?: "fips" | "tweetnacl";
151
277
  /** Folder path where the sqlite file is created. */
152
278
  dbFolder?: string;
153
279
  /** Platform label for device registration (e.g. "ios", "macos", "linux"). */
@@ -565,6 +691,7 @@ export class Client {
565
691
  * Pass-through utility from `@vex-chat/crypto`.
566
692
  */
567
693
  public static decryptKeyData = XUtils.decryptKeyData;
694
+ public static decryptKeyDataAsync = XUtils.decryptKeyDataAsync;
568
695
 
569
696
  /**
570
697
  * Encrypts a secret key with a password.
@@ -572,6 +699,7 @@ export class Client {
572
699
  * Pass-through utility from `@vex-chat/crypto`.
573
700
  */
574
701
  public static encryptKeyData = XUtils.encryptKeyData;
702
+ public static encryptKeyDataAsync = XUtils.encryptKeyDataAsync;
575
703
 
576
704
  private static readonly NOT_FOUND_TTL = 30 * 60 * 1000;
577
705
 
@@ -864,12 +992,19 @@ export class Client {
864
992
  http: { destroy(): void };
865
993
  https: { destroy(): void };
866
994
  };
995
+ /** Cancels in-flight axios work on `close()` so `postAuth`/`getMail` cannot hang forever. */
996
+ private readonly httpAbortController = new AbortController();
867
997
  private readonly http: AxiosInstance;
868
998
  private readonly idKeys: KeyPair | null;
869
999
  private isAlive: boolean = true;
870
1000
  private readonly mailInterval?: NodeJS.Timeout;
871
1001
 
872
1002
  private manuallyClosing: boolean = false;
1003
+ /**
1004
+ * Bumped when the WebSocket is torn down and re-opened so the previous
1005
+ * `postAuth` loop exits instead of overlapping a new one.
1006
+ */
1007
+ private postAuthVersion = 0;
873
1008
  /* Retrieves the userID with the user identifier.
874
1009
  user identifier is checked for userID, then signkey,
875
1010
  and finally falls back to username. */
@@ -896,14 +1031,21 @@ export class Client {
896
1031
  private userRecords: Record<string, User> = {};
897
1032
 
898
1033
  private xKeyRing?: XKeyRing;
1034
+ private readonly cryptoProfile: CryptoProfile;
899
1035
 
900
1036
  private constructor(
901
- privateKey?: string,
1037
+ material: {
1038
+ cryptoProfile: CryptoProfile;
1039
+ idKeys: KeyPair;
1040
+ signKeys: KeyPair;
1041
+ },
902
1042
  options?: ClientOptions,
903
1043
  storage?: Storage,
904
1044
  ) {
905
- // (no super — composition, not inheritance)
906
1045
  this.options = options;
1046
+ this.cryptoProfile = material.cryptoProfile;
1047
+ this.signKeys = material.signKeys;
1048
+ this.idKeys = material.idKeys;
907
1049
 
908
1050
  if (options?.unsafeHttp) {
909
1051
  const env = Client.getNodeEnv();
@@ -918,15 +1060,6 @@ export class Client {
918
1060
  this.prefixes = { HTTP: "https://", WS: "wss://" };
919
1061
  }
920
1062
 
921
- this.signKeys = privateKey
922
- ? xSignKeyPairFromSecret(XUtils.decodeHex(privateKey))
923
- : xSignKeyPair();
924
- this.idKeys = XKeyConvert.convertKeyPair(this.signKeys);
925
-
926
- if (!this.idKeys) {
927
- throw new Error("Could not convert key to X25519!");
928
- }
929
-
930
1063
  this.host = options?.host || "api.vex.wtf";
931
1064
  const dbFileName = options?.inMemoryDb
932
1065
  ? ":memory:"
@@ -946,7 +1079,10 @@ export class Client {
946
1079
  void this.close(true);
947
1080
  });
948
1081
 
949
- this.http = axios.create({ responseType: "arraybuffer" });
1082
+ this.http = axios.create({
1083
+ responseType: "arraybuffer",
1084
+ signal: this.httpAbortController.signal,
1085
+ });
950
1086
  const devKey = options?.devApiKey?.trim();
951
1087
  if (devKey !== undefined && devKey.length > 0) {
952
1088
  this.http.defaults.headers.common["x-dev-api-key"] = devKey;
@@ -972,32 +1108,95 @@ export class Client {
972
1108
  options?: ClientOptions,
973
1109
  storage?: Storage,
974
1110
  ): Promise<Client> => {
975
- const opts = options;
976
- const sk = privateKey ?? XUtils.encodeHex(xSignKeyPair().secretKey);
1111
+ const profile = options?.cryptoProfile ?? "tweetnacl";
1112
+ setCryptoProfile(profile);
1113
+
1114
+ if (
1115
+ profile === "fips" &&
1116
+ typeof globalThis.crypto.subtle !== "object"
1117
+ ) {
1118
+ throw new Error(
1119
+ 'cryptoProfile="fips" requires Web Crypto (globalThis.crypto.subtle).',
1120
+ );
1121
+ }
1122
+
1123
+ let signKeys: KeyPair;
1124
+ if (privateKey) {
1125
+ const d = XUtils.decodeHex(privateKey);
1126
+ signKeys =
1127
+ profile === "tweetnacl"
1128
+ ? xSignKeyPairFromSecret(d)
1129
+ : await xSignKeyPairFromSecretAsync(d);
1130
+ } else {
1131
+ signKeys =
1132
+ profile === "tweetnacl"
1133
+ ? xSignKeyPair()
1134
+ : await xSignKeyPairAsync();
1135
+ }
1136
+
1137
+ const idKeys =
1138
+ profile === "tweetnacl"
1139
+ ? (() => {
1140
+ const c = XKeyConvert.convertKeyPair(signKeys);
1141
+ if (!c) {
1142
+ throw new Error("Could not convert key to X25519!");
1143
+ }
1144
+ return c;
1145
+ })()
1146
+ : await xEcdhKeyPairFromEcdsaKeyPairAsync(signKeys);
1147
+
1148
+ const atRestAes = XUtils.deriveLocalAtRestAesKey(
1149
+ idKeys.secretKey,
1150
+ profile,
1151
+ );
1152
+
977
1153
  let resolvedStorage = storage;
978
1154
  if (!resolvedStorage) {
979
1155
  const { createNodeStorage } = await import("./storage/node.js");
980
- const dbFileName = opts?.inMemoryDb
1156
+ const dbFileName = options?.inMemoryDb
981
1157
  ? ":memory:"
982
- : XUtils.encodeHex(
983
- xSignKeyPairFromSecret(XUtils.decodeHex(sk)).publicKey,
984
- ) + ".sqlite";
985
- const dbPath = opts?.dbFolder
986
- ? opts.dbFolder + "/" + dbFileName
1158
+ : XUtils.encodeHex(signKeys.publicKey) + ".sqlite";
1159
+ const dbPath = options?.dbFolder
1160
+ ? options.dbFolder + "/" + dbFileName
987
1161
  : dbFileName;
988
- resolvedStorage = createNodeStorage(dbPath, sk);
1162
+ resolvedStorage = createNodeStorage(dbPath, atRestAes);
989
1163
  }
990
- const client = new Client(sk, opts, resolvedStorage);
1164
+
1165
+ await resolvedStorage.init();
1166
+
1167
+ const client = new Client(
1168
+ {
1169
+ cryptoProfile: profile,
1170
+ idKeys,
1171
+ signKeys,
1172
+ },
1173
+ options,
1174
+ resolvedStorage,
1175
+ );
991
1176
  await client.init();
992
1177
  return client;
993
1178
  };
994
1179
 
995
1180
  /**
996
- * Generates an ed25519 secret key as a hex string.
997
- *
998
- * @returns A secret key to use for the client. Save it permanently somewhere safe.
1181
+ * Generates a signing secret key as a hex string (tweetnacl: Ed25519; fips: P-256 pkcs8).
1182
+ * In `fips` mode, use `Client.generateSecretKeyAsync()` instead (Web Crypto is async).
999
1183
  */
1000
1184
  public static generateSecretKey(): string {
1185
+ if (getCryptoProfile() === "fips") {
1186
+ throw new Error(
1187
+ 'Use await Client.generateSecretKeyAsync() when the active crypto profile is "fips".',
1188
+ );
1189
+ }
1190
+ return XUtils.encodeHex(xSignKeyPair().secretKey);
1191
+ }
1192
+
1193
+ /**
1194
+ * Async key generation — required for `fips` profile; safe for `tweetnacl` as well.
1195
+ */
1196
+ public static async generateSecretKeyAsync(): Promise<string> {
1197
+ if (getCryptoProfile() === "fips") {
1198
+ return XUtils.encodeHex((await xSignKeyPairAsync()).secretKey);
1199
+ }
1001
1200
  return XUtils.encodeHex(xSignKeyPair().secretKey);
1002
1201
  }
1003
1202
 
@@ -1021,17 +1220,23 @@ export class Client {
1021
1220
  extra: Uint8Array,
1022
1221
  ): Uint8Array[] {
1023
1222
  switch (type) {
1024
- case MailType.initial:
1025
- /* 32 bytes for signkey, 32 bytes for ephemeral key,
1026
- 68 bytes for AD, 6 bytes for otk index (empty for no otk) */
1223
+ case MailType.initial: {
1224
+ if (isFipsInitialExtraV1(extra)) {
1225
+ const [a, b, c, d] = decodeFipsInitialExtraV1(extra);
1226
+ return [a, b, c, d];
1227
+ }
1228
+ /* 32B sign | 32B eph | 32B PK | 68B AD | 6B index (tweetnacl) */
1027
1229
  const signKey = extra.slice(0, 32);
1028
1230
  const ephKey = extra.slice(32, 64);
1029
1231
  const ad = extra.slice(96, 164);
1030
1232
  const index = extra.slice(164, 170);
1031
1233
  return [signKey, ephKey, ad, index];
1234
+ }
1032
1235
  case MailType.subsequent:
1033
- const publicKey = extra;
1034
- return [publicKey];
1236
+ if (isFipsSubsequentExtraV1(extra)) {
1237
+ return [decodeFipsSubsequentExtraV1(extra)];
1238
+ }
1239
+ return [extra];
1035
1240
  default:
1036
1241
  return [];
1037
1242
  }
@@ -1123,6 +1328,7 @@ export class Client {
1123
1328
  */
1124
1329
  public async close(muteEvent = false): Promise<void> {
1125
1330
  this.manuallyClosing = true;
1331
+ this.httpAbortController.abort();
1126
1332
  this.socket.close();
1127
1333
  await this.database.close();
1128
1334
 
@@ -1166,14 +1372,14 @@ export class Client {
1166
1372
  if (!connectToken) {
1167
1373
  throw new Error("Couldn't get connect token.");
1168
1374
  }
1169
- const signed = xSign(
1375
+ const signedAsync = await xSignAsync(
1170
1376
  Uint8Array.from(uuid.parse(connectToken.key)),
1171
1377
  this.signKeys.secretKey,
1172
1378
  );
1173
1379
 
1174
1380
  const res = await this.http.post(
1175
1381
  this.getHost() + "/device/" + this.device.deviceID + "/connect",
1176
- msgpack.encode({ signed }),
1382
+ msgpack.encode({ signed: signedAsync }),
1177
1383
  { headers: { "Content-Type": "application/msgpack" } },
1178
1384
  );
1179
1385
  const { deviceToken } = decodeAxios(ConnectResponseCodec, res.data);
@@ -1186,6 +1392,54 @@ export class Client {
1186
1392
  await this.negotiateOTK();
1187
1393
  }
1188
1394
 
1395
+ /**
1396
+ * Tears down the current WebSocket and opens a new one, keeping the same
1397
+ * session (user + device in storage). Restarts the post-auth mail loop.
1398
+ * Use for long-running processes or e2e where a fresh socket matches a
1399
+ * newly-registered second device.
1400
+ */
1401
+ public async reconnectWebsocket(): Promise<void> {
1402
+ this.postAuthVersion++;
1403
+ if (this.pingInterval) {
1404
+ clearInterval(this.pingInterval);
1405
+ this.pingInterval = null;
1406
+ }
1407
+ this.socket.close();
1408
+ try {
1409
+ await new Promise<void>((resolve, reject) => {
1410
+ const t = setTimeout(() => {
1411
+ this.off("connected", onC);
1412
+ reject(
1413
+ new Error(
1414
+ "reconnectWebsocket: timed out waiting for authorized",
1415
+ ),
1416
+ );
1417
+ }, 15_000);
1418
+ const onC = () => {
1419
+ clearTimeout(t);
1420
+ this.off("connected", onC);
1421
+ resolve();
1422
+ };
1423
+ this.on("connected", onC);
1424
+ try {
1425
+ this.initSocket();
1426
+ } catch (err: unknown) {
1427
+ clearTimeout(t);
1428
+ this.off("connected", onC);
1429
+ const e =
1430
+ err instanceof Error
1431
+ ? err
1432
+ : new Error(String(err), { cause: err });
1433
+ reject(e);
1434
+ }
1435
+ });
1436
+ } catch (e: unknown) {
1437
+ throw e instanceof Error ? e : new Error(String(e), { cause: e });
1438
+ }
1439
+ await new Promise((r) => setTimeout(r, 0));
1440
+ await this.negotiateOTK();
1441
+ }
1442
+
1189
1443
  /**
1190
1444
  * Delete all local data — message history, encryption sessions, and prekeys.
1191
1445
  * Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
@@ -1253,6 +1507,12 @@ export class Client {
1253
1507
  this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
1254
1508
  return { ok: true };
1255
1509
  } catch (err: unknown) {
1510
+ if (isAxiosError(err) && err.response) {
1511
+ return {
1512
+ error: spireErrorBodyMessage(err.response.data),
1513
+ ok: false,
1514
+ };
1515
+ }
1256
1516
  const error = err instanceof Error ? err.message : String(err);
1257
1517
  return { error, ok: false };
1258
1518
  }
@@ -1288,7 +1548,10 @@ export class Client {
1288
1548
  );
1289
1549
 
1290
1550
  const signed = XUtils.encodeHex(
1291
- xSign(XUtils.decodeHex(challenge), this.signKeys.secretKey),
1551
+ await xSignAsync(
1552
+ XUtils.decodeHex(challenge),
1553
+ this.signKeys.secretKey,
1554
+ ),
1292
1555
  );
1293
1556
 
1294
1557
  const verifyRes = await this.http.post(
@@ -1378,7 +1641,7 @@ export class Client {
1378
1641
  if (regKey) {
1379
1642
  const signKey = XUtils.encodeHex(this.signKeys.publicKey);
1380
1643
  const signed = XUtils.encodeHex(
1381
- xSign(
1644
+ await xSignAsync(
1382
1645
  Uint8Array.from(uuid.parse(regKey.key)),
1383
1646
  this.signKeys.secretKey,
1384
1647
  ),
@@ -1408,12 +1671,10 @@ export class Client {
1408
1671
  return [this.getUser(), null];
1409
1672
  } catch (err: unknown) {
1410
1673
  if (isAxiosError(err) && err.response) {
1411
- const raw: unknown = err.response.data;
1412
- const msg =
1413
- raw instanceof ArrayBuffer || raw instanceof Uint8Array
1414
- ? new TextDecoder().decode(raw)
1415
- : String(raw);
1416
- return [null, new Error(msg)];
1674
+ return [
1675
+ null,
1676
+ new Error(spireErrorBodyMessage(err.response.data)),
1677
+ ];
1417
1678
  }
1418
1679
  return [
1419
1680
  null,
@@ -1490,60 +1751,69 @@ export class Client {
1490
1751
 
1491
1752
  // returns the file details and the encryption key
1492
1753
  private async createFile(file: Uint8Array): Promise<[FileSQL, string]> {
1493
- const nonce = xMakeNonce();
1494
- const key = xBoxKeyPair();
1495
- const box = xSecretbox(Uint8Array.from(file), nonce, key.secretKey);
1754
+ return this.runWithThisCryptoProfile(async () => {
1755
+ const nonce = xMakeNonce();
1756
+ const fileKey: Uint8Array =
1757
+ this.cryptoProfile === "fips"
1758
+ ? xRandomBytes(32)
1759
+ : (await xBoxKeyPairAsync()).secretKey;
1760
+ const box = await xSecretboxAsync(
1761
+ Uint8Array.from(file),
1762
+ nonce,
1763
+ fileKey,
1764
+ );
1496
1765
 
1497
- if (typeof FormData !== "undefined") {
1498
- const fpayload = new FormData();
1499
- fpayload.set("owner", this.getDevice().deviceID);
1500
- fpayload.set("nonce", XUtils.encodeHex(nonce));
1501
- fpayload.set("file", new Blob([new Uint8Array(box)]));
1766
+ if (typeof FormData !== "undefined") {
1767
+ const fpayload = new FormData();
1768
+ fpayload.set("owner", this.getDevice().deviceID);
1769
+ fpayload.set("nonce", XUtils.encodeHex(nonce));
1770
+ fpayload.set("file", new Blob([new Uint8Array(box)]));
1502
1771
 
1503
- const fres = await this.http.post(
1504
- this.getHost() + "/file",
1505
- fpayload,
1506
- {
1507
- headers: { "Content-Type": "multipart/form-data" },
1508
- onUploadProgress: (progressEvent) => {
1509
- const percentCompleted = Math.round(
1510
- (progressEvent.loaded * 100) /
1511
- (progressEvent.total ?? 1),
1512
- );
1513
- const { loaded, total = 0 } = progressEvent;
1514
- const progress: FileProgress = {
1515
- direction: "upload",
1516
- loaded,
1517
- progress: percentCompleted,
1518
- token: XUtils.encodeHex(nonce),
1519
- total,
1520
- };
1521
- this.emitter.emit("fileProgress", progress);
1772
+ const fres = await this.http.post(
1773
+ this.getHost() + "/file",
1774
+ fpayload,
1775
+ {
1776
+ headers: { "Content-Type": "multipart/form-data" },
1777
+ onUploadProgress: (progressEvent) => {
1778
+ const percentCompleted = Math.round(
1779
+ (progressEvent.loaded * 100) /
1780
+ (progressEvent.total ?? 1),
1781
+ );
1782
+ const { loaded, total = 0 } = progressEvent;
1783
+ const progress: FileProgress = {
1784
+ direction: "upload",
1785
+ loaded,
1786
+ progress: percentCompleted,
1787
+ token: XUtils.encodeHex(nonce),
1788
+ total,
1789
+ };
1790
+ this.emitter.emit("fileProgress", progress);
1791
+ },
1522
1792
  },
1523
- },
1524
- );
1525
- const fcreatedFile = decodeAxios(FileSQLCodec, fres.data);
1793
+ );
1794
+ const fcreatedFile = decodeAxios(FileSQLCodec, fres.data);
1526
1795
 
1527
- return [fcreatedFile, XUtils.encodeHex(key.secretKey)];
1528
- }
1796
+ return [fcreatedFile, XUtils.encodeHex(fileKey)];
1797
+ }
1529
1798
 
1530
- const payload: {
1531
- file: string;
1532
- nonce: string;
1533
- owner: string;
1534
- } = {
1535
- file: XUtils.encodeBase64(box),
1536
- nonce: XUtils.encodeHex(nonce),
1537
- owner: this.getDevice().deviceID,
1538
- };
1539
- const res = await this.http.post(
1540
- this.getHost() + "/file/json",
1541
- msgpack.encode(payload),
1542
- { headers: { "Content-Type": "application/msgpack" } },
1543
- );
1544
- const createdFile = decodeAxios(FileSQLCodec, res.data);
1799
+ const payload: {
1800
+ file: string;
1801
+ nonce: string;
1802
+ owner: string;
1803
+ } = {
1804
+ file: XUtils.encodeBase64(box),
1805
+ nonce: XUtils.encodeHex(nonce),
1806
+ owner: this.getDevice().deviceID,
1807
+ };
1808
+ const res = await this.http.post(
1809
+ this.getHost() + "/file/json",
1810
+ msgpack.encode(payload),
1811
+ { headers: { "Content-Type": "application/msgpack" } },
1812
+ );
1813
+ const createdFile = decodeAxios(FileSQLCodec, res.data);
1545
1814
 
1546
- return [createdFile, XUtils.encodeHex(key.secretKey)];
1815
+ return [createdFile, XUtils.encodeHex(fileKey)];
1816
+ });
1547
1817
  }
1548
1818
 
1549
1819
  private async createInvite(serverID: string, duration: string) {
@@ -1561,14 +1831,15 @@ export class Client {
1561
1831
  return decodeAxios(InviteCodec, res.data);
1562
1832
  }
1563
1833
 
1564
- private createPreKey(): UnsavedPreKey {
1565
- const preKeyPair = xBoxKeyPair();
1834
+ private async createPreKey(): Promise<UnsavedPreKey> {
1835
+ const preKeyPair = await xBoxKeyPairAsync();
1836
+ const toSign =
1837
+ this.cryptoProfile === "fips"
1838
+ ? fipsP256PreKeySignPayload(preKeyPair.publicKey)
1839
+ : xEncode(xConstants.CURVE, preKeyPair.publicKey);
1566
1840
  return {
1567
1841
  keyPair: preKeyPair,
1568
- signature: xSign(
1569
- xEncode(xConstants.CURVE, preKeyPair.publicKey),
1570
- this.signKeys.secretKey,
1571
- ),
1842
+ signature: await xSignAsync(toSign, this.signKeys.secretKey),
1572
1843
  };
1573
1844
  }
1574
1845
 
@@ -1579,6 +1850,27 @@ export class Client {
1579
1850
  return decodeAxios(ServerCodec, res.data);
1580
1851
  }
1581
1852
 
1853
+ /**
1854
+ * `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
1855
+ * active profile. When several {@link Client} instances use different
1856
+ * `cryptoProfile` values, scope the global to this instance for the duration
1857
+ * of that crypto work.
1858
+ */
1859
+ private async runWithThisCryptoProfile<T>(
1860
+ fn: () => Promise<T>,
1861
+ ): Promise<T> {
1862
+ const prev = getCryptoProfile();
1863
+ if (prev === this.cryptoProfile) {
1864
+ return await fn();
1865
+ }
1866
+ setCryptoProfile(this.cryptoProfile);
1867
+ try {
1868
+ return await fn();
1869
+ } finally {
1870
+ setCryptoProfile(prev);
1871
+ }
1872
+ }
1873
+
1582
1874
  private async createSession(
1583
1875
  device: Device,
1584
1876
  user: User,
@@ -1588,164 +1880,198 @@ export class Client {
1588
1880
  part of a group message */
1589
1881
  mailID: null | string,
1590
1882
  forward: boolean,
1883
+ /**
1884
+ * When `readMail` triggers a best-effort session re-establish, key-bundle
1885
+ * errors should not reject the full read pipeline.
1886
+ */
1887
+ allowKeyBundleFailure = false,
1591
1888
  ): Promise<void> {
1592
- let keyBundle: KeyBundle;
1593
-
1594
- try {
1595
- keyBundle = await this.retrieveKeyBundle(device.deviceID);
1596
- } catch {
1597
- return;
1598
- }
1889
+ return this.runWithThisCryptoProfile(async () => {
1890
+ let keyBundle: KeyBundle;
1599
1891
 
1600
- if (!this.xKeyRing) {
1601
- if (this.manuallyClosing) {
1602
- return;
1892
+ try {
1893
+ keyBundle = await this.retrieveKeyBundle(device.deviceID);
1894
+ } catch (e) {
1895
+ if (allowKeyBundleFailure) {
1896
+ return;
1897
+ }
1898
+ const wrap =
1899
+ e instanceof Error ? e : new Error(String(e), { cause: e });
1900
+ throw new Error(
1901
+ `Failed to load keyBundle for device ${device.deviceID}: ${wrap.message}`,
1902
+ { cause: e },
1903
+ );
1603
1904
  }
1604
- throw new Error("Key ring not initialized.");
1605
- }
1606
-
1607
- // my keys
1608
- const IK_A = this.xKeyRing.identityKeys.secretKey;
1609
- const IK_AP = this.xKeyRing.identityKeys.publicKey;
1610
- const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
1611
-
1612
- // their keys
1613
- const IK_B_raw = XKeyConvert.convertPublicKey(
1614
- new Uint8Array(keyBundle.signKey),
1615
- );
1616
- if (!IK_B_raw) {
1617
- throw new Error("Could not convert sign key to X25519.");
1618
- }
1619
- const IK_B = IK_B_raw;
1620
- const SPK_B = new Uint8Array(keyBundle.preKey.publicKey);
1621
- const OPK_B = keyBundle.otk
1622
- ? new Uint8Array(keyBundle.otk.publicKey)
1623
- : null;
1624
-
1625
- // diffie hellman functions
1626
- const DH1 = xDH(new Uint8Array(IK_A), SPK_B);
1627
- const DH2 = xDH(new Uint8Array(EK_A), IK_B);
1628
- const DH3 = xDH(new Uint8Array(EK_A), SPK_B);
1629
- const DH4 = OPK_B ? xDH(new Uint8Array(EK_A), OPK_B) : null;
1630
-
1631
- // initial key material
1632
- const IKM = DH4 ? xConcat(DH1, DH2, DH3, DH4) : xConcat(DH1, DH2, DH3);
1633
-
1634
- // one time key index
1635
- const IDX = keyBundle.otk
1636
- ? XUtils.numberToUint8Arr(keyBundle.otk.index ?? 0)
1637
- : XUtils.numberToUint8Arr(0);
1638
-
1639
- // shared secret key
1640
- const SK = xKDF(IKM);
1641
- const PK = xBoxKeyPairFromSecret(SK).publicKey;
1642
-
1643
- const AD = xConcat(
1644
- xEncode(xConstants.CURVE, IK_AP),
1645
- xEncode(xConstants.CURVE, IK_B),
1646
- );
1647
1905
 
1648
- const nonce = xMakeNonce();
1649
- const cipher = xSecretbox(message, nonce, SK);
1650
-
1651
- /* 32 bytes for signkey, 32 bytes for ephemeral key,
1652
- 68 bytes for AD, 6 bytes for otk index (empty for no otk) */
1653
- const extra = xConcat(
1654
- this.signKeys.publicKey,
1655
- this.xKeyRing.ephemeralKeys.publicKey,
1656
- PK,
1657
- AD,
1658
- IDX,
1659
- );
1906
+ if (!this.xKeyRing) {
1907
+ if (this.manuallyClosing) {
1908
+ return;
1909
+ }
1910
+ throw new Error("Key ring not initialized.");
1911
+ }
1660
1912
 
1661
- const mail: MailWS = {
1662
- authorID: this.getUser().userID,
1663
- cipher,
1664
- extra,
1665
- forward,
1666
- group,
1667
- mailID: mailID || uuid.v4(),
1668
- mailType: MailType.initial,
1669
- nonce,
1670
- readerID: user.userID,
1671
- recipient: device.deviceID,
1672
- sender: this.getDevice().deviceID,
1673
- };
1913
+ // my keys
1914
+ const IK_A = this.xKeyRing.identityKeys.secretKey;
1915
+ const IK_AP = this.xKeyRing.identityKeys.publicKey;
1916
+ const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
1917
+
1918
+ const fips = this.cryptoProfile === "fips";
1919
+ // their keys — FIPS: `signKey` in bundle is the peer P-256 ECDH identity (raw, typically 65B).
1920
+ const SPK_B = new Uint8Array(keyBundle.preKey.publicKey);
1921
+ const OPK_B = keyBundle.otk
1922
+ ? new Uint8Array(keyBundle.otk.publicKey)
1923
+ : null;
1924
+ const IK_B = fips
1925
+ ? new Uint8Array(keyBundle.signKey)
1926
+ : (() => {
1927
+ const c = XKeyConvert.convertPublicKey(
1928
+ new Uint8Array(keyBundle.signKey),
1929
+ );
1930
+ if (!c) {
1931
+ throw new Error(
1932
+ "Could not convert sign key to X25519.",
1933
+ );
1934
+ }
1935
+ return c;
1936
+ })();
1937
+
1938
+ // diffie hellman functions
1939
+ const DH1 = await xDHAsync(new Uint8Array(IK_A), SPK_B);
1940
+ const DH2 = await xDHAsync(new Uint8Array(EK_A), IK_B);
1941
+ const DH3 = await xDHAsync(new Uint8Array(EK_A), SPK_B);
1942
+ const DH4 = OPK_B
1943
+ ? await xDHAsync(new Uint8Array(EK_A), OPK_B)
1944
+ : null;
1945
+
1946
+ // initial key material
1947
+ const IKM = DH4
1948
+ ? xConcat(DH1, DH2, DH3, DH4)
1949
+ : xConcat(DH1, DH2, DH3);
1950
+
1951
+ // one time key index
1952
+ const IDX = keyBundle.otk
1953
+ ? XUtils.numberToUint8Arr(keyBundle.otk.index ?? 0)
1954
+ : XUtils.numberToUint8Arr(0);
1955
+
1956
+ // shared secret key
1957
+ const SK = xKDF(IKM);
1958
+ const PK = (await xBoxKeyPairFromSecretAsync(SK)).publicKey;
1959
+
1960
+ const AD = fips
1961
+ ? fipsP256AdFromIdentityPubs(
1962
+ IK_AP,
1963
+ new Uint8Array(keyBundle.signKey),
1964
+ )
1965
+ : xConcat(
1966
+ xEncode(xConstants.CURVE, IK_AP),
1967
+ xEncode(xConstants.CURVE, IK_B),
1968
+ );
1969
+
1970
+ const nonce = xMakeNonce();
1971
+ const cipher = await xSecretboxAsync(message, nonce, SK);
1972
+
1973
+ const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
1974
+ const ephKeyWire = this.xKeyRing.ephemeralKeys.publicKey;
1975
+
1976
+ const extra = fips
1977
+ ? encodeFipsInitialExtraV1(signKeyWire, ephKeyWire, PK, AD, IDX)
1978
+ : xConcat(
1979
+ this.signKeys.publicKey,
1980
+ this.xKeyRing.ephemeralKeys.publicKey,
1981
+ PK,
1982
+ AD,
1983
+ IDX,
1984
+ );
1985
+
1986
+ const mail: MailWS = {
1987
+ authorID: this.getUser().userID,
1988
+ cipher,
1989
+ extra,
1990
+ forward,
1991
+ group,
1992
+ mailID: mailID || uuid.v4(),
1993
+ mailType: MailType.initial,
1994
+ nonce,
1995
+ readerID: user.userID,
1996
+ recipient: device.deviceID,
1997
+ sender: this.getDevice().deviceID,
1998
+ };
1674
1999
 
1675
- const hmac = xHMAC(mail, SK);
2000
+ const hmac = xHMAC(mail, SK);
1676
2001
 
1677
- const msg: ResourceMsg = {
1678
- action: "CREATE",
1679
- data: mail,
1680
- resourceType: "mail",
1681
- transmissionID: uuid.v4(),
1682
- type: "resource",
1683
- };
2002
+ const msg: ResourceMsg = {
2003
+ action: "CREATE",
2004
+ data: mail,
2005
+ resourceType: "mail",
2006
+ transmissionID: uuid.v4(),
2007
+ type: "resource",
2008
+ };
1684
2009
 
1685
- // discard the ephemeral keys
1686
- this.newEphemeralKeys();
1687
-
1688
- const sessionEntry: SessionSQL = {
1689
- deviceID: device.deviceID,
1690
- fingerprint: XUtils.encodeHex(AD),
1691
- lastUsed: new Date().toISOString(),
1692
- mode: "initiator",
1693
- publicKey: XUtils.encodeHex(PK),
1694
- sessionID: uuid.v4(),
1695
- SK: XUtils.encodeHex(SK),
1696
- userID: user.userID,
1697
- verified: false,
1698
- };
2010
+ // discard the ephemeral keys
2011
+ await this.newEphemeralKeys();
2012
+
2013
+ const sessionEntry: SessionSQL = {
2014
+ deviceID: device.deviceID,
2015
+ fingerprint: XUtils.encodeHex(AD),
2016
+ lastUsed: new Date().toISOString(),
2017
+ mode: "initiator",
2018
+ publicKey: XUtils.encodeHex(PK),
2019
+ sessionID: uuid.v4(),
2020
+ SK: XUtils.encodeHex(SK),
2021
+ userID: user.userID,
2022
+ verified: false,
2023
+ };
1699
2024
 
1700
- await this.database.saveSession(sessionEntry);
1701
-
1702
- this.emitter.emit("session", sessionEntry, user);
1703
-
1704
- // emit the message
1705
- const forwardedMsg = forward
1706
- ? messageSchema.parse(msgpack.decode(message))
1707
- : null;
1708
- const emitMsg: Message = forwardedMsg
1709
- ? { ...forwardedMsg, forward: true }
1710
- : {
1711
- authorID: mail.authorID,
1712
- decrypted: true,
1713
- direction: "outgoing",
1714
- forward: mail.forward,
1715
- group: mail.group ? uuid.stringify(mail.group) : null,
1716
- mailID: mail.mailID,
1717
- message: XUtils.encodeUTF8(message),
1718
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1719
- readerID: mail.readerID,
1720
- recipient: mail.recipient,
1721
- sender: mail.sender,
1722
- timestamp: new Date().toISOString(),
1723
- };
1724
- this.emitter.emit("message", emitMsg);
1725
-
1726
- // send mail and wait for response
1727
- await new Promise((res, rej) => {
1728
- const callback = (packedMsg: Uint8Array) => {
1729
- const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
1730
- if (receivedMsg.transmissionID === msg.transmissionID) {
1731
- this.socket.off("message", callback);
1732
- const parsed = WSMessageSchema.safeParse(receivedMsg);
1733
- if (parsed.success && parsed.data.type === "success") {
1734
- res(parsed.data.data);
1735
- } else {
1736
- rej(
1737
- new Error(
1738
- "Mail delivery failed: " +
1739
- JSON.stringify(receivedMsg),
1740
- ),
1741
- );
2025
+ await this.database.saveSession(sessionEntry);
2026
+
2027
+ this.emitter.emit("session", sessionEntry, user);
2028
+
2029
+ // emit the message
2030
+ const forwardedMsg = forward
2031
+ ? messageSchema.parse(msgpack.decode(message))
2032
+ : null;
2033
+ const emitMsg: Message = forwardedMsg
2034
+ ? { ...forwardedMsg, forward: true }
2035
+ : {
2036
+ authorID: mail.authorID,
2037
+ decrypted: true,
2038
+ direction: "outgoing",
2039
+ forward: mail.forward,
2040
+ group: mail.group ? uuid.stringify(mail.group) : null,
2041
+ mailID: mail.mailID,
2042
+ message: XUtils.encodeUTF8(message),
2043
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
2044
+ readerID: mail.readerID,
2045
+ recipient: mail.recipient,
2046
+ sender: mail.sender,
2047
+ timestamp: new Date().toISOString(),
2048
+ };
2049
+ this.emitter.emit("message", emitMsg);
2050
+
2051
+ // send mail and wait for response
2052
+ await new Promise((res, rej) => {
2053
+ const callback = (packedMsg: Uint8Array) => {
2054
+ const [_header, receivedMsg] =
2055
+ XUtils.unpackMessage(packedMsg);
2056
+ if (receivedMsg.transmissionID === msg.transmissionID) {
2057
+ this.socket.off("message", callback);
2058
+ const parsed = WSMessageSchema.safeParse(receivedMsg);
2059
+ if (parsed.success && parsed.data.type === "success") {
2060
+ res(parsed.data.data);
2061
+ } else {
2062
+ rej(
2063
+ new Error(
2064
+ "Mail delivery failed: " +
2065
+ JSON.stringify(receivedMsg),
2066
+ ),
2067
+ );
2068
+ }
1742
2069
  }
1743
- }
1744
- };
1745
- this.socket.on("message", callback);
1746
- void this.send(msg, hmac);
2070
+ };
2071
+ this.socket.on("message", callback);
2072
+ void this.send(msg, hmac);
2073
+ });
1747
2074
  });
1748
- this.sending.delete(device.deviceID);
1749
2075
  }
1750
2076
 
1751
2077
  private async deleteChannel(channelID: string): Promise<void> {
@@ -1827,6 +2153,10 @@ export class Client {
1827
2153
  }
1828
2154
 
1829
2155
  private async forward(message: Message) {
2156
+ if (this.isManualCloseInFlight()) {
2157
+ return;
2158
+ }
2159
+
1830
2160
  const copy = { ...message };
1831
2161
 
1832
2162
  if (this.forwarded.has(copy.mailID)) {
@@ -1845,28 +2175,23 @@ export class Client {
1845
2175
  this.getUser().userID,
1846
2176
  "own",
1847
2177
  );
1848
- const promises = [];
1849
2178
  for (const device of devices) {
1850
- if (device.deviceID !== this.getDevice().deviceID) {
1851
- promises.push(
1852
- this.sendMail(
1853
- device,
1854
- this.getUser(),
1855
- msgBytes,
1856
- null,
1857
- copy.mailID,
1858
- true,
1859
- ),
2179
+ if (device.deviceID === this.getDevice().deviceID) {
2180
+ continue;
2181
+ }
2182
+ try {
2183
+ await this.sendMail(
2184
+ device,
2185
+ this.getUser(),
2186
+ msgBytes,
2187
+ null,
2188
+ copy.mailID,
2189
+ true,
1860
2190
  );
2191
+ } catch {
2192
+ /* best-effort per device; parallel handshakes share ephemeral state */
1861
2193
  }
1862
2194
  }
1863
- void Promise.allSettled(promises).then((results) => {
1864
- for (const result of results) {
1865
- const { status } = result;
1866
- if (status === "rejected") {
1867
- }
1868
- }
1869
- });
1870
2195
  }
1871
2196
 
1872
2197
  private async getChannelByID(channelID: string): Promise<Channel | null> {
@@ -1972,16 +2297,49 @@ export class Client {
1972
2297
  .parse(msgpack.decode(mailBuffer));
1973
2298
  const inbox = rawInbox.sort((a, b) => b[2].localeCompare(a[2]));
1974
2299
 
2300
+ if (libvexDebugDmEnabled()) {
2301
+ const did = (() => {
2302
+ try {
2303
+ return this.getDevice().deviceID;
2304
+ } catch {
2305
+ return "(no device)";
2306
+ }
2307
+ })();
2308
+ debugLibvexDm("getMail: inbox", {
2309
+ deviceID: did,
2310
+ count: String(inbox.length),
2311
+ });
2312
+ }
2313
+
1975
2314
  for (const mailDetails of inbox) {
1976
2315
  const [mailHeader, mailBody, timestamp] = mailDetails;
1977
2316
  try {
2317
+ if (libvexDebugDmEnabled()) {
2318
+ debugLibvexDm("getMail: readMail one", {
2319
+ mailID: mailBody.mailID,
2320
+ type: String(mailBody.mailType),
2321
+ recipient: mailBody.recipient,
2322
+ });
2323
+ }
1978
2324
  await this.readMail(mailHeader, mailBody, timestamp);
1979
- } catch (_readMailErr) {
1980
- // non-fatal — inspect _readMailErr in a debugger
2325
+ } catch (readMailErr) {
2326
+ if (libvexDebugDmEnabled()) {
2327
+ // eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
2328
+ console.error(
2329
+ "[libvex:debug-dm] readMail threw",
2330
+ readMailErr,
2331
+ );
2332
+ }
1981
2333
  }
1982
2334
  }
1983
- } catch (_fetchErr) {
1984
- // non-fatal — inspect _fetchErr in a debugger
2335
+ } catch (fetchErr) {
2336
+ if (libvexDebugDmEnabled()) {
2337
+ // eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
2338
+ console.error(
2339
+ "[libvex:debug-dm] getMail fetch failed",
2340
+ fetchErr,
2341
+ );
2342
+ }
1985
2343
  }
1986
2344
  this.fetchingMail = false;
1987
2345
  }
@@ -2127,6 +2485,9 @@ export class Client {
2127
2485
  }
2128
2486
 
2129
2487
  private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
2488
+ if (this.isManualCloseInFlight()) {
2489
+ return [];
2490
+ }
2130
2491
  const res = await this.http.get(
2131
2492
  this.getHost() + "/user/" + userID + "/devices",
2132
2493
  );
@@ -2151,20 +2512,16 @@ export class Client {
2151
2512
  : "Couldn't get device list";
2152
2513
  let lastErr: unknown;
2153
2514
  for (let attempt = 0; attempt < 5; attempt++) {
2515
+ if (this.isManualCloseInFlight()) {
2516
+ return [];
2517
+ }
2154
2518
  if (attempt > 0) {
2155
- if (this.isManualCloseInFlight()) {
2156
- throw new Error(
2157
- `${base}${this.deviceListFailureDetail(lastErr)}`,
2158
- );
2159
- }
2160
2519
  const delayMs = 100 * 2 ** (attempt - 1);
2161
- // Chunk the delay to allow close() to interrupt
2520
+ // Chunk the delay so close() can finish before we retry HTTP.
2162
2521
  const chunkMs = 10;
2163
2522
  for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
2164
2523
  if (this.isManualCloseInFlight()) {
2165
- throw new Error(
2166
- `${base}${this.deviceListFailureDetail(lastErr)}`,
2167
- );
2524
+ return [];
2168
2525
  }
2169
2526
  await sleep(Math.min(chunkMs, delayMs - elapsed));
2170
2527
  }
@@ -2205,6 +2562,28 @@ export class Client {
2205
2562
  }
2206
2563
  }
2207
2564
 
2565
+ /**
2566
+ * Pipeline for decrypted messages — registered in `init`. After `close()` sets
2567
+ * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
2568
+ * race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
2569
+ */
2570
+ private readonly onInternalMessage = (message: Message): void => {
2571
+ if (this.isManualCloseInFlight()) {
2572
+ return;
2573
+ }
2574
+ if (message.direction === "outgoing" && !message.forward) {
2575
+ void this.forward(message);
2576
+ }
2577
+
2578
+ if (
2579
+ message.direction === "incoming" &&
2580
+ message.recipient === message.sender
2581
+ ) {
2582
+ return;
2583
+ }
2584
+ void this.database.saveMessage(message);
2585
+ };
2586
+
2208
2587
  /**
2209
2588
  * Initializes the keyring. This must be called before anything else.
2210
2589
  */
@@ -2223,19 +2602,7 @@ export class Client {
2223
2602
  }
2224
2603
 
2225
2604
  await this.populateKeyRing();
2226
- this.emitter.on("message", (message) => {
2227
- if (message.direction === "outgoing" && !message.forward) {
2228
- void this.forward(message);
2229
- }
2230
-
2231
- if (
2232
- message.direction === "incoming" &&
2233
- message.recipient === message.sender
2234
- ) {
2235
- return;
2236
- }
2237
- void this.database.saveMessage(message);
2238
- });
2605
+ this.emitter.on("message", this.onInternalMessage);
2239
2606
  this.emitter.emit("ready");
2240
2607
  }
2241
2608
 
@@ -2284,7 +2651,7 @@ export class Client {
2284
2651
 
2285
2652
  switch (msg.type) {
2286
2653
  case "challenge":
2287
- this.respond(msg);
2654
+ void this.respond(msg);
2288
2655
  break;
2289
2656
  case "error":
2290
2657
  break;
@@ -2352,14 +2719,14 @@ export class Client {
2352
2719
  await this.submitOTK(needs);
2353
2720
  }
2354
2721
 
2355
- private newEphemeralKeys() {
2722
+ private async newEphemeralKeys() {
2356
2723
  if (!this.xKeyRing) {
2357
2724
  if (this.manuallyClosing) {
2358
2725
  return;
2359
2726
  }
2360
2727
  throw new Error("Key ring not initialized.");
2361
2728
  }
2362
- this.xKeyRing.ephemeralKeys = xBoxKeyPair();
2729
+ this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
2363
2730
  }
2364
2731
 
2365
2732
  private ping() {
@@ -2384,7 +2751,7 @@ export class Client {
2384
2751
  const preKeys: PreKeysCrypto =
2385
2752
  existingPreKeys ??
2386
2753
  (await (async () => {
2387
- const unsaved = this.createPreKey();
2754
+ const unsaved = await this.createPreKey();
2388
2755
  const [saved] = await this.database.savePreKeys(
2389
2756
  [unsaved],
2390
2757
  false,
@@ -2402,7 +2769,7 @@ export class Client {
2402
2769
  sqlSessionToCrypto(session);
2403
2770
  }
2404
2771
 
2405
- const ephemeralKeys = xBoxKeyPair();
2772
+ const ephemeralKeys = await xBoxKeyPairAsync();
2406
2773
 
2407
2774
  this.xKeyRing = {
2408
2775
  ephemeralKeys,
@@ -2412,11 +2779,15 @@ export class Client {
2412
2779
  }
2413
2780
 
2414
2781
  private async postAuth() {
2782
+ const versionAtStart = this.postAuthVersion;
2415
2783
  let count = 0;
2416
2784
  for (;;) {
2417
2785
  if (this.isManualCloseInFlight()) {
2418
2786
  return;
2419
2787
  }
2788
+ if (this.postAuthVersion !== versionAtStart) {
2789
+ return;
2790
+ }
2420
2791
  try {
2421
2792
  await this.getMail();
2422
2793
  count++;
@@ -2430,12 +2801,18 @@ export class Client {
2430
2801
  if (this.isManualCloseInFlight()) {
2431
2802
  return;
2432
2803
  }
2804
+ if (this.postAuthVersion !== versionAtStart) {
2805
+ return;
2806
+ }
2433
2807
  // Chunk the idle delay so `close()` can unwind instead of waiting
2434
2808
  // out one full 60s timer (which would keep the process alive).
2435
2809
  for (let i = 0; i < 60; i++) {
2436
2810
  if (this.isManualCloseInFlight()) {
2437
2811
  return;
2438
2812
  }
2813
+ if (this.postAuthVersion !== versionAtStart) {
2814
+ return;
2815
+ }
2439
2816
  await sleep(1000);
2440
2817
  }
2441
2818
  }
@@ -2451,11 +2828,28 @@ export class Client {
2451
2828
  timestamp: string,
2452
2829
  ) {
2453
2830
  if (this.seenMailIDs.has(mail.mailID)) {
2831
+ if (libvexDebugDmEnabled()) {
2832
+ try {
2833
+ debugLibvexDm("readMail: skip (seen mailID)", {
2834
+ mailID: mail.mailID,
2835
+ thisDevice: this.getDevice().deviceID,
2836
+ });
2837
+ } catch {
2838
+ debugLibvexDm("readMail: skip (seen mailID)", {
2839
+ mailID: mail.mailID,
2840
+ });
2841
+ }
2842
+ }
2454
2843
  return;
2455
2844
  }
2456
2845
  this.seenMailIDs.add(mail.mailID);
2457
2846
 
2458
2847
  if (this.manuallyClosing) {
2848
+ if (libvexDebugDmEnabled()) {
2849
+ debugLibvexDm("readMail: skip (manually closing)", {
2850
+ mailID: mail.mailID,
2851
+ });
2852
+ }
2459
2853
  return;
2460
2854
  }
2461
2855
 
@@ -2468,267 +2862,407 @@ export class Client {
2468
2862
  this.reading = true;
2469
2863
 
2470
2864
  try {
2471
- const healSession = async () => {
2472
- if (this.manuallyClosing || !this.xKeyRing) {
2473
- return;
2474
- }
2475
- const deviceEntry = await this.getDeviceByID(mail.sender);
2476
- const [user, _err] = await this.fetchUser(mail.authorID);
2477
- if (deviceEntry && user) {
2478
- void this.createSession(
2479
- deviceEntry,
2480
- user,
2481
- XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`),
2482
- mail.group,
2483
- uuid.v4(),
2484
- false,
2485
- );
2486
- }
2487
- };
2488
-
2489
- switch (mail.mailType) {
2490
- case MailType.initial:
2491
- const extraParts = Client.deserializeExtra(
2492
- MailType.initial,
2493
- new Uint8Array(mail.extra),
2494
- );
2495
- const signKey = extraParts[0];
2496
- const ephKey = extraParts[1];
2497
- const indexBytes = extraParts[3];
2498
- if (!signKey || !ephKey || !indexBytes) {
2499
- throw new Error(
2500
- "Malformed initial mail extra: missing signKey, ephKey, or indexBytes",
2501
- );
2502
- }
2503
-
2504
- const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
2505
-
2506
- const otk =
2507
- preKeyIndex === 0
2508
- ? null
2509
- : await this.database.getOneTimeKey(preKeyIndex);
2510
-
2511
- if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
2865
+ await this.runWithThisCryptoProfile(async () => {
2866
+ const healSession = async () => {
2867
+ if (this.manuallyClosing || !this.xKeyRing) {
2512
2868
  return;
2513
2869
  }
2514
-
2515
- // their public keys
2516
- const IK_A_raw = XKeyConvert.convertPublicKey(signKey);
2517
- if (!IK_A_raw) {
2518
- return;
2870
+ const deviceEntry = await this.getDeviceByID(mail.sender);
2871
+ const [user, _err] = await this.fetchUser(mail.authorID);
2872
+ if (deviceEntry && user) {
2873
+ void this.createSession(
2874
+ deviceEntry,
2875
+ user,
2876
+ XUtils.decodeUTF8(
2877
+ `��RETRY_REQUEST:${mail.mailID}��`,
2878
+ ),
2879
+ mail.group,
2880
+ uuid.v4(),
2881
+ false,
2882
+ true,
2883
+ );
2519
2884
  }
2520
- const IK_A = IK_A_raw;
2521
- const EK_A = ephKey;
2885
+ };
2522
2886
 
2523
- if (!this.xKeyRing) {
2524
- return;
2525
- }
2526
- // my private keys
2527
- const IK_B = this.xKeyRing.identityKeys.secretKey;
2528
- const IK_BP = this.xKeyRing.identityKeys.publicKey;
2529
- const SPK_B = this.xKeyRing.preKeys.keyPair.secretKey;
2530
- const OPK_B = otk ? otk.keyPair.secretKey : null;
2531
-
2532
- // diffie hellman functions
2533
- const DH1 = xDH(SPK_B, IK_A);
2534
- const DH2 = xDH(IK_B, EK_A);
2535
- const DH3 = xDH(SPK_B, EK_A);
2536
- const DH4 = OPK_B ? xDH(OPK_B, EK_A) : null;
2537
-
2538
- // initial key material
2539
- const IKM = DH4
2540
- ? xConcat(DH1, DH2, DH3, DH4)
2541
- : xConcat(DH1, DH2, DH3);
2542
-
2543
- // shared secret key
2544
- const SK = xKDF(IKM);
2545
- const PK = xBoxKeyPairFromSecret(SK).publicKey;
2546
-
2547
- const hmac = xHMAC(mail, SK);
2548
-
2549
- // associated data
2550
- const AD = xConcat(
2551
- xEncode(xConstants.CURVE, IK_A),
2552
- xEncode(xConstants.CURVE, IK_BP),
2553
- );
2887
+ switch (mail.mailType) {
2888
+ case MailType.initial:
2889
+ const extraParts = Client.deserializeExtra(
2890
+ MailType.initial,
2891
+ new Uint8Array(mail.extra),
2892
+ );
2893
+ const signKey = extraParts[0];
2894
+ const ephKey = extraParts[1];
2895
+ const indexBytes = extraParts[3];
2896
+ if (!signKey || !ephKey || !indexBytes) {
2897
+ throw new Error(
2898
+ "Malformed initial mail extra: missing signKey, ephKey, or indexBytes",
2899
+ );
2900
+ }
2554
2901
 
2555
- if (!XUtils.bytesEqual(hmac, header)) {
2556
- return;
2557
- }
2558
- const unsealed = xSecretboxOpen(
2559
- new Uint8Array(mail.cipher),
2560
- new Uint8Array(mail.nonce),
2561
- SK,
2562
- );
2563
- if (unsealed) {
2564
- let plaintext = "";
2565
- if (!mail.forward) {
2566
- plaintext = XUtils.encodeUTF8(unsealed);
2902
+ const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
2903
+
2904
+ const otk =
2905
+ preKeyIndex === 0
2906
+ ? null
2907
+ : await this.database.getOneTimeKey(
2908
+ preKeyIndex,
2909
+ );
2910
+
2911
+ if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
2912
+ if (libvexDebugDmEnabled()) {
2913
+ try {
2914
+ debugLibvexDm(
2915
+ "readMail initial: abort (otk index mismatch)",
2916
+ {
2917
+ mailID: mail.mailID,
2918
+ preKeyIndex: String(preKeyIndex),
2919
+ otkIndex: String(
2920
+ otk?.index ?? "null",
2921
+ ),
2922
+ thisDevice:
2923
+ this.getDevice().deviceID,
2924
+ },
2925
+ );
2926
+ } catch {
2927
+ debugLibvexDm(
2928
+ "readMail initial: abort (otk index mismatch)",
2929
+ {
2930
+ mailID: mail.mailID,
2931
+ },
2932
+ );
2933
+ }
2934
+ }
2935
+ return;
2567
2936
  }
2568
2937
 
2569
- // emit the message
2570
- const fwdMsg1 = mail.forward
2571
- ? messageSchema.parse(msgpack.decode(unsealed))
2572
- : null;
2573
- const message: Message = fwdMsg1
2574
- ? { ...fwdMsg1, forward: true }
2575
- : {
2576
- authorID: mail.authorID,
2577
- decrypted: true,
2578
- direction: "incoming",
2579
- forward: mail.forward,
2580
- group: mail.group
2581
- ? uuid.stringify(mail.group)
2582
- : null,
2583
- mailID: mail.mailID,
2584
- message: plaintext,
2585
- nonce: XUtils.encodeHex(
2586
- new Uint8Array(mail.nonce),
2587
- ),
2588
- readerID: mail.readerID,
2589
- recipient: mail.recipient,
2590
- sender: mail.sender,
2591
- timestamp: timestamp,
2592
- };
2593
-
2594
- this.emitter.emit("message", message);
2595
-
2596
- // discard onetimekey
2597
- await this.database.deleteOneTimeKey(preKeyIndex);
2598
-
2599
- const deviceEntry = await this.getDeviceByID(
2600
- mail.sender,
2938
+ // their public keys
2939
+ const fipsRead = isFipsInitialExtraV1(
2940
+ new Uint8Array(mail.extra),
2601
2941
  );
2602
- if (!deviceEntry) {
2603
- throw new Error("Couldn't get device entry.");
2942
+ const IK_A = fipsRead
2943
+ ? signKey
2944
+ : (() => {
2945
+ const c =
2946
+ XKeyConvert.convertPublicKey(signKey);
2947
+ if (!c) {
2948
+ return null;
2949
+ }
2950
+ return c;
2951
+ })();
2952
+ if (!IK_A) {
2953
+ if (libvexDebugDmEnabled()) {
2954
+ try {
2955
+ debugLibvexDm(
2956
+ "readMail initial: abort (IK_A null, Ed→X25519?)",
2957
+ {
2958
+ mailID: mail.mailID,
2959
+ fips: String(fipsRead),
2960
+ thisDevice:
2961
+ this.getDevice().deviceID,
2962
+ },
2963
+ );
2964
+ } catch {
2965
+ debugLibvexDm(
2966
+ "readMail initial: abort (IK_A null)",
2967
+ {
2968
+ mailID: mail.mailID,
2969
+ },
2970
+ );
2971
+ }
2972
+ }
2973
+ return;
2604
2974
  }
2605
- const [userEntry, _userErr] = await this.fetchUser(
2606
- deviceEntry.owner,
2607
- );
2608
- if (!userEntry) {
2609
- throw new Error("Couldn't get user entry.");
2975
+ const EK_A = ephKey;
2976
+
2977
+ if (!this.xKeyRing) {
2978
+ if (libvexDebugDmEnabled()) {
2979
+ debugLibvexDm(
2980
+ "readMail initial: abort (no xKeyRing)",
2981
+ {
2982
+ mailID: mail.mailID,
2983
+ },
2984
+ );
2985
+ }
2986
+ return;
2610
2987
  }
2988
+ // my private keys
2989
+ const IK_B = this.xKeyRing.identityKeys.secretKey;
2990
+ const IK_BP = this.xKeyRing.identityKeys.publicKey;
2991
+ const SPK_B = this.xKeyRing.preKeys.keyPair.secretKey;
2992
+ const OPK_B = otk ? otk.keyPair.secretKey : null;
2993
+
2994
+ // diffie hellman functions
2995
+ const DH1 = await xDHAsync(SPK_B, IK_A);
2996
+ const DH2 = await xDHAsync(IK_B, EK_A);
2997
+ const DH3 = await xDHAsync(SPK_B, EK_A);
2998
+ const DH4 = OPK_B ? await xDHAsync(OPK_B, EK_A) : null;
2999
+
3000
+ // initial key material
3001
+ const IKM = DH4
3002
+ ? xConcat(DH1, DH2, DH3, DH4)
3003
+ : xConcat(DH1, DH2, DH3);
3004
+
3005
+ // shared secret key
3006
+ const SK = xKDF(IKM);
3007
+ const PK = (await xBoxKeyPairFromSecretAsync(SK))
3008
+ .publicKey;
3009
+
3010
+ const hmac = xHMAC(mail, SK);
3011
+
3012
+ // associated data
3013
+ const AD = fipsRead
3014
+ ? fipsP256AdFromIdentityPubs(IK_A, IK_BP)
3015
+ : xConcat(
3016
+ xEncode(xConstants.CURVE, IK_A),
3017
+ xEncode(xConstants.CURVE, IK_BP),
3018
+ );
3019
+
3020
+ if (!XUtils.bytesEqual(hmac, header)) {
3021
+ if (libvexDebugDmEnabled()) {
3022
+ try {
3023
+ debugLibvexDm(
3024
+ "readMail initial: abort (HMAC mismatch)",
3025
+ {
3026
+ mailID: mail.mailID,
3027
+ preKeyIndex: String(preKeyIndex),
3028
+ thisDevice:
3029
+ this.getDevice().deviceID,
3030
+ },
3031
+ );
3032
+ } catch {
3033
+ debugLibvexDm(
3034
+ "readMail initial: abort (HMAC mismatch)",
3035
+ {
3036
+ mailID: mail.mailID,
3037
+ },
3038
+ );
3039
+ }
3040
+ }
3041
+ return;
3042
+ }
3043
+ const unsealed = await xSecretboxOpenAsync(
3044
+ new Uint8Array(mail.cipher),
3045
+ new Uint8Array(mail.nonce),
3046
+ SK,
3047
+ );
3048
+ if (unsealed) {
3049
+ let plaintext = "";
3050
+ if (!mail.forward) {
3051
+ plaintext = XUtils.encodeUTF8(unsealed);
3052
+ }
3053
+
3054
+ // emit the message
3055
+ const fwdMsg1 = mail.forward
3056
+ ? messageSchema.parse(msgpack.decode(unsealed))
3057
+ : null;
3058
+ const message: Message = fwdMsg1
3059
+ ? { ...fwdMsg1, forward: true }
3060
+ : {
3061
+ authorID: mail.authorID,
3062
+ decrypted: true,
3063
+ direction: "incoming",
3064
+ forward: mail.forward,
3065
+ group: mail.group
3066
+ ? uuid.stringify(mail.group)
3067
+ : null,
3068
+ mailID: mail.mailID,
3069
+ message: plaintext,
3070
+ nonce: XUtils.encodeHex(
3071
+ new Uint8Array(mail.nonce),
3072
+ ),
3073
+ readerID: mail.readerID,
3074
+ recipient: mail.recipient,
3075
+ sender: mail.sender,
3076
+ timestamp: timestamp,
3077
+ };
3078
+
3079
+ this.emitter.emit("message", message);
3080
+ if (libvexDebugDmEnabled()) {
3081
+ try {
3082
+ debugLibvexDm(
3083
+ "readMail initial: ok (emit message)",
3084
+ {
3085
+ mailID: mail.mailID,
3086
+ preKeyIndex: String(preKeyIndex),
3087
+ thisDevice:
3088
+ this.getDevice().deviceID,
3089
+ plaintextLen: String(
3090
+ plaintext.length,
3091
+ ),
3092
+ },
3093
+ );
3094
+ } catch {
3095
+ debugLibvexDm(
3096
+ "readMail initial: ok (emit message)",
3097
+ {
3098
+ mailID: mail.mailID,
3099
+ },
3100
+ );
3101
+ }
3102
+ }
3103
+
3104
+ // preKeyIndex 0 = med prekey only (no OTK in the X3DH path). Do
3105
+ // not call deleteOneTimeKey(0) — that is not "remove OTK row 0".
3106
+ if (preKeyIndex !== 0) {
3107
+ await this.database.deleteOneTimeKey(
3108
+ preKeyIndex,
3109
+ );
3110
+ }
3111
+
3112
+ const deviceEntry = await this.getDeviceByID(
3113
+ mail.sender,
3114
+ );
3115
+ if (!deviceEntry) {
3116
+ throw new Error("Couldn't get device entry.");
3117
+ }
3118
+ const [userEntry, _userErr] = await this.fetchUser(
3119
+ deviceEntry.owner,
3120
+ );
3121
+ if (!userEntry) {
3122
+ throw new Error("Couldn't get user entry.");
3123
+ }
3124
+
3125
+ this.userRecords[userEntry.userID] = userEntry;
3126
+ this.deviceRecords[deviceEntry.deviceID] =
3127
+ deviceEntry;
3128
+
3129
+ // save session
3130
+ const newSession: SessionSQL = {
3131
+ deviceID: mail.sender,
3132
+ fingerprint: XUtils.encodeHex(AD),
3133
+ lastUsed: new Date().toISOString(),
3134
+ mode: "receiver",
3135
+ publicKey: XUtils.encodeHex(PK),
3136
+ sessionID: uuid.v4(),
3137
+ SK: XUtils.encodeHex(SK),
3138
+ userID: userEntry.userID,
3139
+ verified: false,
3140
+ };
3141
+ await this.database.saveSession(newSession);
2611
3142
 
2612
- this.userRecords[userEntry.userID] = userEntry;
2613
- this.deviceRecords[deviceEntry.deviceID] = deviceEntry;
2614
-
2615
- // save session
2616
- const newSession: SessionSQL = {
2617
- deviceID: mail.sender,
2618
- fingerprint: XUtils.encodeHex(AD),
2619
- lastUsed: new Date().toISOString(),
2620
- mode: "receiver",
2621
- publicKey: XUtils.encodeHex(PK),
2622
- sessionID: uuid.v4(),
2623
- SK: XUtils.encodeHex(SK),
2624
- userID: userEntry.userID,
2625
- verified: false,
2626
- };
2627
- await this.database.saveSession(newSession);
2628
-
2629
- const [user] = await this.fetchUser(newSession.userID);
3143
+ const [user] = await this.fetchUser(
3144
+ newSession.userID,
3145
+ );
2630
3146
 
2631
- if (user) {
2632
- this.emitter.emit("session", newSession, user);
3147
+ if (user) {
3148
+ this.emitter.emit("session", newSession, user);
3149
+ } else {
3150
+ }
2633
3151
  } else {
3152
+ if (libvexDebugDmEnabled()) {
3153
+ debugLibvexDm(
3154
+ "readMail initial: abort (xSecretboxOpen null)",
3155
+ {
3156
+ mailID: mail.mailID,
3157
+ preKeyIndex: String(preKeyIndex),
3158
+ },
3159
+ );
3160
+ }
2634
3161
  }
2635
- } else {
2636
- }
2637
- break;
2638
- case MailType.subsequent:
2639
- const publicKey = Client.deserializeExtra(
2640
- mail.mailType,
2641
- new Uint8Array(mail.extra),
2642
- )[0];
2643
- if (!publicKey) {
2644
- throw new Error(
2645
- "Malformed subsequent mail extra: missing publicKey",
2646
- );
2647
- }
2648
- let session = await this.getSessionByPubkey(publicKey);
2649
- let retries = 0;
2650
- while (!session) {
2651
- if (retries >= 3) {
2652
- break;
3162
+ break;
3163
+ case MailType.subsequent: {
3164
+ const extraBuf = new Uint8Array(mail.extra);
3165
+ const publicKey = isFipsSubsequentExtraV1(extraBuf)
3166
+ ? decodeFipsSubsequentExtraV1(extraBuf)
3167
+ : Client.deserializeExtra(
3168
+ mail.mailType,
3169
+ extraBuf,
3170
+ )[0];
3171
+ if (!publicKey) {
3172
+ throw new Error(
3173
+ "Malformed subsequent mail extra: missing publicKey",
3174
+ );
3175
+ }
3176
+ let session = await this.getSessionByPubkey(publicKey);
3177
+ let retries = 0;
3178
+ while (!session) {
3179
+ if (retries >= 3) {
3180
+ break;
3181
+ }
3182
+ await sleep(100 * 2 ** retries);
3183
+ retries++;
3184
+ session = await this.getSessionByPubkey(publicKey);
2653
3185
  }
2654
- await sleep(100 * 2 ** retries);
2655
- retries++;
2656
- session = await this.getSessionByPubkey(publicKey);
2657
- }
2658
3186
 
2659
- if (!session) {
2660
- void healSession();
2661
- return;
2662
- }
2663
- const HMAC = xHMAC(mail, session.SK);
3187
+ if (!session) {
3188
+ void healSession();
3189
+ return;
3190
+ }
3191
+ const HMAC = xHMAC(mail, session.SK);
2664
3192
 
2665
- if (!XUtils.bytesEqual(HMAC, header)) {
2666
- void healSession();
2667
- return;
2668
- }
3193
+ if (!XUtils.bytesEqual(HMAC, header)) {
3194
+ void healSession();
3195
+ return;
3196
+ }
2669
3197
 
2670
- const decrypted = xSecretboxOpen(
2671
- new Uint8Array(mail.cipher),
2672
- new Uint8Array(mail.nonce),
2673
- session.SK,
2674
- );
3198
+ const decrypted = await xSecretboxOpenAsync(
3199
+ new Uint8Array(mail.cipher),
3200
+ new Uint8Array(mail.nonce),
3201
+ session.SK,
3202
+ );
2675
3203
 
2676
- if (decrypted) {
2677
- const fwdMsg2 = mail.forward
2678
- ? messageSchema.parse(msgpack.decode(decrypted))
2679
- : null;
2680
- const message: Message = fwdMsg2
2681
- ? {
2682
- ...fwdMsg2,
2683
- forward: true,
2684
- }
2685
- : {
2686
- authorID: mail.authorID,
2687
- decrypted: true,
2688
- direction: "incoming",
2689
- forward: mail.forward,
2690
- group: mail.group
2691
- ? uuid.stringify(mail.group)
2692
- : null,
2693
- mailID: mail.mailID,
2694
- message: XUtils.encodeUTF8(decrypted),
2695
- nonce: XUtils.encodeHex(
2696
- new Uint8Array(mail.nonce),
2697
- ),
2698
- readerID: mail.readerID,
2699
- recipient: mail.recipient,
2700
- sender: mail.sender,
2701
- timestamp: timestamp,
2702
- };
2703
- this.emitter.emit("message", message);
2704
-
2705
- void this.database.markSessionUsed(session.sessionID);
2706
- } else {
2707
- void healSession();
2708
-
2709
- // emit the message
2710
- const message: Message = {
2711
- authorID: mail.authorID,
2712
- decrypted: false,
2713
- direction: "incoming",
2714
- forward: mail.forward,
2715
- group: mail.group
2716
- ? uuid.stringify(mail.group)
2717
- : null,
2718
- mailID: mail.mailID,
2719
- message: "",
2720
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
2721
- readerID: mail.readerID,
2722
- recipient: mail.recipient,
2723
- sender: mail.sender,
2724
- timestamp: timestamp,
2725
- };
2726
- this.emitter.emit("message", message);
3204
+ if (decrypted) {
3205
+ const fwdMsg2 = mail.forward
3206
+ ? messageSchema.parse(msgpack.decode(decrypted))
3207
+ : null;
3208
+ const message: Message = fwdMsg2
3209
+ ? {
3210
+ ...fwdMsg2,
3211
+ forward: true,
3212
+ }
3213
+ : {
3214
+ authorID: mail.authorID,
3215
+ decrypted: true,
3216
+ direction: "incoming",
3217
+ forward: mail.forward,
3218
+ group: mail.group
3219
+ ? uuid.stringify(mail.group)
3220
+ : null,
3221
+ mailID: mail.mailID,
3222
+ message: XUtils.encodeUTF8(decrypted),
3223
+ nonce: XUtils.encodeHex(
3224
+ new Uint8Array(mail.nonce),
3225
+ ),
3226
+ readerID: mail.readerID,
3227
+ recipient: mail.recipient,
3228
+ sender: mail.sender,
3229
+ timestamp: timestamp,
3230
+ };
3231
+ this.emitter.emit("message", message);
3232
+
3233
+ void this.database.markSessionUsed(
3234
+ session.sessionID,
3235
+ );
3236
+ } else {
3237
+ void healSession();
3238
+
3239
+ // emit the message
3240
+ const message: Message = {
3241
+ authorID: mail.authorID,
3242
+ decrypted: false,
3243
+ direction: "incoming",
3244
+ forward: mail.forward,
3245
+ group: mail.group
3246
+ ? uuid.stringify(mail.group)
3247
+ : null,
3248
+ mailID: mail.mailID,
3249
+ message: "",
3250
+ nonce: XUtils.encodeHex(
3251
+ new Uint8Array(mail.nonce),
3252
+ ),
3253
+ readerID: mail.readerID,
3254
+ recipient: mail.recipient,
3255
+ sender: mail.sender,
3256
+ timestamp: timestamp,
3257
+ };
3258
+ this.emitter.emit("message", message);
3259
+ }
3260
+ break;
2727
3261
  }
2728
- break;
2729
- default:
2730
- break;
2731
- }
3262
+ default:
3263
+ break;
3264
+ }
3265
+ });
2732
3266
  } finally {
2733
3267
  this.reading = false;
2734
3268
  }
@@ -2763,9 +3297,12 @@ export class Client {
2763
3297
  throw new Error("Couldn't fetch token.");
2764
3298
  }
2765
3299
 
3300
+ // Stored on Spire for signature verification: Ed25519 (hex) in tweetnacl;
3301
+ // P-256 ECDSA SPKI (hex) in FIPS. The server maps this to a raw ECDH
3302
+ // identity in `getKeyBundle` for X3DH; see spire `Database.getKeyBundle`.
2766
3303
  const signKey = this.getKeys().public;
2767
3304
  const signed = XUtils.encodeHex(
2768
- xSign(
3305
+ await xSignAsync(
2769
3306
  Uint8Array.from(uuid.parse(token.key)),
2770
3307
  this.signKeys.secretKey,
2771
3308
  ),
@@ -2794,9 +3331,9 @@ export class Client {
2794
3331
  return decodeAxios(DeviceCodec, res.data);
2795
3332
  }
2796
3333
 
2797
- private respond(msg: ChallMsg) {
3334
+ private async respond(msg: ChallMsg) {
2798
3335
  const response: RespMsg = {
2799
- signed: xSign(
3336
+ signed: await xSignAsync(
2800
3337
  new Uint8Array(msg.challenge),
2801
3338
  this.signKeys.secretKey,
2802
3339
  ),
@@ -2854,7 +3391,7 @@ export class Client {
2854
3391
  );
2855
3392
  const fileData = res.data;
2856
3393
 
2857
- const decrypted = xSecretboxOpen(
3394
+ const decrypted = await xSecretboxOpenAsync(
2858
3395
  new Uint8Array(fileData),
2859
3396
  XUtils.decodeHex(details.nonce),
2860
3397
  XUtils.decodeHex(key),
@@ -2942,7 +3479,6 @@ export class Client {
2942
3479
  }
2943
3480
 
2944
3481
  const mailID = uuid.v4();
2945
- const promises: Array<Promise<void>> = [];
2946
3482
 
2947
3483
  const userIDs = [...new Set(userList.map((user) => user.userID))];
2948
3484
  const devices = await this.getMultiUserDeviceList(userIDs);
@@ -2952,24 +3488,19 @@ export class Client {
2952
3488
  if (!ownerRecord) {
2953
3489
  continue;
2954
3490
  }
2955
- promises.push(
2956
- this.sendMail(
3491
+ try {
3492
+ await this.sendMail(
2957
3493
  device,
2958
3494
  ownerRecord,
2959
3495
  XUtils.decodeUTF8(message),
2960
3496
  uuidToUint8(channelID),
2961
3497
  mailID,
2962
3498
  false,
2963
- ),
2964
- );
2965
- }
2966
- void Promise.allSettled(promises).then((results) => {
2967
- for (const result of results) {
2968
- const { status } = result;
2969
- if (status === "rejected") {
2970
- }
3499
+ );
3500
+ } catch {
3501
+ /* best-effort; each device needs its own X3DH handshake (sequential) */
2971
3502
  }
2972
- });
3503
+ }
2973
3504
  }
2974
3505
 
2975
3506
  /* Sends encrypted mail to a user. */
@@ -2986,87 +3517,119 @@ export class Client {
2986
3517
  await sleep(100);
2987
3518
  }
2988
3519
  this.sending.set(device.deviceID, device);
3520
+ try {
3521
+ const session = await this.database.getSessionByDeviceID(
3522
+ device.deviceID,
3523
+ );
2989
3524
 
2990
- const session = await this.database.getSessionByDeviceID(
2991
- device.deviceID,
2992
- );
2993
-
2994
- if (!session || retry) {
2995
- await this.createSession(device, user, msg, group, mailID, forward);
2996
- return;
2997
- }
3525
+ if (!session || retry) {
3526
+ if (libvexDebugDmEnabled()) {
3527
+ debugLibvexDm("sendMail: createSession path", {
3528
+ peerDevice: device.deviceID,
3529
+ retry: String(retry),
3530
+ hasSession: String(!!session),
3531
+ });
3532
+ }
3533
+ await this.createSession(
3534
+ device,
3535
+ user,
3536
+ msg,
3537
+ group,
3538
+ mailID,
3539
+ forward,
3540
+ false,
3541
+ );
3542
+ if (libvexDebugDmEnabled()) {
3543
+ debugLibvexDm("sendMail: createSession returned", {
3544
+ peerDevice: device.deviceID,
3545
+ });
3546
+ }
3547
+ return;
3548
+ }
2998
3549
 
2999
- const nonce = xMakeNonce();
3000
- const cipher = xSecretbox(msg, nonce, session.SK);
3001
- const extra = session.publicKey;
3550
+ if (libvexDebugDmEnabled()) {
3551
+ debugLibvexDm("sendMail: subsequent path", {
3552
+ peerDevice: device.deviceID,
3553
+ });
3554
+ }
3002
3555
 
3003
- const mail: MailWS = {
3004
- authorID: this.getUser().userID,
3005
- cipher,
3006
- extra,
3007
- forward,
3008
- group,
3009
- mailID: mailID || uuid.v4(),
3010
- mailType: MailType.subsequent,
3011
- nonce,
3012
- readerID: session.userID,
3013
- recipient: device.deviceID,
3014
- sender: this.getDevice().deviceID,
3015
- };
3556
+ const nonce = xMakeNonce();
3557
+ const cipher = await xSecretboxAsync(msg, nonce, session.SK);
3558
+ const extra =
3559
+ this.cryptoProfile === "fips"
3560
+ ? encodeFipsSubsequentExtraV1(session.publicKey)
3561
+ : session.publicKey;
3562
+
3563
+ const mail: MailWS = {
3564
+ authorID: this.getUser().userID,
3565
+ cipher,
3566
+ extra,
3567
+ forward,
3568
+ group,
3569
+ mailID: mailID || uuid.v4(),
3570
+ mailType: MailType.subsequent,
3571
+ nonce,
3572
+ readerID: session.userID,
3573
+ recipient: device.deviceID,
3574
+ sender: this.getDevice().deviceID,
3575
+ };
3016
3576
 
3017
- const msgb: ResourceMsg = {
3018
- action: "CREATE",
3019
- data: mail,
3020
- resourceType: "mail",
3021
- transmissionID: uuid.v4(),
3022
- type: "resource",
3023
- };
3577
+ const msgb: ResourceMsg = {
3578
+ action: "CREATE",
3579
+ data: mail,
3580
+ resourceType: "mail",
3581
+ transmissionID: uuid.v4(),
3582
+ type: "resource",
3583
+ };
3024
3584
 
3025
- const hmac = xHMAC(mail, session.SK);
3026
-
3027
- const fwdOut = forward
3028
- ? messageSchema.parse(msgpack.decode(msg))
3029
- : null;
3030
- const outMsg: Message = fwdOut
3031
- ? { ...fwdOut, forward: true }
3032
- : {
3033
- authorID: mail.authorID,
3034
- decrypted: true,
3035
- direction: "outgoing",
3036
- forward: mail.forward,
3037
- group: mail.group ? uuid.stringify(mail.group) : null,
3038
- mailID: mail.mailID,
3039
- message: XUtils.encodeUTF8(msg),
3040
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3041
- readerID: mail.readerID,
3042
- recipient: mail.recipient,
3043
- sender: mail.sender,
3044
- timestamp: new Date().toISOString(),
3045
- };
3046
- this.emitter.emit("message", outMsg);
3047
-
3048
- await new Promise((res, rej) => {
3049
- const callback = (packedMsg: Uint8Array) => {
3050
- const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
3051
- if (receivedMsg.transmissionID === msgb.transmissionID) {
3052
- this.socket.off("message", callback);
3053
- const parsed = WSMessageSchema.safeParse(receivedMsg);
3054
- if (parsed.success && parsed.data.type === "success") {
3055
- res(parsed.data.data);
3056
- } else {
3057
- rej(
3058
- new Error(
3059
- "Mail delivery failed: " +
3060
- JSON.stringify(receivedMsg),
3061
- ),
3062
- );
3585
+ const hmac = xHMAC(mail, session.SK);
3586
+
3587
+ const fwdOut = forward
3588
+ ? messageSchema.parse(msgpack.decode(msg))
3589
+ : null;
3590
+ const outMsg: Message = fwdOut
3591
+ ? { ...fwdOut, forward: true }
3592
+ : {
3593
+ authorID: mail.authorID,
3594
+ decrypted: true,
3595
+ direction: "outgoing",
3596
+ forward: mail.forward,
3597
+ group: mail.group ? uuid.stringify(mail.group) : null,
3598
+ mailID: mail.mailID,
3599
+ message: XUtils.encodeUTF8(msg),
3600
+ nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3601
+ readerID: mail.readerID,
3602
+ recipient: mail.recipient,
3603
+ sender: mail.sender,
3604
+ timestamp: new Date().toISOString(),
3605
+ };
3606
+ this.emitter.emit("message", outMsg);
3607
+
3608
+ await new Promise((res, rej) => {
3609
+ const callback = (packedMsg: Uint8Array) => {
3610
+ const [_header, receivedMsg] =
3611
+ XUtils.unpackMessage(packedMsg);
3612
+ if (receivedMsg.transmissionID === msgb.transmissionID) {
3613
+ this.socket.off("message", callback);
3614
+ const parsed = WSMessageSchema.safeParse(receivedMsg);
3615
+ if (parsed.success && parsed.data.type === "success") {
3616
+ res(parsed.data.data);
3617
+ } else {
3618
+ rej(
3619
+ new Error(
3620
+ "Mail delivery failed: " +
3621
+ JSON.stringify(receivedMsg),
3622
+ ),
3623
+ );
3624
+ }
3063
3625
  }
3064
- }
3065
- };
3066
- this.socket.on("message", callback);
3067
- void this.send(msgb, hmac);
3068
- });
3069
- this.sending.delete(device.deviceID);
3626
+ };
3627
+ this.socket.on("message", callback);
3628
+ void this.send(msgb, hmac);
3629
+ });
3630
+ } finally {
3631
+ this.sending.delete(device.deviceID);
3632
+ }
3070
3633
  }
3071
3634
 
3072
3635
  private async sendMessage(userID: string, message: string): Promise<void> {
@@ -3079,31 +3642,107 @@ export class Client {
3079
3642
  throw new Error("Couldn't get user entry.");
3080
3643
  }
3081
3644
 
3082
- const deviceList = await this.fetchUserDeviceListWithBackoff(
3645
+ const afterBackoff = await this.fetchUserDeviceListWithBackoff(
3083
3646
  userID,
3084
3647
  "peer",
3085
3648
  );
3086
- const mailID = uuid.v4();
3087
- const promises: Array<Promise<void>> = [];
3649
+ // Back-to-back GETs, merged by deviceID: a second read can list a device
3650
+ // that was not visible in the first snapshot (automation + multi-device)
3651
+ // without adding a fixed sleep.
3652
+ let deviceListRaw: Device[] = afterBackoff;
3653
+ try {
3654
+ const again = await this.fetchUserDeviceListOnce(userID);
3655
+ const byId = new Map<string, Device>();
3656
+ for (const d of afterBackoff) {
3657
+ byId.set(d.deviceID, d);
3658
+ }
3659
+ for (const d of again) {
3660
+ byId.set(d.deviceID, d);
3661
+ }
3662
+ deviceListRaw = [...byId.values()];
3663
+ } catch {
3664
+ deviceListRaw = afterBackoff;
3665
+ }
3666
+ if (deviceListRaw.length === 0) {
3667
+ throw new Error(
3668
+ "No devices for user — cannot send direct message.",
3669
+ );
3670
+ }
3671
+ // Stable order (Peer device list is otherwise DB-order dependent).
3672
+ const deviceList = [...deviceListRaw].sort((a, b) =>
3673
+ a.deviceID.localeCompare(b.deviceID, "en"),
3674
+ );
3675
+ if (libvexDebugDmEnabled()) {
3676
+ debugLibvexDm(
3677
+ "sendMessage: peer device list (merged, sorted)",
3678
+ {
3679
+ userID,
3680
+ nAfterBackoff: String(afterBackoff.length),
3681
+ nMerged: String(deviceListRaw.length),
3682
+ nSorted: String(deviceList.length),
3683
+ ourDevice: this.getDevice().deviceID,
3684
+ },
3685
+ );
3686
+ for (const [i, d] of deviceList.entries()) {
3687
+ debugLibvexDm(`sendMessage: device[${String(i)}]`, {
3688
+ deviceID: d.deviceID,
3689
+ });
3690
+ }
3691
+ }
3692
+ let lastErr: unknown;
3693
+ let failCount = 0;
3088
3694
  for (const device of deviceList) {
3089
- promises.push(
3090
- this.sendMail(
3695
+ const mailID = uuid.v4();
3696
+ try {
3697
+ if (libvexDebugDmEnabled()) {
3698
+ debugLibvexDm("sendMessage: sendMail start", {
3699
+ recipientDevice: device.deviceID,
3700
+ mailID,
3701
+ });
3702
+ }
3703
+ await this.sendMail(
3091
3704
  device,
3092
3705
  userEntry,
3093
3706
  XUtils.decodeUTF8(message),
3094
3707
  null,
3095
3708
  mailID,
3096
3709
  false,
3097
- ),
3098
- );
3099
- }
3100
- void Promise.allSettled(promises).then((results) => {
3101
- for (const result of results) {
3102
- const { status } = result;
3103
- if (status === "rejected") {
3710
+ );
3711
+ if (libvexDebugDmEnabled()) {
3712
+ debugLibvexDm("sendMessage: sendMail ok", {
3713
+ recipientDevice: device.deviceID,
3714
+ });
3715
+ }
3716
+ } catch (e) {
3717
+ if (libvexDebugDmEnabled()) {
3718
+ // eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
3719
+ console.error(
3720
+ "[libvex:debug-dm] sendMessage: sendMail failed for device",
3721
+ device.deviceID,
3722
+ e,
3723
+ );
3104
3724
  }
3725
+ lastErr = e;
3726
+ failCount += 1;
3105
3727
  }
3106
- });
3728
+ }
3729
+ if (failCount > 0) {
3730
+ const base =
3731
+ lastErr instanceof Error
3732
+ ? lastErr
3733
+ : new Error(String(lastErr));
3734
+ if (failCount === deviceList.length) {
3735
+ throw base;
3736
+ }
3737
+ // Multi-device: do not “succeed” when only one device of several got mail —
3738
+ // callers and tests have no per-device result and the other copy times out.
3739
+ const partial = new Error(
3740
+ `Direct message failed to reach ${String(failCount)} of ` +
3741
+ `${String(deviceList.length)} peer device(s) (X3DH/post).`,
3742
+ );
3743
+ partial.cause = base;
3744
+ throw partial;
3745
+ }
3107
3746
  } catch (err: unknown) {
3108
3747
  throw err;
3109
3748
  }
@@ -3130,7 +3769,7 @@ export class Client {
3130
3769
  const otks: UnsavedPreKey[] = [];
3131
3770
 
3132
3771
  for (let i = 0; i < amount; i++) {
3133
- otks[i] = this.createPreKey();
3772
+ otks.push(await this.createPreKey());
3134
3773
  }
3135
3774
 
3136
3775
  const savedKeys = await this.database.savePreKeys(otks, true);