@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.
- package/CLA.md +38 -0
- package/LICENSE-COMMERCIAL +10 -0
- package/LICENSING.md +15 -0
- package/README.md +8 -2
- package/dist/Client.d.ts +47 -3
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +998 -496
- package/dist/Client.js.map +1 -1
- package/dist/Storage.d.ts +5 -0
- package/dist/Storage.d.ts.map +1 -1
- package/dist/Storage.js +5 -0
- package/dist/Storage.js.map +1 -1
- package/dist/__tests__/harness/memory-storage.d.ts +7 -2
- package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.js +44 -29
- package/dist/__tests__/harness/memory-storage.js.map +1 -1
- package/dist/codec.d.ts +9 -9
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +17 -19
- package/dist/codec.js.map +1 -1
- package/dist/codecs.d.ts +5 -0
- package/dist/codecs.d.ts.map +1 -1
- package/dist/codecs.js +5 -0
- package/dist/codecs.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/keystore/memory.d.ts +5 -0
- package/dist/keystore/memory.d.ts.map +1 -1
- package/dist/keystore/memory.js +5 -0
- package/dist/keystore/memory.js.map +1 -1
- package/dist/keystore/node.d.ts +5 -0
- package/dist/keystore/node.d.ts.map +1 -1
- package/dist/keystore/node.js +16 -8
- package/dist/keystore/node.js.map +1 -1
- package/dist/preset/common.d.ts +5 -0
- package/dist/preset/common.d.ts.map +1 -1
- package/dist/preset/common.js +5 -0
- package/dist/preset/common.js.map +1 -1
- package/dist/preset/node.d.ts +5 -0
- package/dist/preset/node.d.ts.map +1 -1
- package/dist/preset/node.js +9 -1
- package/dist/preset/node.js.map +1 -1
- package/dist/preset/test.d.ts +5 -0
- package/dist/preset/test.d.ts.map +1 -1
- package/dist/preset/test.js +9 -1
- package/dist/preset/test.js.map +1 -1
- package/dist/storage/node/http-agents.d.ts +5 -0
- package/dist/storage/node/http-agents.d.ts.map +1 -1
- package/dist/storage/node/http-agents.js +5 -0
- package/dist/storage/node/http-agents.js.map +1 -1
- package/dist/storage/node.d.ts +6 -1
- package/dist/storage/node.d.ts.map +1 -1
- package/dist/storage/node.js +7 -4
- package/dist/storage/node.js.map +1 -1
- package/dist/storage/schema.d.ts +5 -0
- package/dist/storage/schema.d.ts.map +1 -1
- package/dist/storage/schema.js +5 -0
- package/dist/storage/schema.js.map +1 -1
- package/dist/storage/sqlite.d.ts +22 -4
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +172 -98
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/transport/types.d.ts +5 -0
- package/dist/transport/types.d.ts.map +1 -1
- package/dist/transport/types.js +5 -0
- package/dist/transport/types.js.map +1 -1
- package/dist/transport/websocket.d.ts +5 -0
- package/dist/transport/websocket.d.ts.map +1 -1
- package/dist/transport/websocket.js +5 -0
- package/dist/transport/websocket.js.map +1 -1
- package/dist/types/crypto.d.ts +5 -0
- package/dist/types/crypto.d.ts.map +1 -1
- package/dist/types/crypto.js +3 -5
- package/dist/types/crypto.js.map +1 -1
- package/dist/types/identity.d.ts +5 -0
- package/dist/types/identity.d.ts.map +1 -1
- package/dist/types/identity.js +3 -2
- package/dist/types/identity.js.map +1 -1
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -1
- package/dist/utils/capitalize.d.ts +5 -0
- package/dist/utils/capitalize.d.ts.map +1 -1
- package/dist/utils/capitalize.js +5 -0
- package/dist/utils/capitalize.js.map +1 -1
- package/dist/utils/fipsMailExtra.d.ts +30 -0
- package/dist/utils/fipsMailExtra.d.ts.map +1 -0
- package/dist/utils/fipsMailExtra.js +114 -0
- package/dist/utils/fipsMailExtra.js.map +1 -0
- package/dist/utils/formatBytes.d.ts +5 -0
- package/dist/utils/formatBytes.d.ts.map +1 -1
- package/dist/utils/formatBytes.js +5 -0
- package/dist/utils/formatBytes.js.map +1 -1
- package/dist/utils/resolveAtRestAesKey.d.ts +13 -0
- package/dist/utils/resolveAtRestAesKey.d.ts.map +1 -0
- package/dist/utils/resolveAtRestAesKey.js +26 -0
- package/dist/utils/resolveAtRestAesKey.js.map +1 -0
- package/dist/utils/sqlSessionToCrypto.d.ts +5 -0
- package/dist/utils/sqlSessionToCrypto.d.ts.map +1 -1
- package/dist/utils/sqlSessionToCrypto.js +5 -0
- package/dist/utils/sqlSessionToCrypto.js.map +1 -1
- package/dist/utils/uint8uuid.d.ts +5 -0
- package/dist/utils/uint8uuid.d.ts.map +1 -1
- package/dist/utils/uint8uuid.js +5 -0
- package/dist/utils/uint8uuid.js.map +1 -1
- package/package.json +10 -3
- package/src/Client.ts +1281 -642
- package/src/Storage.ts +6 -0
- package/src/__tests__/codec.test.ts +6 -0
- package/src/__tests__/harness/fixtures.ts +6 -0
- package/src/__tests__/harness/memory-storage.ts +72 -52
- package/src/__tests__/harness/platform-transports.ts +6 -0
- package/src/__tests__/harness/poison-node-imports.ts +6 -0
- package/src/__tests__/harness/shared-suite.ts +288 -124
- package/src/__tests__/platform-browser.test.ts +15 -1
- package/src/__tests__/platform-node.test.ts +17 -3
- package/src/codec.ts +21 -8
- package/src/codecs.ts +6 -0
- package/src/index.ts +6 -0
- package/src/keystore/memory.ts +6 -0
- package/src/keystore/node.ts +27 -13
- package/src/preset/common.ts +6 -0
- package/src/preset/node.ts +14 -1
- package/src/preset/test.ts +14 -1
- package/src/storage/node/http-agents.ts +6 -0
- package/src/storage/node.ts +11 -4
- package/src/storage/schema.ts +6 -0
- package/src/storage/sqlite.ts +208 -135
- package/src/transport/types.ts +6 -0
- package/src/transport/websocket.ts +6 -0
- package/src/types/crypto.ts +6 -0
- package/src/types/identity.ts +6 -0
- package/src/types/index.ts +6 -0
- package/src/utils/capitalize.ts +6 -0
- package/src/utils/fipsMailExtra.ts +164 -0
- package/src/utils/formatBytes.ts +6 -0
- package/src/utils/resolveAtRestAesKey.ts +39 -0
- package/src/utils/sqlSessionToCrypto.ts +6 -0
- 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
|
-
|
|
38
|
-
|
|
43
|
+
type CryptoProfile,
|
|
44
|
+
getCryptoProfile,
|
|
45
|
+
setCryptoProfile,
|
|
46
|
+
xBoxKeyPairAsync,
|
|
47
|
+
xBoxKeyPairFromSecretAsync,
|
|
39
48
|
xConcat,
|
|
40
49
|
xConstants,
|
|
41
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
976
|
-
|
|
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 =
|
|
1156
|
+
const dbFileName = options?.inMemoryDb
|
|
981
1157
|
? ":memory:"
|
|
982
|
-
: XUtils.encodeHex(
|
|
983
|
-
|
|
984
|
-
|
|
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,
|
|
1162
|
+
resolvedStorage = createNodeStorage(dbPath, atRestAes);
|
|
989
1163
|
}
|
|
990
|
-
|
|
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
|
|
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
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
1034
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
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
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
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
|
-
|
|
1528
|
-
|
|
1796
|
+
return [fcreatedFile, XUtils.encodeHex(fileKey)];
|
|
1797
|
+
}
|
|
1529
1798
|
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
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
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
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
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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
|
-
|
|
2000
|
+
const hmac = xHMAC(mail, SK);
|
|
1676
2001
|
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
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
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
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
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
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
|
-
|
|
1746
|
-
|
|
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
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
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 (
|
|
1980
|
-
|
|
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 (
|
|
1984
|
-
|
|
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
|
|
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
|
-
|
|
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",
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
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
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
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
|
-
|
|
2521
|
-
const EK_A = ephKey;
|
|
2885
|
+
};
|
|
2522
2886
|
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
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
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
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
|
-
//
|
|
2570
|
-
const
|
|
2571
|
-
|
|
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
|
-
|
|
2603
|
-
|
|
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
|
|
2606
|
-
|
|
2607
|
-
)
|
|
2608
|
-
|
|
2609
|
-
|
|
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
|
-
|
|
2613
|
-
|
|
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
|
-
|
|
2632
|
-
|
|
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
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
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
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
3187
|
+
if (!session) {
|
|
3188
|
+
void healSession();
|
|
3189
|
+
return;
|
|
3190
|
+
}
|
|
3191
|
+
const HMAC = xHMAC(mail, session.SK);
|
|
2664
3192
|
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
3193
|
+
if (!XUtils.bytesEqual(HMAC, header)) {
|
|
3194
|
+
void healSession();
|
|
3195
|
+
return;
|
|
3196
|
+
}
|
|
2669
3197
|
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
3198
|
+
const decrypted = await xSecretboxOpenAsync(
|
|
3199
|
+
new Uint8Array(mail.cipher),
|
|
3200
|
+
new Uint8Array(mail.nonce),
|
|
3201
|
+
session.SK,
|
|
3202
|
+
);
|
|
2675
3203
|
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
:
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
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
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
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
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3550
|
+
if (libvexDebugDmEnabled()) {
|
|
3551
|
+
debugLibvexDm("sendMail: subsequent path", {
|
|
3552
|
+
peerDevice: device.deviceID,
|
|
3553
|
+
});
|
|
3554
|
+
}
|
|
3002
3555
|
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
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
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
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
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
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
|
-
|
|
3067
|
-
|
|
3068
|
-
}
|
|
3069
|
-
|
|
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
|
|
3645
|
+
const afterBackoff = await this.fetchUserDeviceListWithBackoff(
|
|
3083
3646
|
userID,
|
|
3084
3647
|
"peer",
|
|
3085
3648
|
);
|
|
3086
|
-
|
|
3087
|
-
|
|
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
|
-
|
|
3090
|
-
|
|
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
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
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
|
|
3772
|
+
otks.push(await this.createPreKey());
|
|
3134
3773
|
}
|
|
3135
3774
|
|
|
3136
3775
|
const savedKeys = await this.database.savePreKeys(otks, true);
|