@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/dist/Client.js
CHANGED
|
@@ -1,13 +1,111 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
import { getCryptoProfile, setCryptoProfile, xBoxKeyPairAsync, xBoxKeyPairFromSecretAsync, xConcat, xConstants, xDHAsync, xEcdhKeyPairFromEcdsaKeyPairAsync, xEncode, xHMAC, xKDF, XKeyConvert, xMakeNonce, xMnemonic, xRandomBytes, xSecretboxAsync, xSecretboxOpenAsync, xSignAsync, xSignKeyPair, xSignKeyPairAsync, xSignKeyPairFromSecret, xSignKeyPairFromSecretAsync, XUtils, } from "@vex-chat/crypto";
|
|
2
7
|
import { MailType, MailWSSchema, PermissionSchema, WSMessageSchema, } from "@vex-chat/types";
|
|
3
8
|
import axios, { isAxiosError } from "axios";
|
|
4
9
|
import { EventEmitter } from "eventemitter3";
|
|
5
10
|
import * as uuid from "uuid";
|
|
6
11
|
import { z } from "zod/v4";
|
|
7
12
|
import { WebSocketAdapter } from "./transport/websocket.js";
|
|
13
|
+
import { decodeFipsInitialExtraV1, decodeFipsSubsequentExtraV1, encodeFipsInitialExtraV1, encodeFipsSubsequentExtraV1, fipsP256AdFromIdentityPubs, fipsP256PreKeySignPayload, isFipsInitialExtraV1, isFipsSubsequentExtraV1, } from "./utils/fipsMailExtra.js";
|
|
8
14
|
function sleep(ms) {
|
|
9
15
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
16
|
}
|
|
17
|
+
function isRecord(x) {
|
|
18
|
+
return typeof x === "object" && x !== null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Spire 5+ JSON error bodies use `{ "error": { "message", "requestId"?, "details"? } }`.
|
|
22
|
+
* Responses are `arraybuffer` — decode UTF-8 and parse for a one-line `Error` message
|
|
23
|
+
* (plus requestId) instead of a raw JSON blob.
|
|
24
|
+
*/
|
|
25
|
+
function spireErrorBodyMessage(data, max = 8_000) {
|
|
26
|
+
let text;
|
|
27
|
+
if (data instanceof ArrayBuffer) {
|
|
28
|
+
text = new TextDecoder("utf-8", { fatal: false }).decode(new Uint8Array(data));
|
|
29
|
+
}
|
|
30
|
+
else if (data instanceof Uint8Array) {
|
|
31
|
+
text = new TextDecoder("utf-8", { fatal: false }).decode(data);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
return String(data).slice(0, max);
|
|
35
|
+
}
|
|
36
|
+
const t = text.trim();
|
|
37
|
+
if (t.startsWith("{")) {
|
|
38
|
+
try {
|
|
39
|
+
// JSON.parse is typed as any; assign into unknown for safe narrowing.
|
|
40
|
+
const parsed = JSON.parse(t);
|
|
41
|
+
if (!isRecord(parsed)) {
|
|
42
|
+
return t.length > max ? t.slice(0, max) + "…" : t;
|
|
43
|
+
}
|
|
44
|
+
const errField = parsed["error"];
|
|
45
|
+
if (!isRecord(errField)) {
|
|
46
|
+
return t.length > max ? t.slice(0, max) + "…" : t;
|
|
47
|
+
}
|
|
48
|
+
const message = errField["message"];
|
|
49
|
+
if (typeof message !== "string") {
|
|
50
|
+
return t.length > max ? t.slice(0, max) + "…" : t;
|
|
51
|
+
}
|
|
52
|
+
const parts = [message];
|
|
53
|
+
const requestId = errField["requestId"];
|
|
54
|
+
if (typeof requestId === "string" && requestId.length > 0) {
|
|
55
|
+
parts.push(`(requestId: ${requestId})`);
|
|
56
|
+
}
|
|
57
|
+
if (errField["details"] !== undefined) {
|
|
58
|
+
let d = JSON.stringify(errField["details"]);
|
|
59
|
+
if (d.length > 500) {
|
|
60
|
+
d = d.slice(0, 500) + "…";
|
|
61
|
+
}
|
|
62
|
+
parts.push(d);
|
|
63
|
+
}
|
|
64
|
+
return parts.join(" ");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* fall through to raw */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return t.length > max ? t.slice(0, max) + "…" : t;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Set `LIBVEX_DEBUG_DM=1` (e.g. in vitest / shell) to log DM multi-device / X3DH paths.
|
|
74
|
+
* Uses indirect `globalThis` lookup so the bare `process` global never appears in
|
|
75
|
+
* source that the platform-guard plugin scans (browser/RN/Tauri).
|
|
76
|
+
*/
|
|
77
|
+
function libvexDebugDmEnabled() {
|
|
78
|
+
try {
|
|
79
|
+
const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
|
|
80
|
+
if (!g) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const proc = typeof g.get === "function" ? g.get() : g.value;
|
|
84
|
+
if (typeof proc !== "object" || proc === null) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
|
|
88
|
+
if (!envDesc) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const env = typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
|
|
92
|
+
if (typeof env !== "object" || env === null) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return Reflect.get(env, "LIBVEX_DEBUG_DM") === "1";
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function debugLibvexDm(msg, data) {
|
|
102
|
+
if (!libvexDebugDmEnabled()) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
|
|
106
|
+
// eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
|
|
107
|
+
console.error(`[libvex:debug-dm] ${payload}`);
|
|
108
|
+
}
|
|
11
109
|
import { msgpack } from "./codec.js";
|
|
12
110
|
import { ActionTokenCodec, AuthResponseCodec, ChannelArrayCodec, ChannelCodec, ConnectResponseCodec, decodeAxios, DeviceArrayCodec, DeviceChallengeCodec, DeviceCodec, EmojiArrayCodec, EmojiCodec, FileSQLCodec, InviteArrayCodec, InviteCodec, KeyBundleCodec, OtkCountCodec, PermissionArrayCodec, PermissionCodec, ServerArrayCodec, ServerCodec, UserArrayCodec, UserCodec, WhoamiCodec, } from "./codecs.js";
|
|
13
111
|
import { capitalize } from "./utils/capitalize.js";
|
|
@@ -42,12 +140,14 @@ export class Client {
|
|
|
42
140
|
* Pass-through utility from `@vex-chat/crypto`.
|
|
43
141
|
*/
|
|
44
142
|
static decryptKeyData = XUtils.decryptKeyData;
|
|
143
|
+
static decryptKeyDataAsync = XUtils.decryptKeyDataAsync;
|
|
45
144
|
/**
|
|
46
145
|
* Encrypts a secret key with a password.
|
|
47
146
|
*
|
|
48
147
|
* Pass-through utility from `@vex-chat/crypto`.
|
|
49
148
|
*/
|
|
50
149
|
static encryptKeyData = XUtils.encryptKeyData;
|
|
150
|
+
static encryptKeyDataAsync = XUtils.encryptKeyDataAsync;
|
|
51
151
|
static NOT_FOUND_TTL = 30 * 60 * 1000;
|
|
52
152
|
/**
|
|
53
153
|
* Browser-safe NODE_ENV accessor.
|
|
@@ -312,11 +412,18 @@ export class Client {
|
|
|
312
412
|
* Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
|
|
313
413
|
*/
|
|
314
414
|
nodeHttpAgents;
|
|
415
|
+
/** Cancels in-flight axios work on `close()` so `postAuth`/`getMail` cannot hang forever. */
|
|
416
|
+
httpAbortController = new AbortController();
|
|
315
417
|
http;
|
|
316
418
|
idKeys;
|
|
317
419
|
isAlive = true;
|
|
318
420
|
mailInterval;
|
|
319
421
|
manuallyClosing = false;
|
|
422
|
+
/**
|
|
423
|
+
* Bumped when the WebSocket is torn down and re-opened so the previous
|
|
424
|
+
* `postAuth` loop exits instead of overlapping a new one.
|
|
425
|
+
*/
|
|
426
|
+
postAuthVersion = 0;
|
|
320
427
|
/* Retrieves the userID with the user identifier.
|
|
321
428
|
user identifier is checked for userID, then signkey,
|
|
322
429
|
and finally falls back to username. */
|
|
@@ -335,9 +442,12 @@ export class Client {
|
|
|
335
442
|
user;
|
|
336
443
|
userRecords = {};
|
|
337
444
|
xKeyRing;
|
|
338
|
-
|
|
339
|
-
|
|
445
|
+
cryptoProfile;
|
|
446
|
+
constructor(material, options, storage) {
|
|
340
447
|
this.options = options;
|
|
448
|
+
this.cryptoProfile = material.cryptoProfile;
|
|
449
|
+
this.signKeys = material.signKeys;
|
|
450
|
+
this.idKeys = material.idKeys;
|
|
341
451
|
if (options?.unsafeHttp) {
|
|
342
452
|
const env = Client.getNodeEnv();
|
|
343
453
|
if (env !== "development" && env !== "test") {
|
|
@@ -349,13 +459,6 @@ export class Client {
|
|
|
349
459
|
else {
|
|
350
460
|
this.prefixes = { HTTP: "https://", WS: "wss://" };
|
|
351
461
|
}
|
|
352
|
-
this.signKeys = privateKey
|
|
353
|
-
? xSignKeyPairFromSecret(XUtils.decodeHex(privateKey))
|
|
354
|
-
: xSignKeyPair();
|
|
355
|
-
this.idKeys = XKeyConvert.convertKeyPair(this.signKeys);
|
|
356
|
-
if (!this.idKeys) {
|
|
357
|
-
throw new Error("Could not convert key to X25519!");
|
|
358
|
-
}
|
|
359
462
|
this.host = options?.host || "api.vex.wtf";
|
|
360
463
|
const dbFileName = options?.inMemoryDb
|
|
361
464
|
? ":memory:"
|
|
@@ -370,7 +473,10 @@ export class Client {
|
|
|
370
473
|
this.database.on("error", (_error) => {
|
|
371
474
|
void this.close(true);
|
|
372
475
|
});
|
|
373
|
-
this.http = axios.create({
|
|
476
|
+
this.http = axios.create({
|
|
477
|
+
responseType: "arraybuffer",
|
|
478
|
+
signal: this.httpAbortController.signal,
|
|
479
|
+
});
|
|
374
480
|
const devKey = options?.devApiKey?.trim();
|
|
375
481
|
if (devKey !== undefined && devKey.length > 0) {
|
|
376
482
|
this.http.defaults.headers.common["x-dev-api-key"] = devKey;
|
|
@@ -391,29 +497,73 @@ export class Client {
|
|
|
391
497
|
* ```
|
|
392
498
|
*/
|
|
393
499
|
static create = async (privateKey, options, storage) => {
|
|
394
|
-
const
|
|
395
|
-
|
|
500
|
+
const profile = options?.cryptoProfile ?? "tweetnacl";
|
|
501
|
+
setCryptoProfile(profile);
|
|
502
|
+
if (profile === "fips" &&
|
|
503
|
+
typeof globalThis.crypto.subtle !== "object") {
|
|
504
|
+
throw new Error('cryptoProfile="fips" requires Web Crypto (globalThis.crypto.subtle).');
|
|
505
|
+
}
|
|
506
|
+
let signKeys;
|
|
507
|
+
if (privateKey) {
|
|
508
|
+
const d = XUtils.decodeHex(privateKey);
|
|
509
|
+
signKeys =
|
|
510
|
+
profile === "tweetnacl"
|
|
511
|
+
? xSignKeyPairFromSecret(d)
|
|
512
|
+
: await xSignKeyPairFromSecretAsync(d);
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
signKeys =
|
|
516
|
+
profile === "tweetnacl"
|
|
517
|
+
? xSignKeyPair()
|
|
518
|
+
: await xSignKeyPairAsync();
|
|
519
|
+
}
|
|
520
|
+
const idKeys = profile === "tweetnacl"
|
|
521
|
+
? (() => {
|
|
522
|
+
const c = XKeyConvert.convertKeyPair(signKeys);
|
|
523
|
+
if (!c) {
|
|
524
|
+
throw new Error("Could not convert key to X25519!");
|
|
525
|
+
}
|
|
526
|
+
return c;
|
|
527
|
+
})()
|
|
528
|
+
: await xEcdhKeyPairFromEcdsaKeyPairAsync(signKeys);
|
|
529
|
+
const atRestAes = XUtils.deriveLocalAtRestAesKey(idKeys.secretKey, profile);
|
|
396
530
|
let resolvedStorage = storage;
|
|
397
531
|
if (!resolvedStorage) {
|
|
398
532
|
const { createNodeStorage } = await import("./storage/node.js");
|
|
399
|
-
const dbFileName =
|
|
533
|
+
const dbFileName = options?.inMemoryDb
|
|
400
534
|
? ":memory:"
|
|
401
|
-
: XUtils.encodeHex(
|
|
402
|
-
const dbPath =
|
|
403
|
-
?
|
|
535
|
+
: XUtils.encodeHex(signKeys.publicKey) + ".sqlite";
|
|
536
|
+
const dbPath = options?.dbFolder
|
|
537
|
+
? options.dbFolder + "/" + dbFileName
|
|
404
538
|
: dbFileName;
|
|
405
|
-
resolvedStorage = createNodeStorage(dbPath,
|
|
406
|
-
}
|
|
407
|
-
|
|
539
|
+
resolvedStorage = createNodeStorage(dbPath, atRestAes);
|
|
540
|
+
}
|
|
541
|
+
await resolvedStorage.init();
|
|
542
|
+
const client = new Client({
|
|
543
|
+
cryptoProfile: profile,
|
|
544
|
+
idKeys,
|
|
545
|
+
signKeys,
|
|
546
|
+
}, options, resolvedStorage);
|
|
408
547
|
await client.init();
|
|
409
548
|
return client;
|
|
410
549
|
};
|
|
411
550
|
/**
|
|
412
|
-
* Generates
|
|
413
|
-
*
|
|
414
|
-
* @returns A secret key to use for the client. Save it permanently somewhere safe.
|
|
551
|
+
* Generates a signing secret key as a hex string (tweetnacl: Ed25519; fips: P-256 pkcs8).
|
|
552
|
+
* In `fips` mode, use `Client.generateSecretKeyAsync()` instead (Web Crypto is async).
|
|
415
553
|
*/
|
|
416
554
|
static generateSecretKey() {
|
|
555
|
+
if (getCryptoProfile() === "fips") {
|
|
556
|
+
throw new Error('Use await Client.generateSecretKeyAsync() when the active crypto profile is "fips".');
|
|
557
|
+
}
|
|
558
|
+
return XUtils.encodeHex(xSignKeyPair().secretKey);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Async key generation — required for `fips` profile; safe for `tweetnacl` as well.
|
|
562
|
+
*/
|
|
563
|
+
static async generateSecretKeyAsync() {
|
|
564
|
+
if (getCryptoProfile() === "fips") {
|
|
565
|
+
return XUtils.encodeHex((await xSignKeyPairAsync()).secretKey);
|
|
566
|
+
}
|
|
417
567
|
return XUtils.encodeHex(xSignKeyPair().secretKey);
|
|
418
568
|
}
|
|
419
569
|
/**
|
|
@@ -431,17 +581,23 @@ export class Client {
|
|
|
431
581
|
}
|
|
432
582
|
static deserializeExtra(type, extra) {
|
|
433
583
|
switch (type) {
|
|
434
|
-
case MailType.initial:
|
|
435
|
-
|
|
436
|
-
|
|
584
|
+
case MailType.initial: {
|
|
585
|
+
if (isFipsInitialExtraV1(extra)) {
|
|
586
|
+
const [a, b, c, d] = decodeFipsInitialExtraV1(extra);
|
|
587
|
+
return [a, b, c, d];
|
|
588
|
+
}
|
|
589
|
+
/* 32B sign | 32B eph | 32B PK | 68B AD | 6B index (tweetnacl) */
|
|
437
590
|
const signKey = extra.slice(0, 32);
|
|
438
591
|
const ephKey = extra.slice(32, 64);
|
|
439
592
|
const ad = extra.slice(96, 164);
|
|
440
593
|
const index = extra.slice(164, 170);
|
|
441
594
|
return [signKey, ephKey, ad, index];
|
|
595
|
+
}
|
|
442
596
|
case MailType.subsequent:
|
|
443
|
-
|
|
444
|
-
|
|
597
|
+
if (isFipsSubsequentExtraV1(extra)) {
|
|
598
|
+
return [decodeFipsSubsequentExtraV1(extra)];
|
|
599
|
+
}
|
|
600
|
+
return [extra];
|
|
445
601
|
default:
|
|
446
602
|
return [];
|
|
447
603
|
}
|
|
@@ -522,6 +678,7 @@ export class Client {
|
|
|
522
678
|
*/
|
|
523
679
|
async close(muteEvent = false) {
|
|
524
680
|
this.manuallyClosing = true;
|
|
681
|
+
this.httpAbortController.abort();
|
|
525
682
|
this.socket.close();
|
|
526
683
|
await this.database.close();
|
|
527
684
|
if (this.nodeHttpAgents) {
|
|
@@ -556,8 +713,8 @@ export class Client {
|
|
|
556
713
|
if (!connectToken) {
|
|
557
714
|
throw new Error("Couldn't get connect token.");
|
|
558
715
|
}
|
|
559
|
-
const
|
|
560
|
-
const res = await this.http.post(this.getHost() + "/device/" + this.device.deviceID + "/connect", msgpack.encode({ signed }), { headers: { "Content-Type": "application/msgpack" } });
|
|
716
|
+
const signedAsync = await xSignAsync(Uint8Array.from(uuid.parse(connectToken.key)), this.signKeys.secretKey);
|
|
717
|
+
const res = await this.http.post(this.getHost() + "/device/" + this.device.deviceID + "/connect", msgpack.encode({ signed: signedAsync }), { headers: { "Content-Type": "application/msgpack" } });
|
|
561
718
|
const { deviceToken } = decodeAxios(ConnectResponseCodec, res.data);
|
|
562
719
|
this.http.defaults.headers.common["X-Device-Token"] = deviceToken;
|
|
563
720
|
this.initSocket();
|
|
@@ -566,6 +723,50 @@ export class Client {
|
|
|
566
723
|
await new Promise((r) => setTimeout(r, 0));
|
|
567
724
|
await this.negotiateOTK();
|
|
568
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* Tears down the current WebSocket and opens a new one, keeping the same
|
|
728
|
+
* session (user + device in storage). Restarts the post-auth mail loop.
|
|
729
|
+
* Use for long-running processes or e2e where a fresh socket matches a
|
|
730
|
+
* newly-registered second device.
|
|
731
|
+
*/
|
|
732
|
+
async reconnectWebsocket() {
|
|
733
|
+
this.postAuthVersion++;
|
|
734
|
+
if (this.pingInterval) {
|
|
735
|
+
clearInterval(this.pingInterval);
|
|
736
|
+
this.pingInterval = null;
|
|
737
|
+
}
|
|
738
|
+
this.socket.close();
|
|
739
|
+
try {
|
|
740
|
+
await new Promise((resolve, reject) => {
|
|
741
|
+
const t = setTimeout(() => {
|
|
742
|
+
this.off("connected", onC);
|
|
743
|
+
reject(new Error("reconnectWebsocket: timed out waiting for authorized"));
|
|
744
|
+
}, 15_000);
|
|
745
|
+
const onC = () => {
|
|
746
|
+
clearTimeout(t);
|
|
747
|
+
this.off("connected", onC);
|
|
748
|
+
resolve();
|
|
749
|
+
};
|
|
750
|
+
this.on("connected", onC);
|
|
751
|
+
try {
|
|
752
|
+
this.initSocket();
|
|
753
|
+
}
|
|
754
|
+
catch (err) {
|
|
755
|
+
clearTimeout(t);
|
|
756
|
+
this.off("connected", onC);
|
|
757
|
+
const e = err instanceof Error
|
|
758
|
+
? err
|
|
759
|
+
: new Error(String(err), { cause: err });
|
|
760
|
+
reject(e);
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
catch (e) {
|
|
765
|
+
throw e instanceof Error ? e : new Error(String(e), { cause: e });
|
|
766
|
+
}
|
|
767
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
768
|
+
await this.negotiateOTK();
|
|
769
|
+
}
|
|
569
770
|
/**
|
|
570
771
|
* Delete all local data — message history, encryption sessions, and prekeys.
|
|
571
772
|
* Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
|
|
@@ -623,6 +824,12 @@ export class Client {
|
|
|
623
824
|
return { ok: true };
|
|
624
825
|
}
|
|
625
826
|
catch (err) {
|
|
827
|
+
if (isAxiosError(err) && err.response) {
|
|
828
|
+
return {
|
|
829
|
+
error: spireErrorBodyMessage(err.response.data),
|
|
830
|
+
ok: false,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
626
833
|
const error = err instanceof Error ? err.message : String(err);
|
|
627
834
|
return { error, ok: false };
|
|
628
835
|
}
|
|
@@ -647,7 +854,7 @@ export class Client {
|
|
|
647
854
|
signKey: signKeyHex,
|
|
648
855
|
}), { headers: { "Content-Type": "application/msgpack" } });
|
|
649
856
|
const { challenge, challengeID } = decodeAxios(DeviceChallengeCodec, challengeRes.data);
|
|
650
|
-
const signed = XUtils.encodeHex(
|
|
857
|
+
const signed = XUtils.encodeHex(await xSignAsync(XUtils.decodeHex(challenge), this.signKeys.secretKey));
|
|
651
858
|
const verifyRes = await this.http.post(this.getHost() + "/auth/device/verify", msgpack.encode({ challengeID, signed }), { headers: { "Content-Type": "application/msgpack" } });
|
|
652
859
|
const { token, user } = decodeAxios(AuthResponseCodec, verifyRes.data);
|
|
653
860
|
this.setUser(user);
|
|
@@ -704,7 +911,7 @@ export class Client {
|
|
|
704
911
|
const regKey = await this.getToken("register");
|
|
705
912
|
if (regKey) {
|
|
706
913
|
const signKey = XUtils.encodeHex(this.signKeys.publicKey);
|
|
707
|
-
const signed = XUtils.encodeHex(
|
|
914
|
+
const signed = XUtils.encodeHex(await xSignAsync(Uint8Array.from(uuid.parse(regKey.key)), this.signKeys.secretKey));
|
|
708
915
|
const preKeyIndex = this.xKeyRing.preKeys.index;
|
|
709
916
|
const regMsg = {
|
|
710
917
|
deviceName: this.options?.deviceName ?? "unknown",
|
|
@@ -723,11 +930,10 @@ export class Client {
|
|
|
723
930
|
}
|
|
724
931
|
catch (err) {
|
|
725
932
|
if (isAxiosError(err) && err.response) {
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
return [null, new Error(msg)];
|
|
933
|
+
return [
|
|
934
|
+
null,
|
|
935
|
+
new Error(spireErrorBodyMessage(err.response.data)),
|
|
936
|
+
];
|
|
731
937
|
}
|
|
732
938
|
return [
|
|
733
939
|
null,
|
|
@@ -786,41 +992,45 @@ export class Client {
|
|
|
786
992
|
}
|
|
787
993
|
// returns the file details and the encryption key
|
|
788
994
|
async createFile(file) {
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
loaded,
|
|
806
|
-
progress
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
995
|
+
return this.runWithThisCryptoProfile(async () => {
|
|
996
|
+
const nonce = xMakeNonce();
|
|
997
|
+
const fileKey = this.cryptoProfile === "fips"
|
|
998
|
+
? xRandomBytes(32)
|
|
999
|
+
: (await xBoxKeyPairAsync()).secretKey;
|
|
1000
|
+
const box = await xSecretboxAsync(Uint8Array.from(file), nonce, fileKey);
|
|
1001
|
+
if (typeof FormData !== "undefined") {
|
|
1002
|
+
const fpayload = new FormData();
|
|
1003
|
+
fpayload.set("owner", this.getDevice().deviceID);
|
|
1004
|
+
fpayload.set("nonce", XUtils.encodeHex(nonce));
|
|
1005
|
+
fpayload.set("file", new Blob([new Uint8Array(box)]));
|
|
1006
|
+
const fres = await this.http.post(this.getHost() + "/file", fpayload, {
|
|
1007
|
+
headers: { "Content-Type": "multipart/form-data" },
|
|
1008
|
+
onUploadProgress: (progressEvent) => {
|
|
1009
|
+
const percentCompleted = Math.round((progressEvent.loaded * 100) /
|
|
1010
|
+
(progressEvent.total ?? 1));
|
|
1011
|
+
const { loaded, total = 0 } = progressEvent;
|
|
1012
|
+
const progress = {
|
|
1013
|
+
direction: "upload",
|
|
1014
|
+
loaded,
|
|
1015
|
+
progress: percentCompleted,
|
|
1016
|
+
token: XUtils.encodeHex(nonce),
|
|
1017
|
+
total,
|
|
1018
|
+
};
|
|
1019
|
+
this.emitter.emit("fileProgress", progress);
|
|
1020
|
+
},
|
|
1021
|
+
});
|
|
1022
|
+
const fcreatedFile = decodeAxios(FileSQLCodec, fres.data);
|
|
1023
|
+
return [fcreatedFile, XUtils.encodeHex(fileKey)];
|
|
1024
|
+
}
|
|
1025
|
+
const payload = {
|
|
1026
|
+
file: XUtils.encodeBase64(box),
|
|
1027
|
+
nonce: XUtils.encodeHex(nonce),
|
|
1028
|
+
owner: this.getDevice().deviceID,
|
|
1029
|
+
};
|
|
1030
|
+
const res = await this.http.post(this.getHost() + "/file/json", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
|
|
1031
|
+
const createdFile = decodeAxios(FileSQLCodec, res.data);
|
|
1032
|
+
return [createdFile, XUtils.encodeHex(fileKey)];
|
|
1033
|
+
});
|
|
824
1034
|
}
|
|
825
1035
|
async createInvite(serverID, duration) {
|
|
826
1036
|
const payload = {
|
|
@@ -830,145 +1040,190 @@ export class Client {
|
|
|
830
1040
|
const res = await this.http.post(this.getHost() + "/server/" + serverID + "/invites", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
|
|
831
1041
|
return decodeAxios(InviteCodec, res.data);
|
|
832
1042
|
}
|
|
833
|
-
createPreKey() {
|
|
834
|
-
const preKeyPair =
|
|
1043
|
+
async createPreKey() {
|
|
1044
|
+
const preKeyPair = await xBoxKeyPairAsync();
|
|
1045
|
+
const toSign = this.cryptoProfile === "fips"
|
|
1046
|
+
? fipsP256PreKeySignPayload(preKeyPair.publicKey)
|
|
1047
|
+
: xEncode(xConstants.CURVE, preKeyPair.publicKey);
|
|
835
1048
|
return {
|
|
836
1049
|
keyPair: preKeyPair,
|
|
837
|
-
signature:
|
|
1050
|
+
signature: await xSignAsync(toSign, this.signKeys.secretKey),
|
|
838
1051
|
};
|
|
839
1052
|
}
|
|
840
1053
|
async createServer(name) {
|
|
841
1054
|
const res = await this.http.post(this.getHost() + "/server/" + globalThis.btoa(name));
|
|
842
1055
|
return decodeAxios(ServerCodec, res.data);
|
|
843
1056
|
}
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1057
|
+
/**
|
|
1058
|
+
* `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
|
|
1059
|
+
* active profile. When several {@link Client} instances use different
|
|
1060
|
+
* `cryptoProfile` values, scope the global to this instance for the duration
|
|
1061
|
+
* of that crypto work.
|
|
1062
|
+
*/
|
|
1063
|
+
async runWithThisCryptoProfile(fn) {
|
|
1064
|
+
const prev = getCryptoProfile();
|
|
1065
|
+
if (prev === this.cryptoProfile) {
|
|
1066
|
+
return await fn();
|
|
1067
|
+
}
|
|
1068
|
+
setCryptoProfile(this.cryptoProfile);
|
|
849
1069
|
try {
|
|
850
|
-
|
|
1070
|
+
return await fn();
|
|
851
1071
|
}
|
|
852
|
-
|
|
853
|
-
|
|
1072
|
+
finally {
|
|
1073
|
+
setCryptoProfile(prev);
|
|
854
1074
|
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1075
|
+
}
|
|
1076
|
+
async createSession(device, user, message, group,
|
|
1077
|
+
/* this is passed through if the first message is
|
|
1078
|
+
part of a group message */
|
|
1079
|
+
mailID, forward,
|
|
1080
|
+
/**
|
|
1081
|
+
* When `readMail` triggers a best-effort session re-establish, key-bundle
|
|
1082
|
+
* errors should not reject the full read pipeline.
|
|
1083
|
+
*/
|
|
1084
|
+
allowKeyBundleFailure = false) {
|
|
1085
|
+
return this.runWithThisCryptoProfile(async () => {
|
|
1086
|
+
let keyBundle;
|
|
1087
|
+
try {
|
|
1088
|
+
keyBundle = await this.retrieveKeyBundle(device.deviceID);
|
|
858
1089
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
}
|
|
870
|
-
const IK_B = IK_B_raw;
|
|
871
|
-
const SPK_B = new Uint8Array(keyBundle.preKey.publicKey);
|
|
872
|
-
const OPK_B = keyBundle.otk
|
|
873
|
-
? new Uint8Array(keyBundle.otk.publicKey)
|
|
874
|
-
: null;
|
|
875
|
-
// diffie hellman functions
|
|
876
|
-
const DH1 = xDH(new Uint8Array(IK_A), SPK_B);
|
|
877
|
-
const DH2 = xDH(new Uint8Array(EK_A), IK_B);
|
|
878
|
-
const DH3 = xDH(new Uint8Array(EK_A), SPK_B);
|
|
879
|
-
const DH4 = OPK_B ? xDH(new Uint8Array(EK_A), OPK_B) : null;
|
|
880
|
-
// initial key material
|
|
881
|
-
const IKM = DH4 ? xConcat(DH1, DH2, DH3, DH4) : xConcat(DH1, DH2, DH3);
|
|
882
|
-
// one time key index
|
|
883
|
-
const IDX = keyBundle.otk
|
|
884
|
-
? XUtils.numberToUint8Arr(keyBundle.otk.index ?? 0)
|
|
885
|
-
: XUtils.numberToUint8Arr(0);
|
|
886
|
-
// shared secret key
|
|
887
|
-
const SK = xKDF(IKM);
|
|
888
|
-
const PK = xBoxKeyPairFromSecret(SK).publicKey;
|
|
889
|
-
const AD = xConcat(xEncode(xConstants.CURVE, IK_AP), xEncode(xConstants.CURVE, IK_B));
|
|
890
|
-
const nonce = xMakeNonce();
|
|
891
|
-
const cipher = xSecretbox(message, nonce, SK);
|
|
892
|
-
/* 32 bytes for signkey, 32 bytes for ephemeral key,
|
|
893
|
-
68 bytes for AD, 6 bytes for otk index (empty for no otk) */
|
|
894
|
-
const extra = xConcat(this.signKeys.publicKey, this.xKeyRing.ephemeralKeys.publicKey, PK, AD, IDX);
|
|
895
|
-
const mail = {
|
|
896
|
-
authorID: this.getUser().userID,
|
|
897
|
-
cipher,
|
|
898
|
-
extra,
|
|
899
|
-
forward,
|
|
900
|
-
group,
|
|
901
|
-
mailID: mailID || uuid.v4(),
|
|
902
|
-
mailType: MailType.initial,
|
|
903
|
-
nonce,
|
|
904
|
-
readerID: user.userID,
|
|
905
|
-
recipient: device.deviceID,
|
|
906
|
-
sender: this.getDevice().deviceID,
|
|
907
|
-
};
|
|
908
|
-
const hmac = xHMAC(mail, SK);
|
|
909
|
-
const msg = {
|
|
910
|
-
action: "CREATE",
|
|
911
|
-
data: mail,
|
|
912
|
-
resourceType: "mail",
|
|
913
|
-
transmissionID: uuid.v4(),
|
|
914
|
-
type: "resource",
|
|
915
|
-
};
|
|
916
|
-
// discard the ephemeral keys
|
|
917
|
-
this.newEphemeralKeys();
|
|
918
|
-
const sessionEntry = {
|
|
919
|
-
deviceID: device.deviceID,
|
|
920
|
-
fingerprint: XUtils.encodeHex(AD),
|
|
921
|
-
lastUsed: new Date().toISOString(),
|
|
922
|
-
mode: "initiator",
|
|
923
|
-
publicKey: XUtils.encodeHex(PK),
|
|
924
|
-
sessionID: uuid.v4(),
|
|
925
|
-
SK: XUtils.encodeHex(SK),
|
|
926
|
-
userID: user.userID,
|
|
927
|
-
verified: false,
|
|
928
|
-
};
|
|
929
|
-
await this.database.saveSession(sessionEntry);
|
|
930
|
-
this.emitter.emit("session", sessionEntry, user);
|
|
931
|
-
// emit the message
|
|
932
|
-
const forwardedMsg = forward
|
|
933
|
-
? messageSchema.parse(msgpack.decode(message))
|
|
934
|
-
: null;
|
|
935
|
-
const emitMsg = forwardedMsg
|
|
936
|
-
? { ...forwardedMsg, forward: true }
|
|
937
|
-
: {
|
|
938
|
-
authorID: mail.authorID,
|
|
939
|
-
decrypted: true,
|
|
940
|
-
direction: "outgoing",
|
|
941
|
-
forward: mail.forward,
|
|
942
|
-
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
943
|
-
mailID: mail.mailID,
|
|
944
|
-
message: XUtils.encodeUTF8(message),
|
|
945
|
-
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
946
|
-
readerID: mail.readerID,
|
|
947
|
-
recipient: mail.recipient,
|
|
948
|
-
sender: mail.sender,
|
|
949
|
-
timestamp: new Date().toISOString(),
|
|
950
|
-
};
|
|
951
|
-
this.emitter.emit("message", emitMsg);
|
|
952
|
-
// send mail and wait for response
|
|
953
|
-
await new Promise((res, rej) => {
|
|
954
|
-
const callback = (packedMsg) => {
|
|
955
|
-
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
956
|
-
if (receivedMsg.transmissionID === msg.transmissionID) {
|
|
957
|
-
this.socket.off("message", callback);
|
|
958
|
-
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
959
|
-
if (parsed.success && parsed.data.type === "success") {
|
|
960
|
-
res(parsed.data.data);
|
|
961
|
-
}
|
|
962
|
-
else {
|
|
963
|
-
rej(new Error("Mail delivery failed: " +
|
|
964
|
-
JSON.stringify(receivedMsg)));
|
|
965
|
-
}
|
|
1090
|
+
catch (e) {
|
|
1091
|
+
if (allowKeyBundleFailure) {
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const wrap = e instanceof Error ? e : new Error(String(e), { cause: e });
|
|
1095
|
+
throw new Error(`Failed to load keyBundle for device ${device.deviceID}: ${wrap.message}`, { cause: e });
|
|
1096
|
+
}
|
|
1097
|
+
if (!this.xKeyRing) {
|
|
1098
|
+
if (this.manuallyClosing) {
|
|
1099
|
+
return;
|
|
966
1100
|
}
|
|
1101
|
+
throw new Error("Key ring not initialized.");
|
|
1102
|
+
}
|
|
1103
|
+
// my keys
|
|
1104
|
+
const IK_A = this.xKeyRing.identityKeys.secretKey;
|
|
1105
|
+
const IK_AP = this.xKeyRing.identityKeys.publicKey;
|
|
1106
|
+
const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
|
|
1107
|
+
const fips = this.cryptoProfile === "fips";
|
|
1108
|
+
// their keys — FIPS: `signKey` in bundle is the peer P-256 ECDH identity (raw, typically 65B).
|
|
1109
|
+
const SPK_B = new Uint8Array(keyBundle.preKey.publicKey);
|
|
1110
|
+
const OPK_B = keyBundle.otk
|
|
1111
|
+
? new Uint8Array(keyBundle.otk.publicKey)
|
|
1112
|
+
: null;
|
|
1113
|
+
const IK_B = fips
|
|
1114
|
+
? new Uint8Array(keyBundle.signKey)
|
|
1115
|
+
: (() => {
|
|
1116
|
+
const c = XKeyConvert.convertPublicKey(new Uint8Array(keyBundle.signKey));
|
|
1117
|
+
if (!c) {
|
|
1118
|
+
throw new Error("Could not convert sign key to X25519.");
|
|
1119
|
+
}
|
|
1120
|
+
return c;
|
|
1121
|
+
})();
|
|
1122
|
+
// diffie hellman functions
|
|
1123
|
+
const DH1 = await xDHAsync(new Uint8Array(IK_A), SPK_B);
|
|
1124
|
+
const DH2 = await xDHAsync(new Uint8Array(EK_A), IK_B);
|
|
1125
|
+
const DH3 = await xDHAsync(new Uint8Array(EK_A), SPK_B);
|
|
1126
|
+
const DH4 = OPK_B
|
|
1127
|
+
? await xDHAsync(new Uint8Array(EK_A), OPK_B)
|
|
1128
|
+
: null;
|
|
1129
|
+
// initial key material
|
|
1130
|
+
const IKM = DH4
|
|
1131
|
+
? xConcat(DH1, DH2, DH3, DH4)
|
|
1132
|
+
: xConcat(DH1, DH2, DH3);
|
|
1133
|
+
// one time key index
|
|
1134
|
+
const IDX = keyBundle.otk
|
|
1135
|
+
? XUtils.numberToUint8Arr(keyBundle.otk.index ?? 0)
|
|
1136
|
+
: XUtils.numberToUint8Arr(0);
|
|
1137
|
+
// shared secret key
|
|
1138
|
+
const SK = xKDF(IKM);
|
|
1139
|
+
const PK = (await xBoxKeyPairFromSecretAsync(SK)).publicKey;
|
|
1140
|
+
const AD = fips
|
|
1141
|
+
? fipsP256AdFromIdentityPubs(IK_AP, new Uint8Array(keyBundle.signKey))
|
|
1142
|
+
: xConcat(xEncode(xConstants.CURVE, IK_AP), xEncode(xConstants.CURVE, IK_B));
|
|
1143
|
+
const nonce = xMakeNonce();
|
|
1144
|
+
const cipher = await xSecretboxAsync(message, nonce, SK);
|
|
1145
|
+
const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
|
|
1146
|
+
const ephKeyWire = this.xKeyRing.ephemeralKeys.publicKey;
|
|
1147
|
+
const extra = fips
|
|
1148
|
+
? encodeFipsInitialExtraV1(signKeyWire, ephKeyWire, PK, AD, IDX)
|
|
1149
|
+
: xConcat(this.signKeys.publicKey, this.xKeyRing.ephemeralKeys.publicKey, PK, AD, IDX);
|
|
1150
|
+
const mail = {
|
|
1151
|
+
authorID: this.getUser().userID,
|
|
1152
|
+
cipher,
|
|
1153
|
+
extra,
|
|
1154
|
+
forward,
|
|
1155
|
+
group,
|
|
1156
|
+
mailID: mailID || uuid.v4(),
|
|
1157
|
+
mailType: MailType.initial,
|
|
1158
|
+
nonce,
|
|
1159
|
+
readerID: user.userID,
|
|
1160
|
+
recipient: device.deviceID,
|
|
1161
|
+
sender: this.getDevice().deviceID,
|
|
1162
|
+
};
|
|
1163
|
+
const hmac = xHMAC(mail, SK);
|
|
1164
|
+
const msg = {
|
|
1165
|
+
action: "CREATE",
|
|
1166
|
+
data: mail,
|
|
1167
|
+
resourceType: "mail",
|
|
1168
|
+
transmissionID: uuid.v4(),
|
|
1169
|
+
type: "resource",
|
|
967
1170
|
};
|
|
968
|
-
|
|
969
|
-
|
|
1171
|
+
// discard the ephemeral keys
|
|
1172
|
+
await this.newEphemeralKeys();
|
|
1173
|
+
const sessionEntry = {
|
|
1174
|
+
deviceID: device.deviceID,
|
|
1175
|
+
fingerprint: XUtils.encodeHex(AD),
|
|
1176
|
+
lastUsed: new Date().toISOString(),
|
|
1177
|
+
mode: "initiator",
|
|
1178
|
+
publicKey: XUtils.encodeHex(PK),
|
|
1179
|
+
sessionID: uuid.v4(),
|
|
1180
|
+
SK: XUtils.encodeHex(SK),
|
|
1181
|
+
userID: user.userID,
|
|
1182
|
+
verified: false,
|
|
1183
|
+
};
|
|
1184
|
+
await this.database.saveSession(sessionEntry);
|
|
1185
|
+
this.emitter.emit("session", sessionEntry, user);
|
|
1186
|
+
// emit the message
|
|
1187
|
+
const forwardedMsg = forward
|
|
1188
|
+
? messageSchema.parse(msgpack.decode(message))
|
|
1189
|
+
: null;
|
|
1190
|
+
const emitMsg = forwardedMsg
|
|
1191
|
+
? { ...forwardedMsg, forward: true }
|
|
1192
|
+
: {
|
|
1193
|
+
authorID: mail.authorID,
|
|
1194
|
+
decrypted: true,
|
|
1195
|
+
direction: "outgoing",
|
|
1196
|
+
forward: mail.forward,
|
|
1197
|
+
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
1198
|
+
mailID: mail.mailID,
|
|
1199
|
+
message: XUtils.encodeUTF8(message),
|
|
1200
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
1201
|
+
readerID: mail.readerID,
|
|
1202
|
+
recipient: mail.recipient,
|
|
1203
|
+
sender: mail.sender,
|
|
1204
|
+
timestamp: new Date().toISOString(),
|
|
1205
|
+
};
|
|
1206
|
+
this.emitter.emit("message", emitMsg);
|
|
1207
|
+
// send mail and wait for response
|
|
1208
|
+
await new Promise((res, rej) => {
|
|
1209
|
+
const callback = (packedMsg) => {
|
|
1210
|
+
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
1211
|
+
if (receivedMsg.transmissionID === msg.transmissionID) {
|
|
1212
|
+
this.socket.off("message", callback);
|
|
1213
|
+
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
1214
|
+
if (parsed.success && parsed.data.type === "success") {
|
|
1215
|
+
res(parsed.data.data);
|
|
1216
|
+
}
|
|
1217
|
+
else {
|
|
1218
|
+
rej(new Error("Mail delivery failed: " +
|
|
1219
|
+
JSON.stringify(receivedMsg)));
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
this.socket.on("message", callback);
|
|
1224
|
+
void this.send(msg, hmac);
|
|
1225
|
+
});
|
|
970
1226
|
});
|
|
971
|
-
this.sending.delete(device.deviceID);
|
|
972
1227
|
}
|
|
973
1228
|
async deleteChannel(channelID) {
|
|
974
1229
|
await this.http.delete(this.getHost() + "/channel/" + channelID);
|
|
@@ -1034,6 +1289,9 @@ export class Client {
|
|
|
1034
1289
|
}
|
|
1035
1290
|
}
|
|
1036
1291
|
async forward(message) {
|
|
1292
|
+
if (this.isManualCloseInFlight()) {
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1037
1295
|
const copy = { ...message };
|
|
1038
1296
|
if (this.forwarded.has(copy.mailID)) {
|
|
1039
1297
|
return;
|
|
@@ -1047,19 +1305,17 @@ export class Client {
|
|
|
1047
1305
|
}
|
|
1048
1306
|
const msgBytes = Uint8Array.from(msgpack.encode(copy));
|
|
1049
1307
|
const devices = await this.fetchUserDeviceListWithBackoff(this.getUser().userID, "own");
|
|
1050
|
-
const promises = [];
|
|
1051
1308
|
for (const device of devices) {
|
|
1052
|
-
if (device.deviceID
|
|
1053
|
-
|
|
1309
|
+
if (device.deviceID === this.getDevice().deviceID) {
|
|
1310
|
+
continue;
|
|
1054
1311
|
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
for (const result of results) {
|
|
1058
|
-
const { status } = result;
|
|
1059
|
-
if (status === "rejected") {
|
|
1060
|
-
}
|
|
1312
|
+
try {
|
|
1313
|
+
await this.sendMail(device, this.getUser(), msgBytes, null, copy.mailID, true);
|
|
1061
1314
|
}
|
|
1062
|
-
|
|
1315
|
+
catch {
|
|
1316
|
+
/* best-effort per device; parallel handshakes share ephemeral state */
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1063
1319
|
}
|
|
1064
1320
|
async getChannelByID(channelID) {
|
|
1065
1321
|
try {
|
|
@@ -1142,18 +1398,45 @@ export class Client {
|
|
|
1142
1398
|
.array(mailInboxEntry)
|
|
1143
1399
|
.parse(msgpack.decode(mailBuffer));
|
|
1144
1400
|
const inbox = rawInbox.sort((a, b) => b[2].localeCompare(a[2]));
|
|
1401
|
+
if (libvexDebugDmEnabled()) {
|
|
1402
|
+
const did = (() => {
|
|
1403
|
+
try {
|
|
1404
|
+
return this.getDevice().deviceID;
|
|
1405
|
+
}
|
|
1406
|
+
catch {
|
|
1407
|
+
return "(no device)";
|
|
1408
|
+
}
|
|
1409
|
+
})();
|
|
1410
|
+
debugLibvexDm("getMail: inbox", {
|
|
1411
|
+
deviceID: did,
|
|
1412
|
+
count: String(inbox.length),
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1145
1415
|
for (const mailDetails of inbox) {
|
|
1146
1416
|
const [mailHeader, mailBody, timestamp] = mailDetails;
|
|
1147
1417
|
try {
|
|
1418
|
+
if (libvexDebugDmEnabled()) {
|
|
1419
|
+
debugLibvexDm("getMail: readMail one", {
|
|
1420
|
+
mailID: mailBody.mailID,
|
|
1421
|
+
type: String(mailBody.mailType),
|
|
1422
|
+
recipient: mailBody.recipient,
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1148
1425
|
await this.readMail(mailHeader, mailBody, timestamp);
|
|
1149
1426
|
}
|
|
1150
|
-
catch (
|
|
1151
|
-
|
|
1427
|
+
catch (readMailErr) {
|
|
1428
|
+
if (libvexDebugDmEnabled()) {
|
|
1429
|
+
// eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
|
|
1430
|
+
console.error("[libvex:debug-dm] readMail threw", readMailErr);
|
|
1431
|
+
}
|
|
1152
1432
|
}
|
|
1153
1433
|
}
|
|
1154
1434
|
}
|
|
1155
|
-
catch (
|
|
1156
|
-
|
|
1435
|
+
catch (fetchErr) {
|
|
1436
|
+
if (libvexDebugDmEnabled()) {
|
|
1437
|
+
// eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
|
|
1438
|
+
console.error("[libvex:debug-dm] getMail fetch failed", fetchErr);
|
|
1439
|
+
}
|
|
1157
1440
|
}
|
|
1158
1441
|
this.fetchingMail = false;
|
|
1159
1442
|
}
|
|
@@ -1264,6 +1547,9 @@ export class Client {
|
|
|
1264
1547
|
}
|
|
1265
1548
|
}
|
|
1266
1549
|
async fetchUserDeviceListOnce(userID) {
|
|
1550
|
+
if (this.isManualCloseInFlight()) {
|
|
1551
|
+
return [];
|
|
1552
|
+
}
|
|
1267
1553
|
const res = await this.http.get(this.getHost() + "/user/" + userID + "/devices");
|
|
1268
1554
|
const devices = decodeAxios(DeviceArrayCodec, res.data);
|
|
1269
1555
|
for (const device of devices) {
|
|
@@ -1281,16 +1567,16 @@ export class Client {
|
|
|
1281
1567
|
: "Couldn't get device list";
|
|
1282
1568
|
let lastErr;
|
|
1283
1569
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
1570
|
+
if (this.isManualCloseInFlight()) {
|
|
1571
|
+
return [];
|
|
1572
|
+
}
|
|
1284
1573
|
if (attempt > 0) {
|
|
1285
|
-
if (this.isManualCloseInFlight()) {
|
|
1286
|
-
throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
|
|
1287
|
-
}
|
|
1288
1574
|
const delayMs = 100 * 2 ** (attempt - 1);
|
|
1289
|
-
// Chunk the delay
|
|
1575
|
+
// Chunk the delay so close() can finish before we retry HTTP.
|
|
1290
1576
|
const chunkMs = 10;
|
|
1291
1577
|
for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
|
|
1292
1578
|
if (this.isManualCloseInFlight()) {
|
|
1293
|
-
|
|
1579
|
+
return [];
|
|
1294
1580
|
}
|
|
1295
1581
|
await sleep(Math.min(chunkMs, delayMs - elapsed));
|
|
1296
1582
|
}
|
|
@@ -1324,6 +1610,24 @@ export class Client {
|
|
|
1324
1610
|
break;
|
|
1325
1611
|
}
|
|
1326
1612
|
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Pipeline for decrypted messages — registered in `init`. After `close()` sets
|
|
1615
|
+
* `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
|
|
1616
|
+
* race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
|
|
1617
|
+
*/
|
|
1618
|
+
onInternalMessage = (message) => {
|
|
1619
|
+
if (this.isManualCloseInFlight()) {
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
if (message.direction === "outgoing" && !message.forward) {
|
|
1623
|
+
void this.forward(message);
|
|
1624
|
+
}
|
|
1625
|
+
if (message.direction === "incoming" &&
|
|
1626
|
+
message.recipient === message.sender) {
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
void this.database.saveMessage(message);
|
|
1630
|
+
};
|
|
1327
1631
|
/**
|
|
1328
1632
|
* Initializes the keyring. This must be called before anything else.
|
|
1329
1633
|
*/
|
|
@@ -1339,16 +1643,7 @@ export class Client {
|
|
|
1339
1643
|
attachNodeAgentsToAxios(this.http, agents);
|
|
1340
1644
|
}
|
|
1341
1645
|
await this.populateKeyRing();
|
|
1342
|
-
this.emitter.on("message",
|
|
1343
|
-
if (message.direction === "outgoing" && !message.forward) {
|
|
1344
|
-
void this.forward(message);
|
|
1345
|
-
}
|
|
1346
|
-
if (message.direction === "incoming" &&
|
|
1347
|
-
message.recipient === message.sender) {
|
|
1348
|
-
return;
|
|
1349
|
-
}
|
|
1350
|
-
void this.database.saveMessage(message);
|
|
1351
|
-
});
|
|
1646
|
+
this.emitter.on("message", this.onInternalMessage);
|
|
1352
1647
|
this.emitter.emit("ready");
|
|
1353
1648
|
}
|
|
1354
1649
|
initSocket() {
|
|
@@ -1390,7 +1685,7 @@ export class Client {
|
|
|
1390
1685
|
const msg = parseResult.data;
|
|
1391
1686
|
switch (msg.type) {
|
|
1392
1687
|
case "challenge":
|
|
1393
|
-
this.respond(msg);
|
|
1688
|
+
void this.respond(msg);
|
|
1394
1689
|
break;
|
|
1395
1690
|
case "error":
|
|
1396
1691
|
break;
|
|
@@ -1449,14 +1744,14 @@ export class Client {
|
|
|
1449
1744
|
}
|
|
1450
1745
|
await this.submitOTK(needs);
|
|
1451
1746
|
}
|
|
1452
|
-
newEphemeralKeys() {
|
|
1747
|
+
async newEphemeralKeys() {
|
|
1453
1748
|
if (!this.xKeyRing) {
|
|
1454
1749
|
if (this.manuallyClosing) {
|
|
1455
1750
|
return;
|
|
1456
1751
|
}
|
|
1457
1752
|
throw new Error("Key ring not initialized.");
|
|
1458
1753
|
}
|
|
1459
|
-
this.xKeyRing.ephemeralKeys =
|
|
1754
|
+
this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
|
|
1460
1755
|
}
|
|
1461
1756
|
ping() {
|
|
1462
1757
|
if (!this.isAlive) {
|
|
@@ -1476,7 +1771,7 @@ export class Client {
|
|
|
1476
1771
|
const existingPreKeys = await this.database.getPreKeys();
|
|
1477
1772
|
const preKeys = existingPreKeys ??
|
|
1478
1773
|
(await (async () => {
|
|
1479
|
-
const unsaved = this.createPreKey();
|
|
1774
|
+
const unsaved = await this.createPreKey();
|
|
1480
1775
|
const [saved] = await this.database.savePreKeys([unsaved], false);
|
|
1481
1776
|
if (!saved || saved.index == null)
|
|
1482
1777
|
throw new Error("Failed to save prekey — no index returned.");
|
|
@@ -1487,7 +1782,7 @@ export class Client {
|
|
|
1487
1782
|
this.sessionRecords[session.publicKey] =
|
|
1488
1783
|
sqlSessionToCrypto(session);
|
|
1489
1784
|
}
|
|
1490
|
-
const ephemeralKeys =
|
|
1785
|
+
const ephemeralKeys = await xBoxKeyPairAsync();
|
|
1491
1786
|
this.xKeyRing = {
|
|
1492
1787
|
ephemeralKeys,
|
|
1493
1788
|
identityKeys,
|
|
@@ -1495,11 +1790,15 @@ export class Client {
|
|
|
1495
1790
|
};
|
|
1496
1791
|
}
|
|
1497
1792
|
async postAuth() {
|
|
1793
|
+
const versionAtStart = this.postAuthVersion;
|
|
1498
1794
|
let count = 0;
|
|
1499
1795
|
for (;;) {
|
|
1500
1796
|
if (this.isManualCloseInFlight()) {
|
|
1501
1797
|
return;
|
|
1502
1798
|
}
|
|
1799
|
+
if (this.postAuthVersion !== versionAtStart) {
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1503
1802
|
try {
|
|
1504
1803
|
await this.getMail();
|
|
1505
1804
|
count++;
|
|
@@ -1513,12 +1812,18 @@ export class Client {
|
|
|
1513
1812
|
if (this.isManualCloseInFlight()) {
|
|
1514
1813
|
return;
|
|
1515
1814
|
}
|
|
1815
|
+
if (this.postAuthVersion !== versionAtStart) {
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1516
1818
|
// Chunk the idle delay so `close()` can unwind instead of waiting
|
|
1517
1819
|
// out one full 60s timer (which would keep the process alive).
|
|
1518
1820
|
for (let i = 0; i < 60; i++) {
|
|
1519
1821
|
if (this.isManualCloseInFlight()) {
|
|
1520
1822
|
return;
|
|
1521
1823
|
}
|
|
1824
|
+
if (this.postAuthVersion !== versionAtStart) {
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1522
1827
|
await sleep(1000);
|
|
1523
1828
|
}
|
|
1524
1829
|
}
|
|
@@ -1528,10 +1833,28 @@ export class Client {
|
|
|
1528
1833
|
}
|
|
1529
1834
|
async readMail(header, mail, timestamp) {
|
|
1530
1835
|
if (this.seenMailIDs.has(mail.mailID)) {
|
|
1836
|
+
if (libvexDebugDmEnabled()) {
|
|
1837
|
+
try {
|
|
1838
|
+
debugLibvexDm("readMail: skip (seen mailID)", {
|
|
1839
|
+
mailID: mail.mailID,
|
|
1840
|
+
thisDevice: this.getDevice().deviceID,
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
catch {
|
|
1844
|
+
debugLibvexDm("readMail: skip (seen mailID)", {
|
|
1845
|
+
mailID: mail.mailID,
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1531
1849
|
return;
|
|
1532
1850
|
}
|
|
1533
1851
|
this.seenMailIDs.add(mail.mailID);
|
|
1534
1852
|
if (this.manuallyClosing) {
|
|
1853
|
+
if (libvexDebugDmEnabled()) {
|
|
1854
|
+
debugLibvexDm("readMail: skip (manually closing)", {
|
|
1855
|
+
mailID: mail.mailID,
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1535
1858
|
return;
|
|
1536
1859
|
}
|
|
1537
1860
|
this.sendReceipt(new Uint8Array(mail.nonce));
|
|
@@ -1542,207 +1865,297 @@ export class Client {
|
|
|
1542
1865
|
}
|
|
1543
1866
|
this.reading = true;
|
|
1544
1867
|
try {
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
}
|
|
1549
|
-
const deviceEntry = await this.getDeviceByID(mail.sender);
|
|
1550
|
-
const [user, _err] = await this.fetchUser(mail.authorID);
|
|
1551
|
-
if (deviceEntry && user) {
|
|
1552
|
-
void this.createSession(deviceEntry, user, XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`), mail.group, uuid.v4(), false);
|
|
1553
|
-
}
|
|
1554
|
-
};
|
|
1555
|
-
switch (mail.mailType) {
|
|
1556
|
-
case MailType.initial:
|
|
1557
|
-
const extraParts = Client.deserializeExtra(MailType.initial, new Uint8Array(mail.extra));
|
|
1558
|
-
const signKey = extraParts[0];
|
|
1559
|
-
const ephKey = extraParts[1];
|
|
1560
|
-
const indexBytes = extraParts[3];
|
|
1561
|
-
if (!signKey || !ephKey || !indexBytes) {
|
|
1562
|
-
throw new Error("Malformed initial mail extra: missing signKey, ephKey, or indexBytes");
|
|
1563
|
-
}
|
|
1564
|
-
const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
|
|
1565
|
-
const otk = preKeyIndex === 0
|
|
1566
|
-
? null
|
|
1567
|
-
: await this.database.getOneTimeKey(preKeyIndex);
|
|
1568
|
-
if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
|
|
1868
|
+
await this.runWithThisCryptoProfile(async () => {
|
|
1869
|
+
const healSession = async () => {
|
|
1870
|
+
if (this.manuallyClosing || !this.xKeyRing) {
|
|
1569
1871
|
return;
|
|
1570
1872
|
}
|
|
1571
|
-
|
|
1572
|
-
const
|
|
1573
|
-
if (
|
|
1574
|
-
|
|
1575
|
-
}
|
|
1576
|
-
const IK_A = IK_A_raw;
|
|
1577
|
-
const EK_A = ephKey;
|
|
1578
|
-
if (!this.xKeyRing) {
|
|
1579
|
-
return;
|
|
1580
|
-
}
|
|
1581
|
-
// my private keys
|
|
1582
|
-
const IK_B = this.xKeyRing.identityKeys.secretKey;
|
|
1583
|
-
const IK_BP = this.xKeyRing.identityKeys.publicKey;
|
|
1584
|
-
const SPK_B = this.xKeyRing.preKeys.keyPair.secretKey;
|
|
1585
|
-
const OPK_B = otk ? otk.keyPair.secretKey : null;
|
|
1586
|
-
// diffie hellman functions
|
|
1587
|
-
const DH1 = xDH(SPK_B, IK_A);
|
|
1588
|
-
const DH2 = xDH(IK_B, EK_A);
|
|
1589
|
-
const DH3 = xDH(SPK_B, EK_A);
|
|
1590
|
-
const DH4 = OPK_B ? xDH(OPK_B, EK_A) : null;
|
|
1591
|
-
// initial key material
|
|
1592
|
-
const IKM = DH4
|
|
1593
|
-
? xConcat(DH1, DH2, DH3, DH4)
|
|
1594
|
-
: xConcat(DH1, DH2, DH3);
|
|
1595
|
-
// shared secret key
|
|
1596
|
-
const SK = xKDF(IKM);
|
|
1597
|
-
const PK = xBoxKeyPairFromSecret(SK).publicKey;
|
|
1598
|
-
const hmac = xHMAC(mail, SK);
|
|
1599
|
-
// associated data
|
|
1600
|
-
const AD = xConcat(xEncode(xConstants.CURVE, IK_A), xEncode(xConstants.CURVE, IK_BP));
|
|
1601
|
-
if (!XUtils.bytesEqual(hmac, header)) {
|
|
1602
|
-
return;
|
|
1873
|
+
const deviceEntry = await this.getDeviceByID(mail.sender);
|
|
1874
|
+
const [user, _err] = await this.fetchUser(mail.authorID);
|
|
1875
|
+
if (deviceEntry && user) {
|
|
1876
|
+
void this.createSession(deviceEntry, user, XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`), mail.group, uuid.v4(), false, true);
|
|
1603
1877
|
}
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1878
|
+
};
|
|
1879
|
+
switch (mail.mailType) {
|
|
1880
|
+
case MailType.initial:
|
|
1881
|
+
const extraParts = Client.deserializeExtra(MailType.initial, new Uint8Array(mail.extra));
|
|
1882
|
+
const signKey = extraParts[0];
|
|
1883
|
+
const ephKey = extraParts[1];
|
|
1884
|
+
const indexBytes = extraParts[3];
|
|
1885
|
+
if (!signKey || !ephKey || !indexBytes) {
|
|
1886
|
+
throw new Error("Malformed initial mail extra: missing signKey, ephKey, or indexBytes");
|
|
1609
1887
|
}
|
|
1610
|
-
|
|
1611
|
-
const
|
|
1612
|
-
?
|
|
1613
|
-
:
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
};
|
|
1632
|
-
this.emitter.emit("message", message);
|
|
1633
|
-
// discard onetimekey
|
|
1634
|
-
await this.database.deleteOneTimeKey(preKeyIndex);
|
|
1635
|
-
const deviceEntry = await this.getDeviceByID(mail.sender);
|
|
1636
|
-
if (!deviceEntry) {
|
|
1637
|
-
throw new Error("Couldn't get device entry.");
|
|
1888
|
+
const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
|
|
1889
|
+
const otk = preKeyIndex === 0
|
|
1890
|
+
? null
|
|
1891
|
+
: await this.database.getOneTimeKey(preKeyIndex);
|
|
1892
|
+
if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
|
|
1893
|
+
if (libvexDebugDmEnabled()) {
|
|
1894
|
+
try {
|
|
1895
|
+
debugLibvexDm("readMail initial: abort (otk index mismatch)", {
|
|
1896
|
+
mailID: mail.mailID,
|
|
1897
|
+
preKeyIndex: String(preKeyIndex),
|
|
1898
|
+
otkIndex: String(otk?.index ?? "null"),
|
|
1899
|
+
thisDevice: this.getDevice().deviceID,
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
catch {
|
|
1903
|
+
debugLibvexDm("readMail initial: abort (otk index mismatch)", {
|
|
1904
|
+
mailID: mail.mailID,
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
return;
|
|
1638
1909
|
}
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1910
|
+
// their public keys
|
|
1911
|
+
const fipsRead = isFipsInitialExtraV1(new Uint8Array(mail.extra));
|
|
1912
|
+
const IK_A = fipsRead
|
|
1913
|
+
? signKey
|
|
1914
|
+
: (() => {
|
|
1915
|
+
const c = XKeyConvert.convertPublicKey(signKey);
|
|
1916
|
+
if (!c) {
|
|
1917
|
+
return null;
|
|
1918
|
+
}
|
|
1919
|
+
return c;
|
|
1920
|
+
})();
|
|
1921
|
+
if (!IK_A) {
|
|
1922
|
+
if (libvexDebugDmEnabled()) {
|
|
1923
|
+
try {
|
|
1924
|
+
debugLibvexDm("readMail initial: abort (IK_A null, Ed→X25519?)", {
|
|
1925
|
+
mailID: mail.mailID,
|
|
1926
|
+
fips: String(fipsRead),
|
|
1927
|
+
thisDevice: this.getDevice().deviceID,
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
catch {
|
|
1931
|
+
debugLibvexDm("readMail initial: abort (IK_A null)", {
|
|
1932
|
+
mailID: mail.mailID,
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
return;
|
|
1642
1937
|
}
|
|
1643
|
-
|
|
1644
|
-
this.
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
const
|
|
1659
|
-
|
|
1660
|
-
|
|
1938
|
+
const EK_A = ephKey;
|
|
1939
|
+
if (!this.xKeyRing) {
|
|
1940
|
+
if (libvexDebugDmEnabled()) {
|
|
1941
|
+
debugLibvexDm("readMail initial: abort (no xKeyRing)", {
|
|
1942
|
+
mailID: mail.mailID,
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
// my private keys
|
|
1948
|
+
const IK_B = this.xKeyRing.identityKeys.secretKey;
|
|
1949
|
+
const IK_BP = this.xKeyRing.identityKeys.publicKey;
|
|
1950
|
+
const SPK_B = this.xKeyRing.preKeys.keyPair.secretKey;
|
|
1951
|
+
const OPK_B = otk ? otk.keyPair.secretKey : null;
|
|
1952
|
+
// diffie hellman functions
|
|
1953
|
+
const DH1 = await xDHAsync(SPK_B, IK_A);
|
|
1954
|
+
const DH2 = await xDHAsync(IK_B, EK_A);
|
|
1955
|
+
const DH3 = await xDHAsync(SPK_B, EK_A);
|
|
1956
|
+
const DH4 = OPK_B ? await xDHAsync(OPK_B, EK_A) : null;
|
|
1957
|
+
// initial key material
|
|
1958
|
+
const IKM = DH4
|
|
1959
|
+
? xConcat(DH1, DH2, DH3, DH4)
|
|
1960
|
+
: xConcat(DH1, DH2, DH3);
|
|
1961
|
+
// shared secret key
|
|
1962
|
+
const SK = xKDF(IKM);
|
|
1963
|
+
const PK = (await xBoxKeyPairFromSecretAsync(SK))
|
|
1964
|
+
.publicKey;
|
|
1965
|
+
const hmac = xHMAC(mail, SK);
|
|
1966
|
+
// associated data
|
|
1967
|
+
const AD = fipsRead
|
|
1968
|
+
? fipsP256AdFromIdentityPubs(IK_A, IK_BP)
|
|
1969
|
+
: xConcat(xEncode(xConstants.CURVE, IK_A), xEncode(xConstants.CURVE, IK_BP));
|
|
1970
|
+
if (!XUtils.bytesEqual(hmac, header)) {
|
|
1971
|
+
if (libvexDebugDmEnabled()) {
|
|
1972
|
+
try {
|
|
1973
|
+
debugLibvexDm("readMail initial: abort (HMAC mismatch)", {
|
|
1974
|
+
mailID: mail.mailID,
|
|
1975
|
+
preKeyIndex: String(preKeyIndex),
|
|
1976
|
+
thisDevice: this.getDevice().deviceID,
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
catch {
|
|
1980
|
+
debugLibvexDm("readMail initial: abort (HMAC mismatch)", {
|
|
1981
|
+
mailID: mail.mailID,
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
const unsealed = await xSecretboxOpenAsync(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), SK);
|
|
1988
|
+
if (unsealed) {
|
|
1989
|
+
let plaintext = "";
|
|
1990
|
+
if (!mail.forward) {
|
|
1991
|
+
plaintext = XUtils.encodeUTF8(unsealed);
|
|
1992
|
+
}
|
|
1993
|
+
// emit the message
|
|
1994
|
+
const fwdMsg1 = mail.forward
|
|
1995
|
+
? messageSchema.parse(msgpack.decode(unsealed))
|
|
1996
|
+
: null;
|
|
1997
|
+
const message = fwdMsg1
|
|
1998
|
+
? { ...fwdMsg1, forward: true }
|
|
1999
|
+
: {
|
|
2000
|
+
authorID: mail.authorID,
|
|
2001
|
+
decrypted: true,
|
|
2002
|
+
direction: "incoming",
|
|
2003
|
+
forward: mail.forward,
|
|
2004
|
+
group: mail.group
|
|
2005
|
+
? uuid.stringify(mail.group)
|
|
2006
|
+
: null,
|
|
2007
|
+
mailID: mail.mailID,
|
|
2008
|
+
message: plaintext,
|
|
2009
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
2010
|
+
readerID: mail.readerID,
|
|
2011
|
+
recipient: mail.recipient,
|
|
2012
|
+
sender: mail.sender,
|
|
2013
|
+
timestamp: timestamp,
|
|
2014
|
+
};
|
|
2015
|
+
this.emitter.emit("message", message);
|
|
2016
|
+
if (libvexDebugDmEnabled()) {
|
|
2017
|
+
try {
|
|
2018
|
+
debugLibvexDm("readMail initial: ok (emit message)", {
|
|
2019
|
+
mailID: mail.mailID,
|
|
2020
|
+
preKeyIndex: String(preKeyIndex),
|
|
2021
|
+
thisDevice: this.getDevice().deviceID,
|
|
2022
|
+
plaintextLen: String(plaintext.length),
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
catch {
|
|
2026
|
+
debugLibvexDm("readMail initial: ok (emit message)", {
|
|
2027
|
+
mailID: mail.mailID,
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
// preKeyIndex 0 = med prekey only (no OTK in the X3DH path). Do
|
|
2032
|
+
// not call deleteOneTimeKey(0) — that is not "remove OTK row 0".
|
|
2033
|
+
if (preKeyIndex !== 0) {
|
|
2034
|
+
await this.database.deleteOneTimeKey(preKeyIndex);
|
|
2035
|
+
}
|
|
2036
|
+
const deviceEntry = await this.getDeviceByID(mail.sender);
|
|
2037
|
+
if (!deviceEntry) {
|
|
2038
|
+
throw new Error("Couldn't get device entry.");
|
|
2039
|
+
}
|
|
2040
|
+
const [userEntry, _userErr] = await this.fetchUser(deviceEntry.owner);
|
|
2041
|
+
if (!userEntry) {
|
|
2042
|
+
throw new Error("Couldn't get user entry.");
|
|
2043
|
+
}
|
|
2044
|
+
this.userRecords[userEntry.userID] = userEntry;
|
|
2045
|
+
this.deviceRecords[deviceEntry.deviceID] =
|
|
2046
|
+
deviceEntry;
|
|
2047
|
+
// save session
|
|
2048
|
+
const newSession = {
|
|
2049
|
+
deviceID: mail.sender,
|
|
2050
|
+
fingerprint: XUtils.encodeHex(AD),
|
|
2051
|
+
lastUsed: new Date().toISOString(),
|
|
2052
|
+
mode: "receiver",
|
|
2053
|
+
publicKey: XUtils.encodeHex(PK),
|
|
2054
|
+
sessionID: uuid.v4(),
|
|
2055
|
+
SK: XUtils.encodeHex(SK),
|
|
2056
|
+
userID: userEntry.userID,
|
|
2057
|
+
verified: false,
|
|
2058
|
+
};
|
|
2059
|
+
await this.database.saveSession(newSession);
|
|
2060
|
+
const [user] = await this.fetchUser(newSession.userID);
|
|
2061
|
+
if (user) {
|
|
2062
|
+
this.emitter.emit("session", newSession, user);
|
|
2063
|
+
}
|
|
2064
|
+
else {
|
|
2065
|
+
}
|
|
1661
2066
|
}
|
|
1662
2067
|
else {
|
|
2068
|
+
if (libvexDebugDmEnabled()) {
|
|
2069
|
+
debugLibvexDm("readMail initial: abort (xSecretboxOpen null)", {
|
|
2070
|
+
mailID: mail.mailID,
|
|
2071
|
+
preKeyIndex: String(preKeyIndex),
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
1663
2074
|
}
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
}
|
|
1673
|
-
let session = await this.getSessionByPubkey(publicKey);
|
|
1674
|
-
let retries = 0;
|
|
1675
|
-
while (!session) {
|
|
1676
|
-
if (retries >= 3) {
|
|
1677
|
-
break;
|
|
2075
|
+
break;
|
|
2076
|
+
case MailType.subsequent: {
|
|
2077
|
+
const extraBuf = new Uint8Array(mail.extra);
|
|
2078
|
+
const publicKey = isFipsSubsequentExtraV1(extraBuf)
|
|
2079
|
+
? decodeFipsSubsequentExtraV1(extraBuf)
|
|
2080
|
+
: Client.deserializeExtra(mail.mailType, extraBuf)[0];
|
|
2081
|
+
if (!publicKey) {
|
|
2082
|
+
throw new Error("Malformed subsequent mail extra: missing publicKey");
|
|
1678
2083
|
}
|
|
1679
|
-
|
|
1680
|
-
retries
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
void healSession();
|
|
1685
|
-
return;
|
|
1686
|
-
}
|
|
1687
|
-
const HMAC = xHMAC(mail, session.SK);
|
|
1688
|
-
if (!XUtils.bytesEqual(HMAC, header)) {
|
|
1689
|
-
void healSession();
|
|
1690
|
-
return;
|
|
1691
|
-
}
|
|
1692
|
-
const decrypted = xSecretboxOpen(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), session.SK);
|
|
1693
|
-
if (decrypted) {
|
|
1694
|
-
const fwdMsg2 = mail.forward
|
|
1695
|
-
? messageSchema.parse(msgpack.decode(decrypted))
|
|
1696
|
-
: null;
|
|
1697
|
-
const message = fwdMsg2
|
|
1698
|
-
? {
|
|
1699
|
-
...fwdMsg2,
|
|
1700
|
-
forward: true,
|
|
2084
|
+
let session = await this.getSessionByPubkey(publicKey);
|
|
2085
|
+
let retries = 0;
|
|
2086
|
+
while (!session) {
|
|
2087
|
+
if (retries >= 3) {
|
|
2088
|
+
break;
|
|
1701
2089
|
}
|
|
1702
|
-
|
|
2090
|
+
await sleep(100 * 2 ** retries);
|
|
2091
|
+
retries++;
|
|
2092
|
+
session = await this.getSessionByPubkey(publicKey);
|
|
2093
|
+
}
|
|
2094
|
+
if (!session) {
|
|
2095
|
+
void healSession();
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
const HMAC = xHMAC(mail, session.SK);
|
|
2099
|
+
if (!XUtils.bytesEqual(HMAC, header)) {
|
|
2100
|
+
void healSession();
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
const decrypted = await xSecretboxOpenAsync(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), session.SK);
|
|
2104
|
+
if (decrypted) {
|
|
2105
|
+
const fwdMsg2 = mail.forward
|
|
2106
|
+
? messageSchema.parse(msgpack.decode(decrypted))
|
|
2107
|
+
: null;
|
|
2108
|
+
const message = fwdMsg2
|
|
2109
|
+
? {
|
|
2110
|
+
...fwdMsg2,
|
|
2111
|
+
forward: true,
|
|
2112
|
+
}
|
|
2113
|
+
: {
|
|
2114
|
+
authorID: mail.authorID,
|
|
2115
|
+
decrypted: true,
|
|
2116
|
+
direction: "incoming",
|
|
2117
|
+
forward: mail.forward,
|
|
2118
|
+
group: mail.group
|
|
2119
|
+
? uuid.stringify(mail.group)
|
|
2120
|
+
: null,
|
|
2121
|
+
mailID: mail.mailID,
|
|
2122
|
+
message: XUtils.encodeUTF8(decrypted),
|
|
2123
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
2124
|
+
readerID: mail.readerID,
|
|
2125
|
+
recipient: mail.recipient,
|
|
2126
|
+
sender: mail.sender,
|
|
2127
|
+
timestamp: timestamp,
|
|
2128
|
+
};
|
|
2129
|
+
this.emitter.emit("message", message);
|
|
2130
|
+
void this.database.markSessionUsed(session.sessionID);
|
|
2131
|
+
}
|
|
2132
|
+
else {
|
|
2133
|
+
void healSession();
|
|
2134
|
+
// emit the message
|
|
2135
|
+
const message = {
|
|
1703
2136
|
authorID: mail.authorID,
|
|
1704
|
-
decrypted:
|
|
2137
|
+
decrypted: false,
|
|
1705
2138
|
direction: "incoming",
|
|
1706
2139
|
forward: mail.forward,
|
|
1707
2140
|
group: mail.group
|
|
1708
2141
|
? uuid.stringify(mail.group)
|
|
1709
2142
|
: null,
|
|
1710
2143
|
mailID: mail.mailID,
|
|
1711
|
-
message:
|
|
2144
|
+
message: "",
|
|
1712
2145
|
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
1713
2146
|
readerID: mail.readerID,
|
|
1714
2147
|
recipient: mail.recipient,
|
|
1715
2148
|
sender: mail.sender,
|
|
1716
2149
|
timestamp: timestamp,
|
|
1717
2150
|
};
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
else {
|
|
1722
|
-
void healSession();
|
|
1723
|
-
// emit the message
|
|
1724
|
-
const message = {
|
|
1725
|
-
authorID: mail.authorID,
|
|
1726
|
-
decrypted: false,
|
|
1727
|
-
direction: "incoming",
|
|
1728
|
-
forward: mail.forward,
|
|
1729
|
-
group: mail.group
|
|
1730
|
-
? uuid.stringify(mail.group)
|
|
1731
|
-
: null,
|
|
1732
|
-
mailID: mail.mailID,
|
|
1733
|
-
message: "",
|
|
1734
|
-
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
1735
|
-
readerID: mail.readerID,
|
|
1736
|
-
recipient: mail.recipient,
|
|
1737
|
-
sender: mail.sender,
|
|
1738
|
-
timestamp: timestamp,
|
|
1739
|
-
};
|
|
1740
|
-
this.emitter.emit("message", message);
|
|
2151
|
+
this.emitter.emit("message", message);
|
|
2152
|
+
}
|
|
2153
|
+
break;
|
|
1741
2154
|
}
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
}
|
|
2155
|
+
default:
|
|
2156
|
+
break;
|
|
2157
|
+
}
|
|
2158
|
+
});
|
|
1746
2159
|
}
|
|
1747
2160
|
finally {
|
|
1748
2161
|
this.reading = false;
|
|
@@ -1771,8 +2184,11 @@ export class Client {
|
|
|
1771
2184
|
if (!token) {
|
|
1772
2185
|
throw new Error("Couldn't fetch token.");
|
|
1773
2186
|
}
|
|
2187
|
+
// Stored on Spire for signature verification: Ed25519 (hex) in tweetnacl;
|
|
2188
|
+
// P-256 ECDSA SPKI (hex) in FIPS. The server maps this to a raw ECDH
|
|
2189
|
+
// identity in `getKeyBundle` for X3DH; see spire `Database.getKeyBundle`.
|
|
1774
2190
|
const signKey = this.getKeys().public;
|
|
1775
|
-
const signed = XUtils.encodeHex(
|
|
2191
|
+
const signed = XUtils.encodeHex(await xSignAsync(Uint8Array.from(uuid.parse(token.key)), this.signKeys.secretKey));
|
|
1776
2192
|
const devPreKeyIndex = this.xKeyRing.preKeys.index;
|
|
1777
2193
|
const devMsg = {
|
|
1778
2194
|
deviceName: this.options?.deviceName ?? "unknown",
|
|
@@ -1790,9 +2206,9 @@ export class Client {
|
|
|
1790
2206
|
"/devices", msgpack.encode(devMsg), { headers: { "Content-Type": "application/msgpack" } });
|
|
1791
2207
|
return decodeAxios(DeviceCodec, res.data);
|
|
1792
2208
|
}
|
|
1793
|
-
respond(msg) {
|
|
2209
|
+
async respond(msg) {
|
|
1794
2210
|
const response = {
|
|
1795
|
-
signed:
|
|
2211
|
+
signed: await xSignAsync(new Uint8Array(msg.challenge), this.signKeys.secretKey),
|
|
1796
2212
|
transmissionID: msg.transmissionID,
|
|
1797
2213
|
type: "response",
|
|
1798
2214
|
};
|
|
@@ -1828,7 +2244,7 @@ export class Client {
|
|
|
1828
2244
|
},
|
|
1829
2245
|
});
|
|
1830
2246
|
const fileData = res.data;
|
|
1831
|
-
const decrypted =
|
|
2247
|
+
const decrypted = await xSecretboxOpenAsync(new Uint8Array(fileData), XUtils.decodeHex(details.nonce), XUtils.decodeHex(key));
|
|
1832
2248
|
if (decrypted) {
|
|
1833
2249
|
return {
|
|
1834
2250
|
data: new Uint8Array(decrypted),
|
|
@@ -1895,7 +2311,6 @@ export class Client {
|
|
|
1895
2311
|
this.userRecords[user.userID] = user;
|
|
1896
2312
|
}
|
|
1897
2313
|
const mailID = uuid.v4();
|
|
1898
|
-
const promises = [];
|
|
1899
2314
|
const userIDs = [...new Set(userList.map((user) => user.userID))];
|
|
1900
2315
|
const devices = await this.getMultiUserDeviceList(userIDs);
|
|
1901
2316
|
for (const device of devices) {
|
|
@@ -1903,15 +2318,13 @@ export class Client {
|
|
|
1903
2318
|
if (!ownerRecord) {
|
|
1904
2319
|
continue;
|
|
1905
2320
|
}
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
void Promise.allSettled(promises).then((results) => {
|
|
1909
|
-
for (const result of results) {
|
|
1910
|
-
const { status } = result;
|
|
1911
|
-
if (status === "rejected") {
|
|
1912
|
-
}
|
|
2321
|
+
try {
|
|
2322
|
+
await this.sendMail(device, ownerRecord, XUtils.decodeUTF8(message), uuidToUint8(channelID), mailID, false);
|
|
1913
2323
|
}
|
|
1914
|
-
|
|
2324
|
+
catch {
|
|
2325
|
+
/* best-effort; each device needs its own X3DH handshake (sequential) */
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
1915
2328
|
}
|
|
1916
2329
|
/* Sends encrypted mail to a user. */
|
|
1917
2330
|
async sendMail(device, user, msg, group, mailID, forward, retry = false) {
|
|
@@ -1919,74 +2332,97 @@ export class Client {
|
|
|
1919
2332
|
await sleep(100);
|
|
1920
2333
|
}
|
|
1921
2334
|
this.sending.set(device.deviceID, device);
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
authorID: this.getUser().userID,
|
|
1932
|
-
cipher,
|
|
1933
|
-
extra,
|
|
1934
|
-
forward,
|
|
1935
|
-
group,
|
|
1936
|
-
mailID: mailID || uuid.v4(),
|
|
1937
|
-
mailType: MailType.subsequent,
|
|
1938
|
-
nonce,
|
|
1939
|
-
readerID: session.userID,
|
|
1940
|
-
recipient: device.deviceID,
|
|
1941
|
-
sender: this.getDevice().deviceID,
|
|
1942
|
-
};
|
|
1943
|
-
const msgb = {
|
|
1944
|
-
action: "CREATE",
|
|
1945
|
-
data: mail,
|
|
1946
|
-
resourceType: "mail",
|
|
1947
|
-
transmissionID: uuid.v4(),
|
|
1948
|
-
type: "resource",
|
|
1949
|
-
};
|
|
1950
|
-
const hmac = xHMAC(mail, session.SK);
|
|
1951
|
-
const fwdOut = forward
|
|
1952
|
-
? messageSchema.parse(msgpack.decode(msg))
|
|
1953
|
-
: null;
|
|
1954
|
-
const outMsg = fwdOut
|
|
1955
|
-
? { ...fwdOut, forward: true }
|
|
1956
|
-
: {
|
|
1957
|
-
authorID: mail.authorID,
|
|
1958
|
-
decrypted: true,
|
|
1959
|
-
direction: "outgoing",
|
|
1960
|
-
forward: mail.forward,
|
|
1961
|
-
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
1962
|
-
mailID: mail.mailID,
|
|
1963
|
-
message: XUtils.encodeUTF8(msg),
|
|
1964
|
-
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
1965
|
-
readerID: mail.readerID,
|
|
1966
|
-
recipient: mail.recipient,
|
|
1967
|
-
sender: mail.sender,
|
|
1968
|
-
timestamp: new Date().toISOString(),
|
|
1969
|
-
};
|
|
1970
|
-
this.emitter.emit("message", outMsg);
|
|
1971
|
-
await new Promise((res, rej) => {
|
|
1972
|
-
const callback = (packedMsg) => {
|
|
1973
|
-
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
1974
|
-
if (receivedMsg.transmissionID === msgb.transmissionID) {
|
|
1975
|
-
this.socket.off("message", callback);
|
|
1976
|
-
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
1977
|
-
if (parsed.success && parsed.data.type === "success") {
|
|
1978
|
-
res(parsed.data.data);
|
|
1979
|
-
}
|
|
1980
|
-
else {
|
|
1981
|
-
rej(new Error("Mail delivery failed: " +
|
|
1982
|
-
JSON.stringify(receivedMsg)));
|
|
1983
|
-
}
|
|
2335
|
+
try {
|
|
2336
|
+
const session = await this.database.getSessionByDeviceID(device.deviceID);
|
|
2337
|
+
if (!session || retry) {
|
|
2338
|
+
if (libvexDebugDmEnabled()) {
|
|
2339
|
+
debugLibvexDm("sendMail: createSession path", {
|
|
2340
|
+
peerDevice: device.deviceID,
|
|
2341
|
+
retry: String(retry),
|
|
2342
|
+
hasSession: String(!!session),
|
|
2343
|
+
});
|
|
1984
2344
|
}
|
|
2345
|
+
await this.createSession(device, user, msg, group, mailID, forward, false);
|
|
2346
|
+
if (libvexDebugDmEnabled()) {
|
|
2347
|
+
debugLibvexDm("sendMail: createSession returned", {
|
|
2348
|
+
peerDevice: device.deviceID,
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
if (libvexDebugDmEnabled()) {
|
|
2354
|
+
debugLibvexDm("sendMail: subsequent path", {
|
|
2355
|
+
peerDevice: device.deviceID,
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
const nonce = xMakeNonce();
|
|
2359
|
+
const cipher = await xSecretboxAsync(msg, nonce, session.SK);
|
|
2360
|
+
const extra = this.cryptoProfile === "fips"
|
|
2361
|
+
? encodeFipsSubsequentExtraV1(session.publicKey)
|
|
2362
|
+
: session.publicKey;
|
|
2363
|
+
const mail = {
|
|
2364
|
+
authorID: this.getUser().userID,
|
|
2365
|
+
cipher,
|
|
2366
|
+
extra,
|
|
2367
|
+
forward,
|
|
2368
|
+
group,
|
|
2369
|
+
mailID: mailID || uuid.v4(),
|
|
2370
|
+
mailType: MailType.subsequent,
|
|
2371
|
+
nonce,
|
|
2372
|
+
readerID: session.userID,
|
|
2373
|
+
recipient: device.deviceID,
|
|
2374
|
+
sender: this.getDevice().deviceID,
|
|
1985
2375
|
};
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
2376
|
+
const msgb = {
|
|
2377
|
+
action: "CREATE",
|
|
2378
|
+
data: mail,
|
|
2379
|
+
resourceType: "mail",
|
|
2380
|
+
transmissionID: uuid.v4(),
|
|
2381
|
+
type: "resource",
|
|
2382
|
+
};
|
|
2383
|
+
const hmac = xHMAC(mail, session.SK);
|
|
2384
|
+
const fwdOut = forward
|
|
2385
|
+
? messageSchema.parse(msgpack.decode(msg))
|
|
2386
|
+
: null;
|
|
2387
|
+
const outMsg = fwdOut
|
|
2388
|
+
? { ...fwdOut, forward: true }
|
|
2389
|
+
: {
|
|
2390
|
+
authorID: mail.authorID,
|
|
2391
|
+
decrypted: true,
|
|
2392
|
+
direction: "outgoing",
|
|
2393
|
+
forward: mail.forward,
|
|
2394
|
+
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
2395
|
+
mailID: mail.mailID,
|
|
2396
|
+
message: XUtils.encodeUTF8(msg),
|
|
2397
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
2398
|
+
readerID: mail.readerID,
|
|
2399
|
+
recipient: mail.recipient,
|
|
2400
|
+
sender: mail.sender,
|
|
2401
|
+
timestamp: new Date().toISOString(),
|
|
2402
|
+
};
|
|
2403
|
+
this.emitter.emit("message", outMsg);
|
|
2404
|
+
await new Promise((res, rej) => {
|
|
2405
|
+
const callback = (packedMsg) => {
|
|
2406
|
+
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
2407
|
+
if (receivedMsg.transmissionID === msgb.transmissionID) {
|
|
2408
|
+
this.socket.off("message", callback);
|
|
2409
|
+
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
2410
|
+
if (parsed.success && parsed.data.type === "success") {
|
|
2411
|
+
res(parsed.data.data);
|
|
2412
|
+
}
|
|
2413
|
+
else {
|
|
2414
|
+
rej(new Error("Mail delivery failed: " +
|
|
2415
|
+
JSON.stringify(receivedMsg)));
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
};
|
|
2419
|
+
this.socket.on("message", callback);
|
|
2420
|
+
void this.send(msgb, hmac);
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
finally {
|
|
2424
|
+
this.sending.delete(device.deviceID);
|
|
2425
|
+
}
|
|
1990
2426
|
}
|
|
1991
2427
|
async sendMessage(userID, message) {
|
|
1992
2428
|
try {
|
|
@@ -1997,19 +2433,85 @@ export class Client {
|
|
|
1997
2433
|
if (!userEntry) {
|
|
1998
2434
|
throw new Error("Couldn't get user entry.");
|
|
1999
2435
|
}
|
|
2000
|
-
const
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2436
|
+
const afterBackoff = await this.fetchUserDeviceListWithBackoff(userID, "peer");
|
|
2437
|
+
// Back-to-back GETs, merged by deviceID: a second read can list a device
|
|
2438
|
+
// that was not visible in the first snapshot (automation + multi-device)
|
|
2439
|
+
// without adding a fixed sleep.
|
|
2440
|
+
let deviceListRaw = afterBackoff;
|
|
2441
|
+
try {
|
|
2442
|
+
const again = await this.fetchUserDeviceListOnce(userID);
|
|
2443
|
+
const byId = new Map();
|
|
2444
|
+
for (const d of afterBackoff) {
|
|
2445
|
+
byId.set(d.deviceID, d);
|
|
2446
|
+
}
|
|
2447
|
+
for (const d of again) {
|
|
2448
|
+
byId.set(d.deviceID, d);
|
|
2449
|
+
}
|
|
2450
|
+
deviceListRaw = [...byId.values()];
|
|
2451
|
+
}
|
|
2452
|
+
catch {
|
|
2453
|
+
deviceListRaw = afterBackoff;
|
|
2454
|
+
}
|
|
2455
|
+
if (deviceListRaw.length === 0) {
|
|
2456
|
+
throw new Error("No devices for user — cannot send direct message.");
|
|
2457
|
+
}
|
|
2458
|
+
// Stable order (Peer device list is otherwise DB-order dependent).
|
|
2459
|
+
const deviceList = [...deviceListRaw].sort((a, b) => a.deviceID.localeCompare(b.deviceID, "en"));
|
|
2460
|
+
if (libvexDebugDmEnabled()) {
|
|
2461
|
+
debugLibvexDm("sendMessage: peer device list (merged, sorted)", {
|
|
2462
|
+
userID,
|
|
2463
|
+
nAfterBackoff: String(afterBackoff.length),
|
|
2464
|
+
nMerged: String(deviceListRaw.length),
|
|
2465
|
+
nSorted: String(deviceList.length),
|
|
2466
|
+
ourDevice: this.getDevice().deviceID,
|
|
2467
|
+
});
|
|
2468
|
+
for (const [i, d] of deviceList.entries()) {
|
|
2469
|
+
debugLibvexDm(`sendMessage: device[${String(i)}]`, {
|
|
2470
|
+
deviceID: d.deviceID,
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2005
2473
|
}
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2474
|
+
let lastErr;
|
|
2475
|
+
let failCount = 0;
|
|
2476
|
+
for (const device of deviceList) {
|
|
2477
|
+
const mailID = uuid.v4();
|
|
2478
|
+
try {
|
|
2479
|
+
if (libvexDebugDmEnabled()) {
|
|
2480
|
+
debugLibvexDm("sendMessage: sendMail start", {
|
|
2481
|
+
recipientDevice: device.deviceID,
|
|
2482
|
+
mailID,
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
await this.sendMail(device, userEntry, XUtils.decodeUTF8(message), null, mailID, false);
|
|
2486
|
+
if (libvexDebugDmEnabled()) {
|
|
2487
|
+
debugLibvexDm("sendMessage: sendMail ok", {
|
|
2488
|
+
recipientDevice: device.deviceID,
|
|
2489
|
+
});
|
|
2010
2490
|
}
|
|
2011
2491
|
}
|
|
2012
|
-
|
|
2492
|
+
catch (e) {
|
|
2493
|
+
if (libvexDebugDmEnabled()) {
|
|
2494
|
+
// eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
|
|
2495
|
+
console.error("[libvex:debug-dm] sendMessage: sendMail failed for device", device.deviceID, e);
|
|
2496
|
+
}
|
|
2497
|
+
lastErr = e;
|
|
2498
|
+
failCount += 1;
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
if (failCount > 0) {
|
|
2502
|
+
const base = lastErr instanceof Error
|
|
2503
|
+
? lastErr
|
|
2504
|
+
: new Error(String(lastErr));
|
|
2505
|
+
if (failCount === deviceList.length) {
|
|
2506
|
+
throw base;
|
|
2507
|
+
}
|
|
2508
|
+
// Multi-device: do not “succeed” when only one device of several got mail —
|
|
2509
|
+
// callers and tests have no per-device result and the other copy times out.
|
|
2510
|
+
const partial = new Error(`Direct message failed to reach ${String(failCount)} of ` +
|
|
2511
|
+
`${String(deviceList.length)} peer device(s) (X3DH/post).`);
|
|
2512
|
+
partial.cause = base;
|
|
2513
|
+
throw partial;
|
|
2514
|
+
}
|
|
2013
2515
|
}
|
|
2014
2516
|
catch (err) {
|
|
2015
2517
|
throw err;
|
|
@@ -2032,7 +2534,7 @@ export class Client {
|
|
|
2032
2534
|
async submitOTK(amount) {
|
|
2033
2535
|
const otks = [];
|
|
2034
2536
|
for (let i = 0; i < amount; i++) {
|
|
2035
|
-
otks
|
|
2537
|
+
otks.push(await this.createPreKey());
|
|
2036
2538
|
}
|
|
2037
2539
|
const savedKeys = await this.database.savePreKeys(otks, true);
|
|
2038
2540
|
await this.http.post(this.getHost() + "/device/" + this.getDevice().deviceID + "/otk", msgpack.encode(savedKeys.map((key) => this.censorPreKey(key))), {
|