@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/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
 
@@ -872,6 +1000,11 @@ export class Client {
872
1000
  private readonly mailInterval?: NodeJS.Timeout;
873
1001
 
874
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;
875
1008
  /* Retrieves the userID with the user identifier.
876
1009
  user identifier is checked for userID, then signkey,
877
1010
  and finally falls back to username. */
@@ -898,14 +1031,21 @@ export class Client {
898
1031
  private userRecords: Record<string, User> = {};
899
1032
 
900
1033
  private xKeyRing?: XKeyRing;
1034
+ private readonly cryptoProfile: CryptoProfile;
901
1035
 
902
1036
  private constructor(
903
- privateKey?: string,
1037
+ material: {
1038
+ cryptoProfile: CryptoProfile;
1039
+ idKeys: KeyPair;
1040
+ signKeys: KeyPair;
1041
+ },
904
1042
  options?: ClientOptions,
905
1043
  storage?: Storage,
906
1044
  ) {
907
- // (no super — composition, not inheritance)
908
1045
  this.options = options;
1046
+ this.cryptoProfile = material.cryptoProfile;
1047
+ this.signKeys = material.signKeys;
1048
+ this.idKeys = material.idKeys;
909
1049
 
910
1050
  if (options?.unsafeHttp) {
911
1051
  const env = Client.getNodeEnv();
@@ -920,15 +1060,6 @@ export class Client {
920
1060
  this.prefixes = { HTTP: "https://", WS: "wss://" };
921
1061
  }
922
1062
 
923
- this.signKeys = privateKey
924
- ? xSignKeyPairFromSecret(XUtils.decodeHex(privateKey))
925
- : xSignKeyPair();
926
- this.idKeys = XKeyConvert.convertKeyPair(this.signKeys);
927
-
928
- if (!this.idKeys) {
929
- throw new Error("Could not convert key to X25519!");
930
- }
931
-
932
1063
  this.host = options?.host || "api.vex.wtf";
933
1064
  const dbFileName = options?.inMemoryDb
934
1065
  ? ":memory:"
@@ -977,32 +1108,95 @@ export class Client {
977
1108
  options?: ClientOptions,
978
1109
  storage?: Storage,
979
1110
  ): Promise<Client> => {
980
- const opts = options;
981
- 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
+
982
1153
  let resolvedStorage = storage;
983
1154
  if (!resolvedStorage) {
984
1155
  const { createNodeStorage } = await import("./storage/node.js");
985
- const dbFileName = opts?.inMemoryDb
1156
+ const dbFileName = options?.inMemoryDb
986
1157
  ? ":memory:"
987
- : XUtils.encodeHex(
988
- xSignKeyPairFromSecret(XUtils.decodeHex(sk)).publicKey,
989
- ) + ".sqlite";
990
- const dbPath = opts?.dbFolder
991
- ? opts.dbFolder + "/" + dbFileName
1158
+ : XUtils.encodeHex(signKeys.publicKey) + ".sqlite";
1159
+ const dbPath = options?.dbFolder
1160
+ ? options.dbFolder + "/" + dbFileName
992
1161
  : dbFileName;
993
- resolvedStorage = createNodeStorage(dbPath, sk);
1162
+ resolvedStorage = createNodeStorage(dbPath, atRestAes);
994
1163
  }
995
- 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
+ );
996
1176
  await client.init();
997
1177
  return client;
998
1178
  };
999
1179
 
1000
1180
  /**
1001
- * Generates an ed25519 secret key as a hex string.
1002
- *
1003
- * @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).
1004
1183
  */
1005
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
+ }
1006
1200
  return XUtils.encodeHex(xSignKeyPair().secretKey);
1007
1201
  }
1008
1202
 
@@ -1026,17 +1220,23 @@ export class Client {
1026
1220
  extra: Uint8Array,
1027
1221
  ): Uint8Array[] {
1028
1222
  switch (type) {
1029
- case MailType.initial:
1030
- /* 32 bytes for signkey, 32 bytes for ephemeral key,
1031
- 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) */
1032
1229
  const signKey = extra.slice(0, 32);
1033
1230
  const ephKey = extra.slice(32, 64);
1034
1231
  const ad = extra.slice(96, 164);
1035
1232
  const index = extra.slice(164, 170);
1036
1233
  return [signKey, ephKey, ad, index];
1234
+ }
1037
1235
  case MailType.subsequent:
1038
- const publicKey = extra;
1039
- return [publicKey];
1236
+ if (isFipsSubsequentExtraV1(extra)) {
1237
+ return [decodeFipsSubsequentExtraV1(extra)];
1238
+ }
1239
+ return [extra];
1040
1240
  default:
1041
1241
  return [];
1042
1242
  }
@@ -1172,14 +1372,14 @@ export class Client {
1172
1372
  if (!connectToken) {
1173
1373
  throw new Error("Couldn't get connect token.");
1174
1374
  }
1175
- const signed = xSign(
1375
+ const signedAsync = await xSignAsync(
1176
1376
  Uint8Array.from(uuid.parse(connectToken.key)),
1177
1377
  this.signKeys.secretKey,
1178
1378
  );
1179
1379
 
1180
1380
  const res = await this.http.post(
1181
1381
  this.getHost() + "/device/" + this.device.deviceID + "/connect",
1182
- msgpack.encode({ signed }),
1382
+ msgpack.encode({ signed: signedAsync }),
1183
1383
  { headers: { "Content-Type": "application/msgpack" } },
1184
1384
  );
1185
1385
  const { deviceToken } = decodeAxios(ConnectResponseCodec, res.data);
@@ -1192,6 +1392,54 @@ export class Client {
1192
1392
  await this.negotiateOTK();
1193
1393
  }
1194
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
+
1195
1443
  /**
1196
1444
  * Delete all local data — message history, encryption sessions, and prekeys.
1197
1445
  * Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
@@ -1259,6 +1507,12 @@ export class Client {
1259
1507
  this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
1260
1508
  return { ok: true };
1261
1509
  } catch (err: unknown) {
1510
+ if (isAxiosError(err) && err.response) {
1511
+ return {
1512
+ error: spireErrorBodyMessage(err.response.data),
1513
+ ok: false,
1514
+ };
1515
+ }
1262
1516
  const error = err instanceof Error ? err.message : String(err);
1263
1517
  return { error, ok: false };
1264
1518
  }
@@ -1294,7 +1548,10 @@ export class Client {
1294
1548
  );
1295
1549
 
1296
1550
  const signed = XUtils.encodeHex(
1297
- xSign(XUtils.decodeHex(challenge), this.signKeys.secretKey),
1551
+ await xSignAsync(
1552
+ XUtils.decodeHex(challenge),
1553
+ this.signKeys.secretKey,
1554
+ ),
1298
1555
  );
1299
1556
 
1300
1557
  const verifyRes = await this.http.post(
@@ -1384,7 +1641,7 @@ export class Client {
1384
1641
  if (regKey) {
1385
1642
  const signKey = XUtils.encodeHex(this.signKeys.publicKey);
1386
1643
  const signed = XUtils.encodeHex(
1387
- xSign(
1644
+ await xSignAsync(
1388
1645
  Uint8Array.from(uuid.parse(regKey.key)),
1389
1646
  this.signKeys.secretKey,
1390
1647
  ),
@@ -1414,12 +1671,10 @@ export class Client {
1414
1671
  return [this.getUser(), null];
1415
1672
  } catch (err: unknown) {
1416
1673
  if (isAxiosError(err) && err.response) {
1417
- const raw: unknown = err.response.data;
1418
- const msg =
1419
- raw instanceof ArrayBuffer || raw instanceof Uint8Array
1420
- ? new TextDecoder().decode(raw)
1421
- : String(raw);
1422
- return [null, new Error(msg)];
1674
+ return [
1675
+ null,
1676
+ new Error(spireErrorBodyMessage(err.response.data)),
1677
+ ];
1423
1678
  }
1424
1679
  return [
1425
1680
  null,
@@ -1496,60 +1751,69 @@ export class Client {
1496
1751
 
1497
1752
  // returns the file details and the encryption key
1498
1753
  private async createFile(file: Uint8Array): Promise<[FileSQL, string]> {
1499
- const nonce = xMakeNonce();
1500
- const key = xBoxKeyPair();
1501
- 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
+ );
1502
1765
 
1503
- if (typeof FormData !== "undefined") {
1504
- const fpayload = new FormData();
1505
- fpayload.set("owner", this.getDevice().deviceID);
1506
- fpayload.set("nonce", XUtils.encodeHex(nonce));
1507
- 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)]));
1508
1771
 
1509
- const fres = await this.http.post(
1510
- this.getHost() + "/file",
1511
- fpayload,
1512
- {
1513
- headers: { "Content-Type": "multipart/form-data" },
1514
- onUploadProgress: (progressEvent) => {
1515
- const percentCompleted = Math.round(
1516
- (progressEvent.loaded * 100) /
1517
- (progressEvent.total ?? 1),
1518
- );
1519
- const { loaded, total = 0 } = progressEvent;
1520
- const progress: FileProgress = {
1521
- direction: "upload",
1522
- loaded,
1523
- progress: percentCompleted,
1524
- token: XUtils.encodeHex(nonce),
1525
- total,
1526
- };
1527
- 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
+ },
1528
1792
  },
1529
- },
1530
- );
1531
- const fcreatedFile = decodeAxios(FileSQLCodec, fres.data);
1793
+ );
1794
+ const fcreatedFile = decodeAxios(FileSQLCodec, fres.data);
1532
1795
 
1533
- return [fcreatedFile, XUtils.encodeHex(key.secretKey)];
1534
- }
1796
+ return [fcreatedFile, XUtils.encodeHex(fileKey)];
1797
+ }
1535
1798
 
1536
- const payload: {
1537
- file: string;
1538
- nonce: string;
1539
- owner: string;
1540
- } = {
1541
- file: XUtils.encodeBase64(box),
1542
- nonce: XUtils.encodeHex(nonce),
1543
- owner: this.getDevice().deviceID,
1544
- };
1545
- const res = await this.http.post(
1546
- this.getHost() + "/file/json",
1547
- msgpack.encode(payload),
1548
- { headers: { "Content-Type": "application/msgpack" } },
1549
- );
1550
- 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);
1551
1814
 
1552
- return [createdFile, XUtils.encodeHex(key.secretKey)];
1815
+ return [createdFile, XUtils.encodeHex(fileKey)];
1816
+ });
1553
1817
  }
1554
1818
 
1555
1819
  private async createInvite(serverID: string, duration: string) {
@@ -1567,14 +1831,15 @@ export class Client {
1567
1831
  return decodeAxios(InviteCodec, res.data);
1568
1832
  }
1569
1833
 
1570
- private createPreKey(): UnsavedPreKey {
1571
- 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);
1572
1840
  return {
1573
1841
  keyPair: preKeyPair,
1574
- signature: xSign(
1575
- xEncode(xConstants.CURVE, preKeyPair.publicKey),
1576
- this.signKeys.secretKey,
1577
- ),
1842
+ signature: await xSignAsync(toSign, this.signKeys.secretKey),
1578
1843
  };
1579
1844
  }
1580
1845
 
@@ -1585,6 +1850,27 @@ export class Client {
1585
1850
  return decodeAxios(ServerCodec, res.data);
1586
1851
  }
1587
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
+
1588
1874
  private async createSession(
1589
1875
  device: Device,
1590
1876
  user: User,
@@ -1594,164 +1880,198 @@ export class Client {
1594
1880
  part of a group message */
1595
1881
  mailID: null | string,
1596
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,
1597
1888
  ): Promise<void> {
1598
- let keyBundle: KeyBundle;
1889
+ return this.runWithThisCryptoProfile(async () => {
1890
+ let keyBundle: KeyBundle;
1599
1891
 
1600
- try {
1601
- keyBundle = await this.retrieveKeyBundle(device.deviceID);
1602
- } catch {
1603
- return;
1604
- }
1605
-
1606
- if (!this.xKeyRing) {
1607
- if (this.manuallyClosing) {
1608
- 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
+ );
1609
1904
  }
1610
- throw new Error("Key ring not initialized.");
1611
- }
1612
1905
 
1613
- // my keys
1614
- const IK_A = this.xKeyRing.identityKeys.secretKey;
1615
- const IK_AP = this.xKeyRing.identityKeys.publicKey;
1616
- const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
1617
-
1618
- // their keys
1619
- const IK_B_raw = XKeyConvert.convertPublicKey(
1620
- new Uint8Array(keyBundle.signKey),
1621
- );
1622
- if (!IK_B_raw) {
1623
- throw new Error("Could not convert sign key to X25519.");
1624
- }
1625
- const IK_B = IK_B_raw;
1626
- const SPK_B = new Uint8Array(keyBundle.preKey.publicKey);
1627
- const OPK_B = keyBundle.otk
1628
- ? new Uint8Array(keyBundle.otk.publicKey)
1629
- : null;
1630
-
1631
- // diffie hellman functions
1632
- const DH1 = xDH(new Uint8Array(IK_A), SPK_B);
1633
- const DH2 = xDH(new Uint8Array(EK_A), IK_B);
1634
- const DH3 = xDH(new Uint8Array(EK_A), SPK_B);
1635
- const DH4 = OPK_B ? xDH(new Uint8Array(EK_A), OPK_B) : null;
1636
-
1637
- // initial key material
1638
- const IKM = DH4 ? xConcat(DH1, DH2, DH3, DH4) : xConcat(DH1, DH2, DH3);
1639
-
1640
- // one time key index
1641
- const IDX = keyBundle.otk
1642
- ? XUtils.numberToUint8Arr(keyBundle.otk.index ?? 0)
1643
- : XUtils.numberToUint8Arr(0);
1644
-
1645
- // shared secret key
1646
- const SK = xKDF(IKM);
1647
- const PK = xBoxKeyPairFromSecret(SK).publicKey;
1648
-
1649
- const AD = xConcat(
1650
- xEncode(xConstants.CURVE, IK_AP),
1651
- xEncode(xConstants.CURVE, IK_B),
1652
- );
1653
-
1654
- const nonce = xMakeNonce();
1655
- const cipher = xSecretbox(message, nonce, SK);
1656
-
1657
- /* 32 bytes for signkey, 32 bytes for ephemeral key,
1658
- 68 bytes for AD, 6 bytes for otk index (empty for no otk) */
1659
- const extra = xConcat(
1660
- this.signKeys.publicKey,
1661
- this.xKeyRing.ephemeralKeys.publicKey,
1662
- PK,
1663
- AD,
1664
- IDX,
1665
- );
1906
+ if (!this.xKeyRing) {
1907
+ if (this.manuallyClosing) {
1908
+ return;
1909
+ }
1910
+ throw new Error("Key ring not initialized.");
1911
+ }
1666
1912
 
1667
- const mail: MailWS = {
1668
- authorID: this.getUser().userID,
1669
- cipher,
1670
- extra,
1671
- forward,
1672
- group,
1673
- mailID: mailID || uuid.v4(),
1674
- mailType: MailType.initial,
1675
- nonce,
1676
- readerID: user.userID,
1677
- recipient: device.deviceID,
1678
- sender: this.getDevice().deviceID,
1679
- };
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
+ };
1680
1999
 
1681
- const hmac = xHMAC(mail, SK);
2000
+ const hmac = xHMAC(mail, SK);
1682
2001
 
1683
- const msg: ResourceMsg = {
1684
- action: "CREATE",
1685
- data: mail,
1686
- resourceType: "mail",
1687
- transmissionID: uuid.v4(),
1688
- type: "resource",
1689
- };
2002
+ const msg: ResourceMsg = {
2003
+ action: "CREATE",
2004
+ data: mail,
2005
+ resourceType: "mail",
2006
+ transmissionID: uuid.v4(),
2007
+ type: "resource",
2008
+ };
1690
2009
 
1691
- // discard the ephemeral keys
1692
- this.newEphemeralKeys();
1693
-
1694
- const sessionEntry: SessionSQL = {
1695
- deviceID: device.deviceID,
1696
- fingerprint: XUtils.encodeHex(AD),
1697
- lastUsed: new Date().toISOString(),
1698
- mode: "initiator",
1699
- publicKey: XUtils.encodeHex(PK),
1700
- sessionID: uuid.v4(),
1701
- SK: XUtils.encodeHex(SK),
1702
- userID: user.userID,
1703
- verified: false,
1704
- };
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
+ };
1705
2024
 
1706
- await this.database.saveSession(sessionEntry);
1707
-
1708
- this.emitter.emit("session", sessionEntry, user);
1709
-
1710
- // emit the message
1711
- const forwardedMsg = forward
1712
- ? messageSchema.parse(msgpack.decode(message))
1713
- : null;
1714
- const emitMsg: Message = forwardedMsg
1715
- ? { ...forwardedMsg, forward: true }
1716
- : {
1717
- authorID: mail.authorID,
1718
- decrypted: true,
1719
- direction: "outgoing",
1720
- forward: mail.forward,
1721
- group: mail.group ? uuid.stringify(mail.group) : null,
1722
- mailID: mail.mailID,
1723
- message: XUtils.encodeUTF8(message),
1724
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
1725
- readerID: mail.readerID,
1726
- recipient: mail.recipient,
1727
- sender: mail.sender,
1728
- timestamp: new Date().toISOString(),
1729
- };
1730
- this.emitter.emit("message", emitMsg);
1731
-
1732
- // send mail and wait for response
1733
- await new Promise((res, rej) => {
1734
- const callback = (packedMsg: Uint8Array) => {
1735
- const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
1736
- if (receivedMsg.transmissionID === msg.transmissionID) {
1737
- this.socket.off("message", callback);
1738
- const parsed = WSMessageSchema.safeParse(receivedMsg);
1739
- if (parsed.success && parsed.data.type === "success") {
1740
- res(parsed.data.data);
1741
- } else {
1742
- rej(
1743
- new Error(
1744
- "Mail delivery failed: " +
1745
- JSON.stringify(receivedMsg),
1746
- ),
1747
- );
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
+ }
1748
2069
  }
1749
- }
1750
- };
1751
- this.socket.on("message", callback);
1752
- void this.send(msg, hmac);
2070
+ };
2071
+ this.socket.on("message", callback);
2072
+ void this.send(msg, hmac);
2073
+ });
1753
2074
  });
