@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/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.
|
|
@@ -319,6 +419,11 @@ export class Client {
|
|
|
319
419
|
isAlive = true;
|
|
320
420
|
mailInterval;
|
|
321
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;
|
|
322
427
|
/* Retrieves the userID with the user identifier.
|
|
323
428
|
user identifier is checked for userID, then signkey,
|
|
324
429
|
and finally falls back to username. */
|
|
@@ -337,9 +442,12 @@ export class Client {
|
|
|
337
442
|
user;
|
|
338
443
|
userRecords = {};
|
|
339
444
|
xKeyRing;
|
|
340
|
-
|
|
341
|
-
|
|
445
|
+
cryptoProfile;
|
|
446
|
+
constructor(material, options, storage) {
|
|
342
447
|
this.options = options;
|
|
448
|
+
this.cryptoProfile = material.cryptoProfile;
|
|
449
|
+
this.signKeys = material.signKeys;
|
|
450
|
+
this.idKeys = material.idKeys;
|
|
343
451
|
if (options?.unsafeHttp) {
|
|
344
452
|
const env = Client.getNodeEnv();
|
|
345
453
|
if (env !== "development" && env !== "test") {
|
|
@@ -351,13 +459,6 @@ export class Client {
|
|
|
351
459
|
else {
|
|
352
460
|
this.prefixes = { HTTP: "https://", WS: "wss://" };
|
|
353
461
|
}
|
|
354
|
-
this.signKeys = privateKey
|
|
355
|
-
? xSignKeyPairFromSecret(XUtils.decodeHex(privateKey))
|
|
356
|
-
: xSignKeyPair();
|
|
357
|
-
this.idKeys = XKeyConvert.convertKeyPair(this.signKeys);
|
|
358
|
-
if (!this.idKeys) {
|
|
359
|
-
throw new Error("Could not convert key to X25519!");
|
|
360
|
-
}
|
|
361
462
|
this.host = options?.host || "api.vex.wtf";
|
|
362
463
|
const dbFileName = options?.inMemoryDb
|
|
363
464
|
? ":memory:"
|
|
@@ -396,29 +497,73 @@ export class Client {
|
|
|
396
497
|
* ```
|
|
397
498
|
*/
|
|
398
499
|
static create = async (privateKey, options, storage) => {
|
|
399
|
-
const
|
|
400
|
-
|
|
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);
|
|
401
530
|
let resolvedStorage = storage;
|
|
402
531
|
if (!resolvedStorage) {
|
|
403
532
|
const { createNodeStorage } = await import("./storage/node.js");
|
|
404
|
-
const dbFileName =
|
|
533
|
+
const dbFileName = options?.inMemoryDb
|
|
405
534
|
? ":memory:"
|
|
406
|
-
: XUtils.encodeHex(
|
|
407
|
-
const dbPath =
|
|
408
|
-
?
|
|
535
|
+
: XUtils.encodeHex(signKeys.publicKey) + ".sqlite";
|
|
536
|
+
const dbPath = options?.dbFolder
|
|
537
|
+
? options.dbFolder + "/" + dbFileName
|
|
409
538
|
: dbFileName;
|
|
410
|
-
resolvedStorage = createNodeStorage(dbPath,
|
|
411
|
-
}
|
|
412
|
-
|
|
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);
|
|
413
547
|
await client.init();
|
|
414
548
|
return client;
|
|
415
549
|
};
|
|
416
550
|
/**
|
|
417
|
-
* Generates
|
|
418
|
-
*
|
|
419
|
-
* @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).
|
|
420
553
|
*/
|
|
421
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
|
+
}
|
|
422
567
|
return XUtils.encodeHex(xSignKeyPair().secretKey);
|
|
423
568
|
}
|
|
424
569
|
/**
|
|
@@ -436,17 +581,23 @@ export class Client {
|
|
|
436
581
|
}
|
|
437
582
|
static deserializeExtra(type, extra) {
|
|
438
583
|
switch (type) {
|
|
439
|
-
case MailType.initial:
|
|
440
|
-
|
|
441
|
-
|
|
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) */
|
|
442
590
|
const signKey = extra.slice(0, 32);
|
|
443
591
|
const ephKey = extra.slice(32, 64);
|
|
444
592
|
const ad = extra.slice(96, 164);
|
|
445
593
|
const index = extra.slice(164, 170);
|
|
446
594
|
return [signKey, ephKey, ad, index];
|
|
595
|
+
}
|
|
447
596
|
case MailType.subsequent:
|
|
448
|
-
|
|
449
|
-
|
|
597
|
+
if (isFipsSubsequentExtraV1(extra)) {
|
|
598
|
+
return [decodeFipsSubsequentExtraV1(extra)];
|
|
599
|
+
}
|
|
600
|
+
return [extra];
|
|
450
601
|
default:
|
|
451
602
|
return [];
|
|
452
603
|
}
|
|
@@ -562,8 +713,8 @@ export class Client {
|
|
|
562
713
|
if (!connectToken) {
|
|
563
714
|
throw new Error("Couldn't get connect token.");
|
|
564
715
|
}
|
|
565
|
-
const
|
|
566
|
-
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" } });
|
|
567
718
|
const { deviceToken } = decodeAxios(ConnectResponseCodec, res.data);
|
|
568
719
|
this.http.defaults.headers.common["X-Device-Token"] = deviceToken;
|
|
569
720
|
this.initSocket();
|
|
@@ -572,6 +723,50 @@ export class Client {
|
|
|
572
723
|
await new Promise((r) => setTimeout(r, 0));
|
|
573
724
|
await this.negotiateOTK();
|
|
574
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
|
+
}
|
|
575
770
|
/**
|
|
576
771
|
* Delete all local data — message history, encryption sessions, and prekeys.
|
|
577
772
|
* Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
|
|
@@ -629,6 +824,12 @@ export class Client {
|
|
|
629
824
|
return { ok: true };
|
|
630
825
|
}
|
|
631
826
|
catch (err) {
|
|
827
|
+
if (isAxiosError(err) && err.response) {
|
|
828
|
+
return {
|
|
829
|
+
error: spireErrorBodyMessage(err.response.data),
|
|
830
|
+
ok: false,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
632
833
|
const error = err instanceof Error ? err.message : String(err);
|
|
633
834
|
return { error, ok: false };
|
|
634
835
|
}
|
|
@@ -653,7 +854,7 @@ export class Client {
|
|
|
653
854
|
signKey: signKeyHex,
|
|
654
855
|
}), { headers: { "Content-Type": "application/msgpack" } });
|
|
655
856
|
const { challenge, challengeID } = decodeAxios(DeviceChallengeCodec, challengeRes.data);
|
|
656
|
-
const signed = XUtils.encodeHex(
|
|
857
|
+
const signed = XUtils.encodeHex(await xSignAsync(XUtils.decodeHex(challenge), this.signKeys.secretKey));
|
|
657
858
|
const verifyRes = await this.http.post(this.getHost() + "/auth/device/verify", msgpack.encode({ challengeID, signed }), { headers: { "Content-Type": "application/msgpack" } });
|
|
658
859
|
const { token, user } = decodeAxios(AuthResponseCodec, verifyRes.data);
|
|
659
860
|
this.setUser(user);
|
|
@@ -710,7 +911,7 @@ export class Client {
|
|
|
710
911
|
const regKey = await this.getToken("register");
|
|
711
912
|
if (regKey) {
|
|
712
913
|
const signKey = XUtils.encodeHex(this.signKeys.publicKey);
|
|
713
|
-
const signed = XUtils.encodeHex(
|
|
914
|
+
const signed = XUtils.encodeHex(await xSignAsync(Uint8Array.from(uuid.parse(regKey.key)), this.signKeys.secretKey));
|
|
714
915
|
const preKeyIndex = this.xKeyRing.preKeys.index;
|
|
715
916
|
const regMsg = {
|
|
716
917
|
deviceName: this.options?.deviceName ?? "unknown",
|
|
@@ -729,11 +930,10 @@ export class Client {
|
|
|
729
930
|
}
|
|
730
931
|
catch (err) {
|
|
731
932
|
if (isAxiosError(err) && err.response) {
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
return [null, new Error(msg)];
|
|
933
|
+
return [
|
|
934
|
+
null,
|
|
935
|
+
new Error(spireErrorBodyMessage(err.response.data)),
|
|
936
|
+
];
|
|
737
937
|
}
|
|
738
938
|
return [
|
|
739
939
|
null,
|
|
@@ -792,41 +992,45 @@ export class Client {
|
|
|
792
992
|
}
|
|
793
993
|
// returns the file details and the encryption key
|
|
794
994
|
async createFile(file) {
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
loaded,
|
|
812
|
-
progress
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
+
});
|
|
830
1034
|
}
|
|
831
1035
|
async createInvite(serverID, duration) {
|
|
832
1036
|
const payload = {
|
|
@@ -836,145 +1040,190 @@ export class Client {
|
|
|
836
1040
|
const res = await this.http.post(this.getHost() + "/server/" + serverID + "/invites", msgpack.encode(payload), { headers: { "Content-Type": "application/msgpack" } });
|
|
837
1041
|
return decodeAxios(InviteCodec, res.data);
|
|
838
1042
|
}
|
|
839
|
-
createPreKey() {
|
|
840
|
-
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);
|
|
841
1048
|
return {
|
|
842
1049
|
keyPair: preKeyPair,
|
|
843
|
-
signature:
|
|
1050
|
+
signature: await xSignAsync(toSign, this.signKeys.secretKey),
|
|
844
1051
|
};
|
|
845
1052
|
}
|
|
846
1053
|
async createServer(name) {
|
|
847
1054
|
const res = await this.http.post(this.getHost() + "/server/" + globalThis.btoa(name));
|
|
848
1055
|
return decodeAxios(ServerCodec, res.data);
|
|
849
1056
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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);
|
|
855
1069
|
try {
|
|
856
|
-
|
|
1070
|
+
return await fn();
|
|
857
1071
|
}
|
|
858
|
-
|
|
859
|
-
|
|
1072
|
+
finally {
|
|
1073
|
+
setCryptoProfile(prev);
|
|
860
1074
|
}
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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);
|
|
864
1089
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
const IK_A = this.xKeyRing.identityKeys.secretKey;
|
|
869
|
-
const IK_AP = this.xKeyRing.identityKeys.publicKey;
|
|
870
|
-
const EK_A = this.xKeyRing.ephemeralKeys.secretKey;
|
|
871
|
-
// their keys
|
|
872
|
-
const IK_B_raw = XKeyConvert.convertPublicKey(new Uint8Array(keyBundle.signKey));
|
|
873
|
-
if (!IK_B_raw) {
|
|
874
|
-
throw new Error("Could not convert sign key to X25519.");
|
|
875
|
-
}
|
|
876
|
-
const IK_B = IK_B_raw;
|
|
877
|
-
const SPK_B = new Uint8Array(keyBundle.preKey.publicKey);
|
|
878
|
-
const OPK_B = keyBundle.otk
|
|
879
|
-
? new Uint8Array(keyBundle.otk.publicKey)
|
|
880
|
-
: null;
|
|
881
|
-
// diffie hellman functions
|
|
882
|
-
const DH1 = xDH(new Uint8Array(IK_A), SPK_B);
|
|
883
|
-
const DH2 = xDH(new Uint8Array(EK_A), IK_B);
|
|
884
|
-
const DH3 = xDH(new Uint8Array(EK_A), SPK_B);
|
|
885
|
-
const DH4 = OPK_B ? xDH(new Uint8Array(EK_A), OPK_B) : null;
|
|
886
|
-
// initial key material
|
|
887
|
-
const IKM = DH4 ? xConcat(DH1, DH2, DH3, DH4) : xConcat(DH1, DH2, DH3);
|
|
888
|
-
// one time key index
|
|
889
|
-
const IDX = keyBundle.otk
|
|
890
|
-
? XUtils.numberToUint8Arr(keyBundle.otk.index ?? 0)
|
|
891
|
-
: XUtils.numberToUint8Arr(0);
|
|
892
|
-
// shared secret key
|
|
893
|
-
const SK = xKDF(IKM);
|
|
894
|
-
const PK = xBoxKeyPairFromSecret(SK).publicKey;
|
|
895
|
-
const AD = xConcat(xEncode(xConstants.CURVE, IK_AP), xEncode(xConstants.CURVE, IK_B));
|
|
896
|
-
const nonce = xMakeNonce();
|
|
897
|
-
const cipher = xSecretbox(message, nonce, SK);
|
|
898
|
-
/* 32 bytes for signkey, 32 bytes for ephemeral key,
|
|
899
|
-
68 bytes for AD, 6 bytes for otk index (empty for no otk) */
|
|
900
|
-
const extra = xConcat(this.signKeys.publicKey, this.xKeyRing.ephemeralKeys.publicKey, PK, AD, IDX);
|
|
901
|
-
const mail = {
|
|
902
|
-
authorID: this.getUser().userID,
|
|
903
|
-
cipher,
|
|
904
|
-
extra,
|
|
905
|
-
forward,
|
|
906
|
-
group,
|
|
907
|
-
mailID: mailID || uuid.v4(),
|
|
908
|
-
mailType: MailType.initial,
|
|
909
|
-
nonce,
|
|
910
|
-
readerID: user.userID,
|
|
911
|
-
recipient: device.deviceID,
|
|
912
|
-
sender: this.getDevice().deviceID,
|
|
913
|
-
};
|
|
914
|
-
const hmac = xHMAC(mail, SK);
|
|
915
|
-
const msg = {
|
|
916
|
-
action: "CREATE",
|
|
917
|
-
data: mail,
|
|
918
|
-
resourceType: "mail",
|
|
919
|
-
transmissionID: uuid.v4(),
|
|
920
|
-
type: "resource",
|
|
921
|
-
};
|
|
922
|
-
// discard the ephemeral keys
|
|
923
|
-
this.newEphemeralKeys();
|
|
924
|
-
const sessionEntry = {
|
|
925
|
-
deviceID: device.deviceID,
|
|
926
|
-
fingerprint: XUtils.encodeHex(AD),
|
|
927
|
-
lastUsed: new Date().toISOString(),
|
|
928
|
-
mode: "initiator",
|
|
929
|
-
publicKey: XUtils.encodeHex(PK),
|
|
930
|
-
sessionID: uuid.v4(),
|
|
931
|
-
SK: XUtils.encodeHex(SK),
|
|
932
|
-
userID: user.userID,
|
|
933
|
-
verified: false,
|
|
934
|
-
};
|
|
935
|
-
await this.database.saveSession(sessionEntry);
|
|
936
|
-
this.emitter.emit("session", sessionEntry, user);
|
|
937
|
-
// emit the message
|
|
938
|
-
const forwardedMsg = forward
|
|
939
|
-
? messageSchema.parse(msgpack.decode(message))
|
|
940
|
-
: null;
|
|
941
|
-
const emitMsg = forwardedMsg
|
|
942
|
-
? { ...forwardedMsg, forward: true }
|
|
943
|
-
: {
|
|
944
|
-
authorID: mail.authorID,
|
|
945
|
-
decrypted: true,
|
|
946
|
-
direction: "outgoing",
|
|
947
|
-
forward: mail.forward,
|
|
948
|
-
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
949
|
-
mailID: mail.mailID,
|
|
950
|
-
message: XUtils.encodeUTF8(message),
|
|
951
|
-
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
952
|
-
readerID: mail.readerID,
|
|
953
|
-
recipient: mail.recipient,
|
|
954
|
-
sender: mail.sender,
|
|
955
|
-
timestamp: new Date().toISOString(),
|
|
956
|
-
};
|
|
957
|
-
this.emitter.emit("message", emitMsg);
|
|
958
|
-
// send mail and wait for response
|
|
959
|
-
await new Promise((res, rej) => {
|
|
960
|
-
const callback = (packedMsg) => {
|
|
961
|
-
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
962
|
-
if (receivedMsg.transmissionID === msg.transmissionID) {
|
|
963
|
-
this.socket.off("message", callback);
|
|
964
|
-
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
965
|
-
if (parsed.success && parsed.data.type === "success") {
|
|
966
|
-
res(parsed.data.data);
|
|
967
|
-
}
|
|
968
|
-
else {
|
|
969
|
-
rej(new Error("Mail delivery failed: " +
|
|
970
|
-
JSON.stringify(receivedMsg)));
|
|
971
|
-
}
|
|
1090
|
+
catch (e) {
|
|
1091
|
+
if (allowKeyBundleFailure) {
|
|
1092
|
+
return;
|
|
972
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;
|
|
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",
|
|
973
1170
|
};
|
|
974
|
-
|
|
975
|
-
|
|
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
|
+
});
|
|
976
1226
|
});
|
|
977
|
-
this.sending.delete(device.deviceID);
|
|
978
1227
|
}
|
|
979
1228
|
async deleteChannel(channelID) {
|
|
980
1229
|
await this.http.delete(this.getHost() + "/channel/" + channelID);
|
|
@@ -1056,19 +1305,17 @@ export class Client {
|
|
|
1056
1305
|
}
|
|
1057
1306
|
const msgBytes = Uint8Array.from(msgpack.encode(copy));
|
|
1058
1307
|
const devices = await this.fetchUserDeviceListWithBackoff(this.getUser().userID, "own");
|
|
1059
|
-
const promises = [];
|
|
1060
1308
|
for (const device of devices) {
|
|
1061
|
-
if (device.deviceID
|
|
1062
|
-
|
|
1309
|
+
if (device.deviceID === this.getDevice().deviceID) {
|
|
1310
|
+
continue;
|
|
1063
1311
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
for (const result of results) {
|
|
1067
|
-
const { status } = result;
|
|
1068
|
-
if (status === "rejected") {
|
|
1069
|
-
}
|
|
1312
|
+
try {
|
|
1313
|
+
await this.sendMail(device, this.getUser(), msgBytes, null, copy.mailID, true);
|
|
1070
1314
|
}
|
|
1071
|
-
|
|
1315
|
+
catch {
|
|
1316
|
+
/* best-effort per device; parallel handshakes share ephemeral state */
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1072
1319
|
}
|
|
1073
1320
|
async getChannelByID(channelID) {
|
|
1074
1321
|
try {
|
|
@@ -1151,18 +1398,45 @@ export class Client {
|
|
|
1151
1398
|
.array(mailInboxEntry)
|
|
1152
1399
|
.parse(msgpack.decode(mailBuffer));
|
|
1153
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
|
+
}
|
|
1154
1415
|
for (const mailDetails of inbox) {
|
|
1155
1416
|
const [mailHeader, mailBody, timestamp] = mailDetails;
|
|
1156
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
|
+
}
|
|
1157
1425
|
await this.readMail(mailHeader, mailBody, timestamp);
|
|
1158
1426
|
}
|
|
1159
|
-
catch (
|
|
1160
|
-
|
|
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
|
+
}
|
|
1161
1432
|
}
|
|
1162
1433
|
}
|
|
1163
1434
|
}
|
|
1164
|
-
catch (
|
|
1165
|
-
|
|
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
|
+
}
|
|
1166
1440
|
}
|
|
1167
1441
|
this.fetchingMail = false;
|
|
1168
1442
|
}
|
|
@@ -1411,7 +1685,7 @@ export class Client {
|
|
|
1411
1685
|
const msg = parseResult.data;
|
|
1412
1686
|
switch (msg.type) {
|
|
1413
1687
|
case "challenge":
|
|
1414
|
-
this.respond(msg);
|
|
1688
|
+
void this.respond(msg);
|
|
1415
1689
|
break;
|
|
1416
1690
|
case "error":
|
|
1417
1691
|
break;
|
|
@@ -1470,14 +1744,14 @@ export class Client {
|
|
|
1470
1744
|
}
|
|
1471
1745
|
await this.submitOTK(needs);
|
|
1472
1746
|
}
|
|
1473
|
-
newEphemeralKeys() {
|
|
1747
|
+
async newEphemeralKeys() {
|
|
1474
1748
|
if (!this.xKeyRing) {
|
|
1475
1749
|
if (this.manuallyClosing) {
|
|
1476
1750
|
return;
|
|
1477
1751
|
}
|
|
1478
1752
|
throw new Error("Key ring not initialized.");
|
|
1479
1753
|
}
|
|
1480
|
-
this.xKeyRing.ephemeralKeys =
|
|
1754
|
+
this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
|
|
1481
1755
|
}
|
|
1482
1756
|
ping() {
|
|
1483
1757
|
if (!this.isAlive) {
|
|
@@ -1497,7 +1771,7 @@ export class Client {
|
|
|
1497
1771
|
const existingPreKeys = await this.database.getPreKeys();
|
|
1498
1772
|
const preKeys = existingPreKeys ??
|
|
1499
1773
|
(await (async () => {
|
|
1500
|
-
const unsaved = this.createPreKey();
|
|
1774
|
+
const unsaved = await this.createPreKey();
|
|
1501
1775
|
const [saved] = await this.database.savePreKeys([unsaved], false);
|
|
1502
1776
|
if (!saved || saved.index == null)
|
|
1503
1777
|
throw new Error("Failed to save prekey — no index returned.");
|
|
@@ -1508,7 +1782,7 @@ export class Client {
|
|
|
1508
1782
|
this.sessionRecords[session.publicKey] =
|
|
1509
1783
|
sqlSessionToCrypto(session);
|
|
1510
1784
|
}
|
|
1511
|
-
const ephemeralKeys =
|
|
1785
|
+
const ephemeralKeys = await xBoxKeyPairAsync();
|
|
1512
1786
|
this.xKeyRing = {
|
|
1513
1787
|
ephemeralKeys,
|
|
1514
1788
|
identityKeys,
|
|
@@ -1516,11 +1790,15 @@ export class Client {
|
|
|
1516
1790
|
};
|
|
1517
1791
|
}
|
|
1518
1792
|
async postAuth() {
|
|
1793
|
+
const versionAtStart = this.postAuthVersion;
|
|
1519
1794
|
let count = 0;
|
|
1520
1795
|
for (;;) {
|
|
1521
1796
|
if (this.isManualCloseInFlight()) {
|
|
1522
1797
|
return;
|
|
1523
1798
|
}
|
|
1799
|
+
if (this.postAuthVersion !== versionAtStart) {
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1524
1802
|
try {
|
|
1525
1803
|
await this.getMail();
|
|
1526
1804
|
count++;
|
|
@@ -1534,12 +1812,18 @@ export class Client {
|
|
|
1534
1812
|
if (this.isManualCloseInFlight()) {
|
|
1535
1813
|
return;
|
|
1536
1814
|
}
|
|
1815
|
+
if (this.postAuthVersion !== versionAtStart) {
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1537
1818
|
// Chunk the idle delay so `close()` can unwind instead of waiting
|
|
1538
1819
|
// out one full 60s timer (which would keep the process alive).
|
|
1539
1820
|
for (let i = 0; i < 60; i++) {
|
|
1540
1821
|
if (this.isManualCloseInFlight()) {
|
|
1541
1822
|
return;
|
|
1542
1823
|
}
|
|
1824
|
+
if (this.postAuthVersion !== versionAtStart) {
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1543
1827
|
await sleep(1000);
|
|
1544
1828
|
}
|
|
1545
1829
|
}
|
|
@@ -1549,10 +1833,28 @@ export class Client {
|
|
|
1549
1833
|
}
|
|
1550
1834
|
async readMail(header, mail, timestamp) {
|
|
1551
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
|
+
}
|
|
1552
1849
|
return;
|
|
1553
1850
|
}
|
|
1554
1851
|
this.seenMailIDs.add(mail.mailID);
|
|
1555
1852
|
if (this.manuallyClosing) {
|
|
1853
|
+
if (libvexDebugDmEnabled()) {
|
|
1854
|
+
debugLibvexDm("readMail: skip (manually closing)", {
|
|
1855
|
+
mailID: mail.mailID,
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1556
1858
|
return;
|
|
1557
1859
|
}
|
|
1558
1860
|
this.sendReceipt(new Uint8Array(mail.nonce));
|
|
@@ -1563,207 +1865,297 @@ export class Client {
|
|
|
1563
1865
|
}
|
|
1564
1866
|
this.reading = true;
|
|
1565
1867
|
try {
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
}
|
|
1570
|
-
const deviceEntry = await this.getDeviceByID(mail.sender);
|
|
1571
|
-
const [user, _err] = await this.fetchUser(mail.authorID);
|
|
1572
|
-
if (deviceEntry && user) {
|
|
1573
|
-
void this.createSession(deviceEntry, user, XUtils.decodeUTF8(`��RETRY_REQUEST:${mail.mailID}��`), mail.group, uuid.v4(), false);
|
|
1574
|
-
}
|
|
1575
|
-
};
|
|
1576
|
-
switch (mail.mailType) {
|
|
1577
|
-
case MailType.initial:
|
|
1578
|
-
const extraParts = Client.deserializeExtra(MailType.initial, new Uint8Array(mail.extra));
|
|
1579
|
-
const signKey = extraParts[0];
|
|
1580
|
-
const ephKey = extraParts[1];
|
|
1581
|
-
const indexBytes = extraParts[3];
|
|
1582
|
-
if (!signKey || !ephKey || !indexBytes) {
|
|
1583
|
-
throw new Error("Malformed initial mail extra: missing signKey, ephKey, or indexBytes");
|
|
1584
|
-
}
|
|
1585
|
-
const preKeyIndex = XUtils.uint8ArrToNumber(indexBytes);
|
|
1586
|
-
const otk = preKeyIndex === 0
|
|
1587
|
-
? null
|
|
1588
|
-
: await this.database.getOneTimeKey(preKeyIndex);
|
|
1589
|
-
if (otk?.index !== preKeyIndex && preKeyIndex !== 0) {
|
|
1590
|
-
return;
|
|
1591
|
-
}
|
|
1592
|
-
// their public keys
|
|
1593
|
-
const IK_A_raw = XKeyConvert.convertPublicKey(signKey);
|
|
1594
|
-
if (!IK_A_raw) {
|
|
1868
|
+
await this.runWithThisCryptoProfile(async () => {
|
|
1869
|
+
const healSession = async () => {
|
|
1870
|
+
if (this.manuallyClosing || !this.xKeyRing) {
|
|
1595
1871
|
return;
|
|
1596
1872
|
}
|
|
1597
|
-
const
|
|
1598
|
-
const
|
|
1599
|
-
if (
|
|
1600
|
-
|
|
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);
|
|
1601
1877
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
const DH4 = OPK_B ? xDH(OPK_B, EK_A) : null;
|
|
1612
|
-
// initial key material
|
|
1613
|
-
const IKM = DH4
|
|
1614
|
-
? xConcat(DH1, DH2, DH3, DH4)
|
|
1615
|
-
: xConcat(DH1, DH2, DH3);
|
|
1616
|
-
// shared secret key
|
|
1617
|
-
const SK = xKDF(IKM);
|
|
1618
|
-
const PK = xBoxKeyPairFromSecret(SK).publicKey;
|
|
1619
|
-
const hmac = xHMAC(mail, SK);
|
|
1620
|
-
// associated data
|
|
1621
|
-
const AD = xConcat(xEncode(xConstants.CURVE, IK_A), xEncode(xConstants.CURVE, IK_BP));
|
|
1622
|
-
if (!XUtils.bytesEqual(hmac, header)) {
|
|
1623
|
-
return;
|
|
1624
|
-
}
|
|
1625
|
-
const unsealed = xSecretboxOpen(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), SK);
|
|
1626
|
-
if (unsealed) {
|
|
1627
|
-
let plaintext = "";
|
|
1628
|
-
if (!mail.forward) {
|
|
1629
|
-
plaintext = XUtils.encodeUTF8(unsealed);
|
|
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");
|
|
1630
1887
|
}
|
|
1631
|
-
|
|
1632
|
-
const
|
|
1633
|
-
?
|
|
1634
|
-
:
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
};
|
|
1653
|
-
this.emitter.emit("message", message);
|
|
1654
|
-
// discard onetimekey
|
|
1655
|
-
await this.database.deleteOneTimeKey(preKeyIndex);
|
|
1656
|
-
const deviceEntry = await this.getDeviceByID(mail.sender);
|
|
1657
|
-
if (!deviceEntry) {
|
|
1658
|
-
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;
|
|
1659
1909
|
}
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
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;
|
|
1663
1937
|
}
|
|
1664
|
-
|
|
1665
|
-
this.
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
const
|
|
1680
|
-
|
|
1681
|
-
|
|
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
|
+
}
|
|
1682
2066
|
}
|
|
1683
2067
|
else {
|
|
2068
|
+
if (libvexDebugDmEnabled()) {
|
|
2069
|
+
debugLibvexDm("readMail initial: abort (xSecretboxOpen null)", {
|
|
2070
|
+
mailID: mail.mailID,
|
|
2071
|
+
preKeyIndex: String(preKeyIndex),
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
1684
2074
|
}
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
}
|
|
1694
|
-
let session = await this.getSessionByPubkey(publicKey);
|
|
1695
|
-
let retries = 0;
|
|
1696
|
-
while (!session) {
|
|
1697
|
-
if (retries >= 3) {
|
|
1698
|
-
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");
|
|
1699
2083
|
}
|
|
1700
|
-
|
|
1701
|
-
retries
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
void healSession();
|
|
1706
|
-
return;
|
|
1707
|
-
}
|
|
1708
|
-
const HMAC = xHMAC(mail, session.SK);
|
|
1709
|
-
if (!XUtils.bytesEqual(HMAC, header)) {
|
|
1710
|
-
void healSession();
|
|
1711
|
-
return;
|
|
1712
|
-
}
|
|
1713
|
-
const decrypted = xSecretboxOpen(new Uint8Array(mail.cipher), new Uint8Array(mail.nonce), session.SK);
|
|
1714
|
-
if (decrypted) {
|
|
1715
|
-
const fwdMsg2 = mail.forward
|
|
1716
|
-
? messageSchema.parse(msgpack.decode(decrypted))
|
|
1717
|
-
: null;
|
|
1718
|
-
const message = fwdMsg2
|
|
1719
|
-
? {
|
|
1720
|
-
...fwdMsg2,
|
|
1721
|
-
forward: true,
|
|
2084
|
+
let session = await this.getSessionByPubkey(publicKey);
|
|
2085
|
+
let retries = 0;
|
|
2086
|
+
while (!session) {
|
|
2087
|
+
if (retries >= 3) {
|
|
2088
|
+
break;
|
|
1722
2089
|
}
|
|
1723
|
-
|
|
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 = {
|
|
1724
2136
|
authorID: mail.authorID,
|
|
1725
|
-
decrypted:
|
|
2137
|
+
decrypted: false,
|
|
1726
2138
|
direction: "incoming",
|
|
1727
2139
|
forward: mail.forward,
|
|
1728
2140
|
group: mail.group
|
|
1729
2141
|
? uuid.stringify(mail.group)
|
|
1730
2142
|
: null,
|
|
1731
2143
|
mailID: mail.mailID,
|
|
1732
|
-
message:
|
|
2144
|
+
message: "",
|
|
1733
2145
|
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
1734
2146
|
readerID: mail.readerID,
|
|
1735
2147
|
recipient: mail.recipient,
|
|
1736
2148
|
sender: mail.sender,
|
|
1737
2149
|
timestamp: timestamp,
|
|
1738
2150
|
};
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
else {
|
|
1743
|
-
void healSession();
|
|
1744
|
-
// emit the message
|
|
1745
|
-
const message = {
|
|
1746
|
-
authorID: mail.authorID,
|
|
1747
|
-
decrypted: false,
|
|
1748
|
-
direction: "incoming",
|
|
1749
|
-
forward: mail.forward,
|
|
1750
|
-
group: mail.group
|
|
1751
|
-
? uuid.stringify(mail.group)
|
|
1752
|
-
: null,
|
|
1753
|
-
mailID: mail.mailID,
|
|
1754
|
-
message: "",
|
|
1755
|
-
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
1756
|
-
readerID: mail.readerID,
|
|
1757
|
-
recipient: mail.recipient,
|
|
1758
|
-
sender: mail.sender,
|
|
1759
|
-
timestamp: timestamp,
|
|
1760
|
-
};
|
|
1761
|
-
this.emitter.emit("message", message);
|
|
2151
|
+
this.emitter.emit("message", message);
|
|
2152
|
+
}
|
|
2153
|
+
break;
|
|
1762
2154
|
}
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
}
|
|
2155
|
+
default:
|
|
2156
|
+
break;
|
|
2157
|
+
}
|
|
2158
|
+
});
|
|
1767
2159
|
}
|
|
1768
2160
|
finally {
|
|
1769
2161
|
this.reading = false;
|
|
@@ -1792,8 +2184,11 @@ export class Client {
|
|
|
1792
2184
|
if (!token) {
|
|
1793
2185
|
throw new Error("Couldn't fetch token.");
|
|
1794
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`.
|
|
1795
2190
|
const signKey = this.getKeys().public;
|
|
1796
|
-
const signed = XUtils.encodeHex(
|
|
2191
|
+
const signed = XUtils.encodeHex(await xSignAsync(Uint8Array.from(uuid.parse(token.key)), this.signKeys.secretKey));
|
|
1797
2192
|
const devPreKeyIndex = this.xKeyRing.preKeys.index;
|
|
1798
2193
|
const devMsg = {
|
|
1799
2194
|
deviceName: this.options?.deviceName ?? "unknown",
|
|
@@ -1811,9 +2206,9 @@ export class Client {
|
|
|
1811
2206
|
"/devices", msgpack.encode(devMsg), { headers: { "Content-Type": "application/msgpack" } });
|
|
1812
2207
|
return decodeAxios(DeviceCodec, res.data);
|
|
1813
2208
|
}
|
|
1814
|
-
respond(msg) {
|
|
2209
|
+
async respond(msg) {
|
|
1815
2210
|
const response = {
|
|
1816
|
-
signed:
|
|
2211
|
+
signed: await xSignAsync(new Uint8Array(msg.challenge), this.signKeys.secretKey),
|
|
1817
2212
|
transmissionID: msg.transmissionID,
|
|
1818
2213
|
type: "response",
|
|
1819
2214
|
};
|
|
@@ -1849,7 +2244,7 @@ export class Client {
|
|
|
1849
2244
|
},
|
|
1850
2245
|
});
|
|
1851
2246
|
const fileData = res.data;
|
|
1852
|
-
const decrypted =
|
|
2247
|
+
const decrypted = await xSecretboxOpenAsync(new Uint8Array(fileData), XUtils.decodeHex(details.nonce), XUtils.decodeHex(key));
|
|
1853
2248
|
if (decrypted) {
|
|
1854
2249
|
return {
|
|
1855
2250
|
data: new Uint8Array(decrypted),
|
|
@@ -1916,7 +2311,6 @@ export class Client {
|
|
|
1916
2311
|
this.userRecords[user.userID] = user;
|
|
1917
2312
|
}
|
|
1918
2313
|
const mailID = uuid.v4();
|
|
1919
|
-
const promises = [];
|
|
1920
2314
|
const userIDs = [...new Set(userList.map((user) => user.userID))];
|
|
1921
2315
|
const devices = await this.getMultiUserDeviceList(userIDs);
|
|
1922
2316
|
for (const device of devices) {
|
|
@@ -1924,15 +2318,13 @@ export class Client {
|
|
|
1924
2318
|
if (!ownerRecord) {
|
|
1925
2319
|
continue;
|
|
1926
2320
|
}
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
void Promise.allSettled(promises).then((results) => {
|
|
1930
|
-
for (const result of results) {
|
|
1931
|
-
const { status } = result;
|
|
1932
|
-
if (status === "rejected") {
|
|
1933
|
-
}
|
|
2321
|
+
try {
|
|
2322
|
+
await this.sendMail(device, ownerRecord, XUtils.decodeUTF8(message), uuidToUint8(channelID), mailID, false);
|
|
1934
2323
|
}
|
|
1935
|
-
|
|
2324
|
+
catch {
|
|
2325
|
+
/* best-effort; each device needs its own X3DH handshake (sequential) */
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
1936
2328
|
}
|
|
1937
2329
|
/* Sends encrypted mail to a user. */
|
|
1938
2330
|
async sendMail(device, user, msg, group, mailID, forward, retry = false) {
|
|
@@ -1940,74 +2332,97 @@ export class Client {
|
|
|
1940
2332
|
await sleep(100);
|
|
1941
2333
|
}
|
|
1942
2334
|
this.sending.set(device.deviceID, device);
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
authorID: this.getUser().userID,
|
|
1953
|
-
cipher,
|
|
1954
|
-
extra,
|
|
1955
|
-
forward,
|
|
1956
|
-
group,
|
|
1957
|
-
mailID: mailID || uuid.v4(),
|
|
1958
|
-
mailType: MailType.subsequent,
|
|
1959
|
-
nonce,
|
|
1960
|
-
readerID: session.userID,
|
|
1961
|
-
recipient: device.deviceID,
|
|
1962
|
-
sender: this.getDevice().deviceID,
|
|
1963
|
-
};
|
|
1964
|
-
const msgb = {
|
|
1965
|
-
action: "CREATE",
|
|
1966
|
-
data: mail,
|
|
1967
|
-
resourceType: "mail",
|
|
1968
|
-
transmissionID: uuid.v4(),
|
|
1969
|
-
type: "resource",
|
|
1970
|
-
};
|
|
1971
|
-
const hmac = xHMAC(mail, session.SK);
|
|
1972
|
-
const fwdOut = forward
|
|
1973
|
-
? messageSchema.parse(msgpack.decode(msg))
|
|
1974
|
-
: null;
|
|
1975
|
-
const outMsg = fwdOut
|
|
1976
|
-
? { ...fwdOut, forward: true }
|
|
1977
|
-
: {
|
|
1978
|
-
authorID: mail.authorID,
|
|
1979
|
-
decrypted: true,
|
|
1980
|
-
direction: "outgoing",
|
|
1981
|
-
forward: mail.forward,
|
|
1982
|
-
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
1983
|
-
mailID: mail.mailID,
|
|
1984
|
-
message: XUtils.encodeUTF8(msg),
|
|
1985
|
-
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
1986
|
-
readerID: mail.readerID,
|
|
1987
|
-
recipient: mail.recipient,
|
|
1988
|
-
sender: mail.sender,
|
|
1989
|
-
timestamp: new Date().toISOString(),
|
|
1990
|
-
};
|
|
1991
|
-
this.emitter.emit("message", outMsg);
|
|
1992
|
-
await new Promise((res, rej) => {
|
|
1993
|
-
const callback = (packedMsg) => {
|
|
1994
|
-
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
1995
|
-
if (receivedMsg.transmissionID === msgb.transmissionID) {
|
|
1996
|
-
this.socket.off("message", callback);
|
|
1997
|
-
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
1998
|
-
if (parsed.success && parsed.data.type === "success") {
|
|
1999
|
-
res(parsed.data.data);
|
|
2000
|
-
}
|
|
2001
|
-
else {
|
|
2002
|
-
rej(new Error("Mail delivery failed: " +
|
|
2003
|
-
JSON.stringify(receivedMsg)));
|
|
2004
|
-
}
|
|
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
|
+
});
|
|
2005
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,
|
|
2006
2375
|
};
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
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
|
+
}
|
|
2011
2426
|
}
|
|
2012
2427
|
async sendMessage(userID, message) {
|
|
2013
2428
|
try {
|
|
@@ -2018,19 +2433,85 @@ export class Client {
|
|
|
2018
2433
|
if (!userEntry) {
|
|
2019
2434
|
throw new Error("Couldn't get user entry.");
|
|
2020
2435
|
}
|
|
2021
|
-
const
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
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.");
|
|
2026
2457
|
}
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
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
|
+
}
|
|
2473
|
+
}
|
|
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
|
+
});
|
|
2031
2490
|
}
|
|
2032
2491
|
}
|
|
2033
|
-
|
|
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
|
+
}
|
|
2034
2515
|
}
|
|
2035
2516
|
catch (err) {
|
|
2036
2517
|
throw err;
|
|
@@ -2053,7 +2534,7 @@ export class Client {
|
|
|
2053
2534
|
async submitOTK(amount) {
|
|
2054
2535
|
const otks = [];
|
|
2055
2536
|
for (let i = 0; i < amount; i++) {
|
|
2056
|
-
otks
|
|
2537
|
+
otks.push(await this.createPreKey());
|
|
2057
2538
|
}
|
|
2058
2539
|
const savedKeys = await this.database.savePreKeys(otks, true);
|
|
2059
2540
|
await this.http.post(this.getHost() + "/device/" + this.getDevice().deviceID + "/otk", msgpack.encode(savedKeys.map((key) => this.censorPreKey(key))), {
|