@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.
- 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 +39 -3
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +961 -480
- 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 +14 -4
- package/src/Client.ts +1239 -619
- 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
|
|
|
@@ -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
|
-
|
|
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
|
|
981
|
-
|
|
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 =
|
|
1156
|
+
const dbFileName = options?.inMemoryDb
|
|
986
1157
|
? ":memory:"
|
|
987
|
-
: XUtils.encodeHex(
|
|
988
|
-
|
|
989
|
-
|
|
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,
|
|
1162
|
+
resolvedStorage = createNodeStorage(dbPath, atRestAes);
|
|
994
1163
|
}
|
|
995
|
-
|
|
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
|
|
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
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
-
|
|
1534
|
-
|
|
1796
|
+
return [fcreatedFile, XUtils.encodeHex(fileKey)];
|
|
1797
|
+
}
|
|
1535
1798
|
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
1889
|
+
return this.runWithThisCryptoProfile(async () => {
|
|
1890
|
+
let keyBundle: KeyBundle;
|
|
1599
1891
|
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
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
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
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
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
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
|
-
|
|
2000
|
+
const hmac = xHMAC(mail, SK);
|
|
1682
2001
|
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
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
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
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
|
-
|
|
1752
|
-
|
|
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
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
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 (
|
|
1990
|
-
|
|
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 (
|
|
1994
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
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
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
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
|
-
|
|
2540
|
-
const EK_A = ephKey;
|
|
2885
|
+
};
|
|
2541
2886
|
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
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
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
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
|
-
//
|
|
2589
|
-
const
|
|
2590
|
-
|
|
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
|
-
|
|
2622
|
-
|
|
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
|
|
2625
|
-
|
|
2626
|
-
)
|
|
2627
|
-
|
|
2628
|
-
|
|
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
|
-
|
|
2632
|
-
|
|
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
|
-
|
|
2651
|
-
|
|
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
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
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
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
3187
|
+
if (!session) {
|
|
3188
|
+
void healSession();
|
|
3189
|
+
return;
|
|
3190
|
+
}
|
|
3191
|
+
const HMAC = xHMAC(mail, session.SK);
|
|
2683
3192
|
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
3193
|
+
if (!XUtils.bytesEqual(HMAC, header)) {
|
|
3194
|
+
void healSession();
|
|
3195
|
+
return;
|
|
3196
|
+
}
|
|
2688
3197
|
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
3198
|
+
const decrypted = await xSecretboxOpenAsync(
|
|
3199
|
+
new Uint8Array(mail.cipher),
|
|
3200
|
+
new Uint8Array(mail.nonce),
|
|
3201
|
+
session.SK,
|
|
3202
|
+
);
|
|
2694
3203
|
|
|
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
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
:
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
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
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
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
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3550
|
+
if (libvexDebugDmEnabled()) {
|
|
3551
|
+
debugLibvexDm("sendMail: subsequent path", {
|
|
3552
|
+
peerDevice: device.deviceID,
|
|
3553
|
+
});
|
|
3554
|
+
}
|
|
3021
3555
|
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
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
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
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
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
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
|
-
|
|
3086
|
-
|
|
3087
|
-
}
|
|
3088
|
-
|
|
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
|
|
3645
|
+
const afterBackoff = await this.fetchUserDeviceListWithBackoff(
|
|
3102
3646
|
userID,
|
|
3103
3647
|
"peer",
|
|
3104
3648
|
);
|
|
3105
|
-
|
|
3106
|
-
|
|
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
|
-
|
|
3109
|
-
|
|
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
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
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
|
|
3772
|
+
otks.push(await this.createPreKey());
|
|
3153
3773
|
}
|
|
3154
3774
|
|
|
3155
3775
|
const savedKeys = await this.database.savePreKeys(otks, true);
|