1754
- this.sending.delete(device.deviceID);
1755
2075
  }
1756
2076
 
1757
2077
  private async deleteChannel(channelID: string): Promise<void> {
@@ -1855,28 +2175,23 @@ export class Client {
1855
2175
  this.getUser().userID,
1856
2176
  "own",
1857
2177
  );
1858
- const promises = [];
1859
2178
  for (const device of devices) {
1860
- if (device.deviceID !== this.getDevice().deviceID) {
1861
- promises.push(
1862
- this.sendMail(
1863
- device,
1864
- this.getUser(),
1865
- msgBytes,
1866
- null,
1867
- copy.mailID,
1868
- true,
1869
- ),
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,
1870
2190
  );
2191
+ } catch {
2192
+ /* best-effort per device; parallel handshakes share ephemeral state */
1871
2193
  }
1872
2194
  }
1873
- void Promise.allSettled(promises).then((results) => {
1874
- for (const result of results) {
1875
- const { status } = result;
1876
- if (status === "rejected") {
1877
- }
1878
- }
1879
- });
1880
2195
  }
1881
2196
 
1882
2197
  private async getChannelByID(channelID: string): Promise<Channel | null> {
@@ -1982,16 +2297,49 @@ export class Client {
1982
2297
  .parse(msgpack.decode(mailBuffer));
1983
2298
  const inbox = rawInbox.sort((a, b) => b[2].localeCompare(a[2]));
1984
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
+
1985
2314
  for (const mailDetails of inbox) {
1986
2315
  const [mailHeader, mailBody, timestamp] = mailDetails;
1987
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
+ }
1988
2324
  await this.readMail(mailHeader, mailBody, timestamp);
1989
- } catch (_readMailErr) {
1990
- // 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
+ }
1991
2333
  }
1992
2334
  }
1993
- } catch (_fetchErr) {
1994
- // 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
+ }
1995
2343
  }
1996
2344
  this.fetchingMail = false;
1997
2345
  }
@@ -2303,7 +2651,7 @@ export class Client {
2303
2651
 
2304
2652
  switch (msg.type) {
2305
2653
  case "challenge":
2306
- this.respond(msg);
2654
+ void this.respond(msg);
2307
2655
  break;
2308
2656
  case "error":
2309
2657
  break;
@@ -2371,14 +2719,14 @@ export class Client {
2371
2719
  await this.submitOTK(needs);
2372
2720
  }
2373
2721
 
2374
- private newEphemeralKeys() {
2722
+ private async newEphemeralKeys() {
2375
2723
  if (!this.xKeyRing) {
2376
2724
  if (this.manuallyClosing) {
2377
2725
  return;
2378
2726
  }
2379
2727
  throw new Error("Key ring not initialized.");
2380
2728
  }
2381
- this.xKeyRing.ephemeralKeys = xBoxKeyPair();
2729
+ this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
2382
2730
  }
2383
2731
 
2384
2732
  private ping() {
@@ -2403,7 +2751,7 @@ export class Client {
2403
2751
  const preKeys: PreKeysCrypto =
2404
2752
  existingPreKeys ??
2405
2753
  (await (async () => {
2406
- const unsaved = this.createPreKey();
2754
+ const unsaved = await this.createPreKey();
2407
2755
  const [saved] = await this.database.savePreKeys(
2408
2756
  [unsaved],
2409
2757
  false,
@@ -2421,7 +2769,7 @@ export class Client {
2421
2769
  sqlSessionToCrypto(session);
2422
2770
  }
2423
2771
 
2424
- const ephemeralKeys = xBoxKeyPair();
2772
+ const ephemeralKeys = await xBoxKeyPairAsync();
2425
2773
 
2426
2774
  this.xKeyRing = {
2427
2775
  ephemeralKeys,
@@ -2431,11 +2779,15 @@ export class Client {
2431
2779
  }
2432
2780
 
2433
2781
  private async postAuth() {
2782
+ const versionAtStart = this.postAuthVersion;
2434
2783
  let count = 0;
2435
2784
  for (;;) {
2436
2785
  if (this.isManualCloseInFlight()) {
2437
2786
  return;
2438
2787
  }
2788
+ if (this.postAuthVersion !== versionAtStart) {
2789
+ return;
2790
+ }
2439
2791
  try {
2440
2792
  await this.getMail();
2441
2793
  count++;
@@ -2449,12 +2801,18 @@ export class Client {
2449
2801
  if (this.isManualCloseInFlight()) {
2450
2802
  return;
2451
2803
  }
2804
+ if (this.postAuthVersion !== versionAtStart) {
2805
+ return;
2806
+ }
2452
2807
  // Chunk the idle delay so `close()` can unwind instead of waiting
2453
2808
  // out one full 60s timer (which would keep the process alive).
2454
2809
  for (let i = 0; i < 60; i++) {
2455
2810
  if (this.isManualCloseInFlight()) {
2456
2811
  return;
2457
2812
  }
2813
+ if (this.postAuthVersion !== versionAtStart) {
2814
+ return;
2815
+ }
2458
2816
  await sleep(1000);
2459
2817
  }
2460
2818
  }
@@ -2470,11 +2828,28 @@ export class Client {
2470
2828
  timestamp: string,
2471
2829
  ) {
2472
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
+ }
2473
2843
  return;
2474
2844
  }
2475
2845
  this.seenMailIDs.add(mail.mailID);
2476
2846
 
2477
2847
  if (this.manuallyClosing) {
2848
+ if (libvexDebugDmEnabled()) {
2849
+ debugLibvexDm("readMail: skip (manually closing)", {
2850
+ mailID: mail.mailID,
2851
+ });
2852
+ }
2478
2853
  return;
2479
2854
  }
2480
2855
 
@@ -2487,267 +2862,407 @@ export class Client {
2487
2862
  this.reading = true;
2488
2863
 
2489
2864
  try {
2490
- const healSession = async () => {
2491
- if (this.manuallyClosing || !this.xKeyRing) {
2492
- return;
2493
- }
2494
- const deviceEntry = await this.getDeviceByID(mail.sender);
2495
- const [user, _err] = await this.fetchUser(mail.authorID);
2496
- if (deviceEntry && user) {
2497
- void this.createSession(
2498
- deviceEntry,
2499
- user,
2500
- XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`),
2501
- mail.group,
2502
- uuid.v4(),
2503
- false,
2504
- );
2505
- }
2506
- };
2507
-
2508
- switch (mail.mailType) {
2509
- case MailType.initial:
2510
- const extraParts = Client.deserializeExtra(
2511
- MailType.initial,
2512
- new Uint8Array(mail.extra),
2513
- );
2514
- const signKey = extraParts[0];
2515
- const ephKey = extraParts[1];
2516
- const indexBytes = extraParts[3];
2517
- if (!signKey || !ephKey || !indexBytes) {
2518
- throw new Error(
2519
- "Malformed initial mail extra: missing signKey, ephKey, or indexBytes",
2520
- );
2521
- }
2522
-
2523
- const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
2524
-
2525
- const otk =
2526
- preKeyIndex === 0
2527
- ? null
2528
- : await this.database.getOneTimeKey(preKeyIndex);
2529
-
2530
- if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
2865
+ await this.runWithThisCryptoProfile(async () => {
2866
+ const healSession = async () => {
2867
+ if (this.manuallyClosing || !this.xKeyRing) {
2531
2868
  return;
2532
2869
  }
2533
-
2534
- // their public keys
2535
- const IK_A_raw = XKeyConvert.convertPublicKey(signKey);
2536
- if (!IK_A_raw) {
2537
- 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
+ );
2538
2884
  }
2539
- const IK_A = IK_A_raw;
2540
- const EK_A = ephKey;
2885
+ };
2541
2886
 
2542
- if (!this.xKeyRing) {
2543
- return;
2544
- }
2545
- // my private keys
2546
- const IK_B = this.xKeyRing.identityKeys.secretKey;
2547
- const IK_BP = this.xKeyRing.identityKeys.publicKey;
2548
- const SPK_B = this.xKeyRing.preKeys.keyPair.secretKey;
2549
- const OPK_B = otk ? otk.keyPair.secretKey : null;
2550
-
2551
- // diffie hellman functions
2552
- const DH1 = xDH(SPK_B, IK_A);
2553
- const DH2 = xDH(IK_B, EK_A);
2554
- const DH3 = xDH(SPK_B, EK_A);
2555
- const DH4 = OPK_B ? xDH(OPK_B, EK_A) : null;
2556
-
2557
- // initial key material
2558
- const IKM = DH4
2559
- ? xConcat(DH1, DH2, DH3, DH4)
2560
- : xConcat(DH1, DH2, DH3);
2561
-
2562
- // shared secret key
2563
- const SK = xKDF(IKM);
2564
- const PK = xBoxKeyPairFromSecret(SK).publicKey;
2565
-
2566
- const hmac = xHMAC(mail, SK);
2567
-
2568
- // associated data
2569
- const AD = xConcat(
2570
- xEncode(xConstants.CURVE, IK_A),
2571
- xEncode(xConstants.CURVE, IK_BP),
2572
- );
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
+ }
2573
2901
 
2574
- if (!XUtils.bytesEqual(hmac, header)) {
2575
- return;
2576
- }
2577
- const unsealed = xSecretboxOpen(
2578
- new Uint8Array(mail.cipher),
2579
- new Uint8Array(mail.nonce),
2580
- SK,
2581
- );
2582
- if (unsealed) {
2583
- let plaintext = "";
2584
- if (!mail.forward) {
2585
- 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;
2586
2936
  }
2587
2937
 
2588
- // emit the message
2589
- const fwdMsg1 = mail.forward
2590
- ? messageSchema.parse(msgpack.decode(unsealed))
2591
- : null;
2592
- const message: Message = fwdMsg1
2593
- ? { ...fwdMsg1, forward: true }
2594
- : {
2595
- authorID: mail.authorID,
2596
- decrypted: true,
2597
- direction: "incoming",
2598
- forward: mail.forward,
2599
- group: mail.group
2600
- ? uuid.stringify(mail.group)
2601
- : null,
2602
- mailID: mail.mailID,
2603
- message: plaintext,
2604
- nonce: XUtils.encodeHex(
2605
- new Uint8Array(mail.nonce),
2606
- ),
2607
- readerID: mail.readerID,
2608
- recipient: mail.recipient,
2609
- sender: mail.sender,
2610
- timestamp: timestamp,
2611
- };
2612
-
2613
- this.emitter.emit("message", message);
2614
-
2615
- // discard onetimekey
2616
- await this.database.deleteOneTimeKey(preKeyIndex);
2617
-
2618
- const deviceEntry = await this.getDeviceByID(
2619
- mail.sender,
2938
+ // their public keys
2939
+ const fipsRead = isFipsInitialExtraV1(
2940
+ new Uint8Array(mail.extra),
2620
2941
  );
2621
- if (!deviceEntry) {
2622
- 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;
2623
2974
  }
2624
- const [userEntry, _userErr] = await this.fetchUser(
2625
- deviceEntry.owner,
2626
- );
2627
- if (!userEntry) {
2628
- 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;
2629
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);
2630
3142
 
2631
- this.userRecords[userEntry.userID] = userEntry;
2632
- this.deviceRecords[deviceEntry.deviceID] = deviceEntry;
2633
-
2634
- // save session
2635
- const newSession: SessionSQL = {
2636
- deviceID: mail.sender,
2637
- fingerprint: XUtils.encodeHex(AD),
2638
- lastUsed: new Date().toISOString(),
2639
- mode: "receiver",
2640
- publicKey: XUtils.encodeHex(PK),
2641
- sessionID: uuid.v4(),
2642
- SK: XUtils.encodeHex(SK),
2643
- userID: userEntry.userID,
2644
- verified: false,
2645
- };
2646
- await this.database.saveSession(newSession);
2647
-
2648
- const [user] = await this.fetchUser(newSession.userID);
3143
+ const [user] = await this.fetchUser(
3144
+ newSession.userID,
3145
+ );
2649
3146
 
2650
- if (user) {
2651
- this.emitter.emit("session", newSession, user);
3147
+ if (user) {
3148
+ this.emitter.emit("session", newSession, user);
3149
+ } else {
3150
+ }
2652
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
+ }
2653
3161
  }
2654
- } else {
2655
- }
2656
- break;
2657
- case MailType.subsequent:
2658
- const publicKey = Client.deserializeExtra(
2659
- mail.mailType,
2660
- new Uint8Array(mail.extra),
2661
- )[0];
2662
- if (!publicKey) {
2663
- throw new Error(
2664
- "Malformed subsequent mail extra: missing publicKey",
2665
- );
2666
- }
2667
- let session = await this.getSessionByPubkey(publicKey);
2668
- let retries = 0;
2669
- while (!session) {
2670
- if (retries >= 3) {
2671
- 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);
2672
3185
  }
2673
- await sleep(100 * 2 ** retries);
2674
- retries++;
2675
- session = await this.getSessionByPubkey(publicKey);
2676
- }
2677
3186
 
2678
- if (!session) {
2679
- void healSession();
2680
- return;
2681
- }
2682
- const HMAC = xHMAC(mail, session.SK);
3187
+ if (!session) {
3188
+ void healSession();
3189
+ return;
3190
+ }
3191
+ const HMAC = xHMAC(mail, session.SK);
2683
3192
 
2684
- if (!XUtils.bytesEqual(HMAC, header)) {
2685
- void healSession();
2686
- return;
2687
- }
3193
+ if (!XUtils.bytesEqual(HMAC, header)) {
3194
+ void healSession();
3195
+ return;
3196
+ }
2688
3197
 
2689
- const decrypted = xSecretboxOpen(
2690
- new Uint8Array(mail.cipher),
2691
- new Uint8Array(mail.nonce),
2692
- session.SK,
2693
- );
3198
+ const decrypted = await xSecretboxOpenAsync(
3199
+ new Uint8Array(mail.cipher),
3200
+ new Uint8Array(mail.nonce),
3201
+ session.SK,
3202
+ );
2694
3203
 
2695
- if (decrypted) {
2696
- const fwdMsg2 = mail.forward
2697
- ? messageSchema.parse(msgpack.decode(decrypted))
2698
- : null;
2699
- const message: Message = fwdMsg2
2700
- ? {
2701
- ...fwdMsg2,
2702
- forward: true,
2703
- }
2704
- : {
2705
- authorID: mail.authorID,
2706
- decrypted: true,
2707
- direction: "incoming",
2708
- forward: mail.forward,
2709
- group: mail.group
2710
- ? uuid.stringify(mail.group)
2711
- : null,
2712
- mailID: mail.mailID,
2713
- message: XUtils.encodeUTF8(decrypted),
2714
- nonce: XUtils.encodeHex(
2715
- new Uint8Array(mail.nonce),
2716
- ),
2717
- readerID: mail.readerID,
2718
- recipient: mail.recipient,
2719
- sender: mail.sender,
2720
- timestamp: timestamp,
2721
- };
2722
- this.emitter.emit("message", message);
2723
-
2724
- void this.database.markSessionUsed(session.sessionID);
2725
- } else {
2726
- void healSession();
2727
-
2728
- // emit the message
2729
- const message: Message = {
2730
- authorID: mail.authorID,
2731
- decrypted: false,
2732
- direction: "incoming",
2733
- forward: mail.forward,
2734
- group: mail.group
2735
- ? uuid.stringify(mail.group)
2736
- : null,
2737
- mailID: mail.mailID,
2738
- message: "",
2739
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
2740
- readerID: mail.readerID,
2741
- recipient: mail.recipient,
2742
- sender: mail.sender,
2743
- timestamp: timestamp,
2744
- };
2745
- 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;
2746
3261
  }
2747
- break;
2748
- default:
2749
- break;
2750
- }
3262
+ default:
3263
+ break;
3264
+ }
3265
+ });
2751
3266
  } finally {
2752
3267
  this.reading = false;
2753
3268
  }
@@ -2782,9 +3297,12 @@ export class Client {
2782
3297
  throw new Error("Couldn't fetch token.");
2783
3298
  }
2784
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`.
2785
3303
  const signKey = this.getKeys().public;
2786
3304
  const signed = XUtils.encodeHex(
2787
- xSign(
3305
+ await xSignAsync(
2788
3306
  Uint8Array.from(uuid.parse(token.key)),
2789
3307
  this.signKeys.secretKey,
2790
3308
  ),
@@ -2813,9 +3331,9 @@ export class Client {
2813
3331
  return decodeAxios(DeviceCodec, res.data);
2814
3332
  }
2815
3333
 
2816
- private respond(msg: ChallMsg) {
3334
+ private async respond(msg: ChallMsg) {
2817
3335
  const response: RespMsg = {
2818
- signed: xSign(
3336
+ signed: await xSignAsync(
2819
3337
  new Uint8Array(msg.challenge),
2820
3338
  this.signKeys.secretKey,
2821
3339
  ),
@@ -2873,7 +3391,7 @@ export class Client {
2873
3391
  );
2874
3392
  const fileData = res.data;
2875
3393
 
2876
- const decrypted = xSecretboxOpen(
3394
+ const decrypted = await xSecretboxOpenAsync(
2877
3395
  new Uint8Array(fileData),
2878
3396
  XUtils.decodeHex(details.nonce),
2879
3397
  XUtils.decodeHex(key),
@@ -2961,7 +3479,6 @@ export class Client {
2961
3479
  }
2962
3480
 
2963
3481
  const mailID = uuid.v4();
2964
- const promises: Array<Promise<void>> = [];
2965
3482
 
2966
3483
  const userIDs = [...new Set(userList.map((user) => user.userID))];
2967
3484
  const devices = await this.getMultiUserDeviceList(userIDs);
@@ -2971,24 +3488,19 @@ export class Client {
2971
3488
  if (!ownerRecord) {
2972
3489
  continue;
2973
3490
  }
2974
- promises.push(
2975
- this.sendMail(
3491
+ try {
3492
+ await this.sendMail(
2976
3493
  device,
2977
3494
  ownerRecord,
2978
3495
  XUtils.decodeUTF8(message),
2979
3496
  uuidToUint8(channelID),
2980
3497
  mailID,
2981
3498
  false,
2982
- ),
2983
- );
2984
- }
2985
- void Promise.allSettled(promises).then((results) => {
2986
- for (const result of results) {
2987
- const { status } = result;
2988
- if (status === "rejected") {
2989
- }
3499
+ );
3500
+ } catch {
3501
+ /* best-effort; each device needs its own X3DH handshake (sequential) */
2990
3502
  }
2991
- });
3503
+ }
2992
3504
  }
2993
3505
 
2994
3506
  /* Sends encrypted mail to a user. */
@@ -3005,87 +3517,119 @@ export class Client {
3005
3517
  await sleep(100);
3006
3518
  }
3007
3519
  this.sending.set(device.deviceID, device);
3520
+ try {
3521
+ const session = await this.database.getSessionByDeviceID(
3522
+ device.deviceID,
3523
+ );
3008
3524
 
3009
- const session = await this.database.getSessionByDeviceID(
3010
- device.deviceID,
3011
- );
3012
-
3013
- if (!session || retry) {
3014
- await this.createSession(device, user, msg, group, mailID, forward);
3015
- return;
3016
- }
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
+ }
3017
3549
 
3018
- const nonce = xMakeNonce();
3019
- const cipher = xSecretbox(msg, nonce, session.SK);
3020
- const extra = session.publicKey;
3550
+ if (libvexDebugDmEnabled()) {
3551
+ debugLibvexDm("sendMail: subsequent path", {
3552
+ peerDevice: device.deviceID,
3553
+ });
3554
+ }
3021
3555
 
3022
- const mail: MailWS = {
3023
- authorID: this.getUser().userID,
3024
- cipher,
3025
- extra,
3026
- forward,
3027
- group,
3028
- mailID: mailID || uuid.v4(),
3029
- mailType: MailType.subsequent,
3030
- nonce,
3031
- readerID: session.userID,
3032
- recipient: device.deviceID,
3033
- sender: this.getDevice().deviceID,
3034
- };
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
+ };
3035
3576
 
3036
- const msgb: ResourceMsg = {
3037
- action: "CREATE",
3038
- data: mail,
3039
- resourceType: "mail",
3040
- transmissionID: uuid.v4(),
3041
- type: "resource",
3042
- };
3577
+ const msgb: ResourceMsg = {
3578
+ action: "CREATE",
3579
+ data: mail,
3580
+ resourceType: "mail",
3581
+ transmissionID: uuid.v4(),
3582
+ type: "resource",
3583
+ };
3043
3584
 
3044
- const hmac = xHMAC(mail, session.SK);
3045
-
3046
- const fwdOut = forward
3047
- ? messageSchema.parse(msgpack.decode(msg))
3048
- : null;
3049
- const outMsg: Message = fwdOut
3050
- ? { ...fwdOut, forward: true }
3051
- : {
3052
- authorID: mail.authorID,
3053
- decrypted: true,
3054
- direction: "outgoing",
3055
- forward: mail.forward,
3056
- group: mail.group ? uuid.stringify(mail.group) : null,
3057
- mailID: mail.mailID,
3058
- message: XUtils.encodeUTF8(msg),
3059
- nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
3060
- readerID: mail.readerID,
3061
- recipient: mail.recipient,
3062
- sender: mail.sender,
3063
- timestamp: new Date().toISOString(),
3064
- };
3065
- this.emitter.emit("message", outMsg);
3066
-
3067
- await new Promise((res, rej) => {
3068
- const callback = (packedMsg: Uint8Array) => {
3069
- const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
3070
- if (receivedMsg.transmissionID === msgb.transmissionID) {
3071
- this.socket.off("message", callback);
3072
- const parsed = WSMessageSchema.safeParse(receivedMsg);
3073
- if (parsed.success && parsed.data.type === "success") {
3074
- res(parsed.data.data);
3075
- } else {
3076
- rej(
3077
- new Error(
3078
- "Mail delivery failed: " +
3079
- JSON.stringify(receivedMsg),
3080
- ),
3081
- );
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
+ }
3082
3625
  }
3083
- }
3084
- };
3085
- this.socket.on("message", callback);
3086
- void this.send(msgb, hmac);
3087
- });
3088
- 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
+ }
3089
3633
  }
3090
3634
 
3091
3635
  private async sendMessage(userID: string, message: string): Promise<void> {
@@ -3098,31 +3642,107 @@ export class Client {
3098
3642
  throw new Error("Couldn't get user entry.");
3099
3643
  }
3100
3644
 
3101
- const deviceList = await this.fetchUserDeviceListWithBackoff(
3645
+ const afterBackoff = await this.fetchUserDeviceListWithBackoff(
3102
3646
  userID,
3103
3647
  "peer",
3104
3648
  );
3105
- const mailID = uuid.v4();
3106
- 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;
3107
3694
  for (const device of deviceList) {
3108
- promises.push(
3109
- 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(
3110
3704
  device,
3111
3705
  userEntry,
3112
3706
  XUtils.decodeUTF8(message),
3113
3707
  null,
3114
3708
  mailID,
3115
3709
  false,
3116
- ),
3117
- );
3118
- }
3119
- void Promise.allSettled(promises).then((results) => {
3120
- for (const result of results) {
3121
- const { status } = result;
3122
- 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
+ );
3123
3724
  }
3725
+ lastErr = e;
3726
+ failCount += 1;
3124
3727
  }
3125
- });
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
+ }
3126
3746
  } catch (err: unknown) {
3127
3747
  throw err;
3128
3748
  }
@@ -3149,7 +3769,7 @@ export class Client {
3149
3769
  const otks: UnsavedPreKey[] = [];
3150
3770
 
3151
3771
  for (let i = 0; i < amount; i++) {
3152
- otks[i] = this.createPreKey();
3772
+ otks.push(await this.createPreKey());
3153
3773
  }
3154
3774
 
3155
3775
  const savedKeys = await this.database.savePreKeys(otks, true);