@vex-chat/libvex 5.4.0 → 5.5.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/README.md +25 -23
- package/dist/Client.d.ts +113 -77
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +311 -234
- package/dist/Client.js.map +1 -1
- package/dist/__tests__/harness/memory-storage.d.ts +1 -1
- package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.js +1 -1
- package/dist/__tests__/harness/memory-storage.js.map +1 -1
- package/dist/codecs.d.ts +118 -0
- package/dist/codecs.d.ts.map +1 -1
- package/dist/codecs.js +41 -0
- package/dist/codecs.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/storage/node/http-agents.d.ts +1 -1
- package/dist/storage/node/http-agents.d.ts.map +1 -1
- package/dist/storage/node/http-agents.js +4 -4
- package/dist/storage/node/http-agents.js.map +1 -1
- package/dist/storage/sqlite.d.ts +8 -8
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +16 -16
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/utils/fipsMailExtra.d.ts +9 -9
- package/dist/utils/fipsMailExtra.d.ts.map +1 -1
- package/dist/utils/fipsMailExtra.js +47 -47
- package/dist/utils/fipsMailExtra.js.map +1 -1
- package/dist/utils/resolveAtRestAesKey.js +1 -1
- package/dist/utils/resolveAtRestAesKey.js.map +1 -1
- package/package.json +134 -152
- package/src/Client.ts +452 -306
- package/src/__tests__/harness/memory-storage.ts +1 -1
- package/src/__tests__/harness/shared-suite.ts +177 -177
- package/src/codecs.ts +52 -0
- package/src/index.ts +4 -0
- package/src/storage/node/http-agents.ts +7 -7
- package/src/storage/sqlite.ts +23 -23
- package/src/utils/fipsMailExtra.ts +80 -80
- package/src/utils/resolveAtRestAesKey.ts +1 -1
package/src/Client.ts
CHANGED
|
@@ -89,14 +89,56 @@ import {
|
|
|
89
89
|
isFipsSubsequentExtraV1,
|
|
90
90
|
} from "./utils/fipsMailExtra.js";
|
|
91
91
|
|
|
92
|
-
function
|
|
93
|
-
|
|
92
|
+
function debugLibvexDm(
|
|
93
|
+
msg: string,
|
|
94
|
+
data?: Record<string, boolean | null | number | string | undefined>,
|
|
95
|
+
): void {
|
|
96
|
+
if (!libvexDebugDmEnabled()) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
|
|
100
|
+
// eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
|
|
101
|
+
console.error(`[libvex:debug-dm] ${payload}`);
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
function isRecord(x: unknown): x is Record<string, unknown> {
|
|
97
105
|
return typeof x === "object" && x !== null;
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Set `LIBVEX_DEBUG_DM=1` (e.g. in vitest / shell) to log DM multi-device / X3DH paths.
|
|
110
|
+
* Uses indirect `globalThis` lookup so the bare `process` global never appears in
|
|
111
|
+
* source that the platform-guard plugin scans (browser/RN/Tauri).
|
|
112
|
+
*/
|
|
113
|
+
function libvexDebugDmEnabled(): boolean {
|
|
114
|
+
try {
|
|
115
|
+
const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
|
|
116
|
+
if (!g) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
const proc: unknown = typeof g.get === "function" ? g.get() : g.value;
|
|
120
|
+
if (typeof proc !== "object" || proc === null) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
|
|
124
|
+
if (!envDesc) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
const env: unknown =
|
|
128
|
+
typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
|
|
129
|
+
if (typeof env !== "object" || env === null) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
return Reflect.get(env, "LIBVEX_DEBUG_DM") === "1";
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sleep(ms: number): Promise<void> {
|
|
139
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
140
|
+
}
|
|
141
|
+
|
|
100
142
|
/**
|
|
101
143
|
* Spire 5+ JSON error bodies use `{ "error": { "message", "requestId"?, "details"? } }`.
|
|
102
144
|
* Responses are `arraybuffer` — decode UTF-8 and parse for a one-line `Error` message
|
|
@@ -149,48 +191,6 @@ function spireErrorBodyMessage(data: unknown, max = 8_000): string {
|
|
|
149
191
|
return t.length > max ? t.slice(0, max) + "…" : t;
|
|
150
192
|
}
|
|
151
193
|
|
|
152
|
-
/**
|
|
153
|
-
* Set `LIBVEX_DEBUG_DM=1` (e.g. in vitest / shell) to log DM multi-device / X3DH paths.
|
|
154
|
-
* Uses indirect `globalThis` lookup so the bare `process` global never appears in
|
|
155
|
-
* source that the platform-guard plugin scans (browser/RN/Tauri).
|
|
156
|
-
*/
|
|
157
|
-
function libvexDebugDmEnabled(): boolean {
|
|
158
|
-
try {
|
|
159
|
-
const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
|
|
160
|
-
if (!g) {
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
163
|
-
const proc: unknown = typeof g.get === "function" ? g.get() : g.value;
|
|
164
|
-
if (typeof proc !== "object" || proc === null) {
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
|
|
168
|
-
if (!envDesc) {
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
const env: unknown =
|
|
172
|
-
typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
|
|
173
|
-
if (typeof env !== "object" || env === null) {
|
|
174
|
-
return false;
|
|
175
|
-
}
|
|
176
|
-
return Reflect.get(env, "LIBVEX_DEBUG_DM") === "1";
|
|
177
|
-
} catch {
|
|
178
|
-
return false;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function debugLibvexDm(
|
|
183
|
-
msg: string,
|
|
184
|
-
data?: Record<string, string | number | boolean | null | undefined>,
|
|
185
|
-
): void {
|
|
186
|
-
if (!libvexDebugDmEnabled()) {
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
|
|
190
|
-
// eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
|
|
191
|
-
console.error(`[libvex:debug-dm] ${payload}`);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
194
|
import { msgpack } from "./codec.js";
|
|
195
195
|
import {
|
|
196
196
|
ActionTokenCodec,
|
|
@@ -202,6 +202,7 @@ import {
|
|
|
202
202
|
DeviceArrayCodec,
|
|
203
203
|
DeviceChallengeCodec,
|
|
204
204
|
DeviceCodec,
|
|
205
|
+
DeviceRegistrationResultCodec,
|
|
205
206
|
EmojiArrayCodec,
|
|
206
207
|
EmojiCodec,
|
|
207
208
|
FileSQLCodec,
|
|
@@ -209,6 +210,8 @@ import {
|
|
|
209
210
|
InviteCodec,
|
|
210
211
|
KeyBundleCodec,
|
|
211
212
|
OtkCountCodec,
|
|
213
|
+
PendingDeviceRequestArrayCodec,
|
|
214
|
+
PendingDeviceRequestCodec,
|
|
212
215
|
PermissionArrayCodec,
|
|
213
216
|
PermissionCodec,
|
|
214
217
|
ServerArrayCodec,
|
|
@@ -276,6 +279,12 @@ export interface ClientOptions {
|
|
|
276
279
|
cryptoProfile?: "fips" | "tweetnacl";
|
|
277
280
|
/** Folder path where the sqlite file is created. */
|
|
278
281
|
dbFolder?: string;
|
|
282
|
+
/**
|
|
283
|
+
* When set (non-empty), sent as `x-dev-api-key` on every HTTP request.
|
|
284
|
+
* Spire omits in-process rate limits when this matches the server's `DEV_API_KEY`
|
|
285
|
+
* (local / load-testing only — never use in production).
|
|
286
|
+
*/
|
|
287
|
+
devApiKey?: string;
|
|
279
288
|
/** Platform label for device registration (e.g. "ios", "macos", "linux"). */
|
|
280
289
|
deviceName?: string;
|
|
281
290
|
/** API host without protocol. Defaults to `api.vex.wtf`. */
|
|
@@ -286,46 +295,30 @@ export interface ClientOptions {
|
|
|
286
295
|
saveHistory?: boolean;
|
|
287
296
|
/** Use `http/ws` instead of `https/wss`. Intended for local/dev environments. */
|
|
288
297
|
unsafeHttp?: boolean;
|
|
289
|
-
/**
|
|
290
|
-
* When set (non-empty), sent as `x-dev-api-key` on every HTTP request.
|
|
291
|
-
* Spire omits in-process rate limits when this matches the server's `DEV_API_KEY`
|
|
292
|
-
* (local / load-testing only — never use in production).
|
|
293
|
-
*/
|
|
294
|
-
devApiKey?: string;
|
|
295
298
|
}
|
|
296
299
|
|
|
300
|
+
export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
|
|
301
|
+
|
|
297
302
|
/**
|
|
298
303
|
* @ignore
|
|
299
304
|
*/
|
|
300
305
|
export interface Devices {
|
|
306
|
+
/** Approves a pending device registration request as the current device. */
|
|
307
|
+
approveRequest: (requestID: string) => Promise<Device>;
|
|
301
308
|
/** Deletes one of the account's devices (except the currently active one). */
|
|
302
309
|
delete: (deviceID: string) => Promise<void>;
|
|
310
|
+
/** Fetches one pending registration request by ID for the current user. */
|
|
311
|
+
getRequest: (requestID: string) => Promise<null | PendingDeviceRequest>;
|
|
312
|
+
/** Lists pending/processed registration requests for the current user. */
|
|
313
|
+
listRequests: () => Promise<PendingDeviceRequest[]>;
|
|
303
314
|
/** Registers the current key material as a new device. */
|
|
304
|
-
register: () => Promise<
|
|
315
|
+
register: () => Promise<DeviceRegistrationResult | null>;
|
|
316
|
+
/** Rejects a pending device registration request as the current device. */
|
|
317
|
+
rejectRequest: (requestID: string) => Promise<void>;
|
|
305
318
|
/** Fetches one device by ID. */
|
|
306
319
|
retrieve: (deviceIdentifier: string) => Promise<Device | null>;
|
|
307
320
|
}
|
|
308
321
|
|
|
309
|
-
/**
|
|
310
|
-
* Channel is a chat channel on a server.
|
|
311
|
-
*
|
|
312
|
-
* Common fields:
|
|
313
|
-
* - `channelID`
|
|
314
|
-
* - `serverID`
|
|
315
|
-
* - `name`
|
|
316
|
-
*/
|
|
317
|
-
export type { Channel } from "@vex-chat/types";
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Server is a single chat server.
|
|
321
|
-
*
|
|
322
|
-
* Common fields:
|
|
323
|
-
* - `serverID`
|
|
324
|
-
* - `name`
|
|
325
|
-
* - `icon` (optional URL/data)
|
|
326
|
-
*/
|
|
327
|
-
export type { Server } from "@vex-chat/types";
|
|
328
|
-
|
|
329
322
|
/**
|
|
330
323
|
* @ignore
|
|
331
324
|
*/
|
|
@@ -379,6 +372,26 @@ export interface FileProgress {
|
|
|
379
372
|
*/
|
|
380
373
|
export type FileRes = FileResponse;
|
|
381
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Channel is a chat channel on a server.
|
|
377
|
+
*
|
|
378
|
+
* Common fields:
|
|
379
|
+
* - `channelID`
|
|
380
|
+
* - `serverID`
|
|
381
|
+
* - `name`
|
|
382
|
+
*/
|
|
383
|
+
export type { Channel } from "@vex-chat/types";
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Server is a single chat server.
|
|
387
|
+
*
|
|
388
|
+
* Common fields:
|
|
389
|
+
* - `serverID`
|
|
390
|
+
* - `name`
|
|
391
|
+
* - `icon` (optional URL/data)
|
|
392
|
+
*/
|
|
393
|
+
export type { Server } from "@vex-chat/types";
|
|
394
|
+
|
|
382
395
|
/**
|
|
383
396
|
* @ignore
|
|
384
397
|
*/
|
|
@@ -454,6 +467,31 @@ export interface Message {
|
|
|
454
467
|
timestamp: string;
|
|
455
468
|
}
|
|
456
469
|
|
|
470
|
+
export type PendingDeviceApprovalStatus =
|
|
471
|
+
| "approved"
|
|
472
|
+
| "expired"
|
|
473
|
+
| "pending"
|
|
474
|
+
| "rejected";
|
|
475
|
+
|
|
476
|
+
export interface PendingDeviceRegistration {
|
|
477
|
+
challenge: string;
|
|
478
|
+
expiresAt: string;
|
|
479
|
+
requestID: string;
|
|
480
|
+
status: "pending_approval";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export interface PendingDeviceRequest {
|
|
484
|
+
approvedDeviceID?: string | undefined;
|
|
485
|
+
createdAt: string;
|
|
486
|
+
deviceName: string;
|
|
487
|
+
error?: string | undefined;
|
|
488
|
+
expiresAt: string;
|
|
489
|
+
requestID: string;
|
|
490
|
+
signKey: string;
|
|
491
|
+
status: PendingDeviceApprovalStatus;
|
|
492
|
+
username: string;
|
|
493
|
+
}
|
|
494
|
+
|
|
457
495
|
/** Zod schema matching the {@link Message} interface for forwarded-message decode. */
|
|
458
496
|
const messageSchema: z.ZodType<Message> = z.object({
|
|
459
497
|
authorID: z.string(),
|
|
@@ -476,6 +514,14 @@ const mailInboxEntry = z.tuple([
|
|
|
476
514
|
MailWSSchema,
|
|
477
515
|
z.string(),
|
|
478
516
|
]);
|
|
517
|
+
const deviceRequestNotifyData = z.object({
|
|
518
|
+
requestID: z.string(),
|
|
519
|
+
status: z.union([
|
|
520
|
+
z.literal("approved"),
|
|
521
|
+
z.literal("pending"),
|
|
522
|
+
z.literal("rejected"),
|
|
523
|
+
]),
|
|
524
|
+
});
|
|
479
525
|
|
|
480
526
|
/**
|
|
481
527
|
* Event signatures emitted by {@link Client}.
|
|
@@ -490,6 +536,14 @@ export interface ClientEvents {
|
|
|
490
536
|
connected: () => void;
|
|
491
537
|
/** Mail decryption pass is in progress. */
|
|
492
538
|
decryptingMail: () => void;
|
|
539
|
+
/** Device approval queue changed (pending/approved/rejected). */
|
|
540
|
+
deviceRequest: (update: {
|
|
541
|
+
requestID: string;
|
|
542
|
+
status: Extract<
|
|
543
|
+
PendingDeviceApprovalStatus,
|
|
544
|
+
"approved" | "pending" | "rejected"
|
|
545
|
+
>;
|
|
546
|
+
}) => void;
|
|
493
547
|
/** WebSocket connection lost. */
|
|
494
548
|
disconnect: () => void;
|
|
495
549
|
/** Progress update for a file upload or download. */
|
|
@@ -748,8 +802,12 @@ export class Client {
|
|
|
748
802
|
* Device management methods.
|
|
749
803
|
*/
|
|
750
804
|
public devices: Devices = {
|
|
805
|
+
approveRequest: this.approveDeviceRequest.bind(this),
|
|
751
806
|
delete: this.deleteDevice.bind(this),
|
|
807
|
+
getRequest: this.getDeviceRegistrationRequest.bind(this),
|
|
808
|
+
listRequests: this.listDeviceRegistrationRequests.bind(this),
|
|
752
809
|
register: this.registerDevice.bind(this),
|
|
810
|
+
rejectRequest: this.rejectDeviceRequest.bind(this),
|
|
753
811
|
retrieve: this.getDeviceByID.bind(this),
|
|
754
812
|
};
|
|
755
813
|
|
|
@@ -967,6 +1025,8 @@ export class Client {
|
|
|
967
1025
|
retrieve: this.fetchUser.bind(this),
|
|
968
1026
|
};
|
|
969
1027
|
|
|
1028
|
+
private readonly cryptoProfile: CryptoProfile;
|
|
1029
|
+
|
|
970
1030
|
private readonly database: Storage;
|
|
971
1031
|
|
|
972
1032
|
private readonly dbPath: string;
|
|
@@ -977,34 +1037,28 @@ export class Client {
|
|
|
977
1037
|
|
|
978
1038
|
// ── Event subscription (composition over inheritance) ───────────────
|
|
979
1039
|
private readonly emitter = new EventEmitter<ClientEvents>();
|
|
980
|
-
|
|
981
1040
|
private fetchingMail: boolean = false;
|
|
1041
|
+
|
|
982
1042
|
private firstMailFetch = true;
|
|
983
1043
|
|
|
984
1044
|
private readonly forwarded = new Set<string>();
|
|
985
|
-
|
|
986
1045
|
private readonly host: string;
|
|
987
|
-
|
|
988
|
-
* Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
|
|
989
|
-
* Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
|
|
990
|
-
*/
|
|
991
|
-
private nodeHttpAgents?: {
|
|
992
|
-
http: { destroy(): void };
|
|
993
|
-
https: { destroy(): void };
|
|
994
|
-
};
|
|
1046
|
+
private readonly http: AxiosInstance;
|
|
995
1047
|
/** Cancels in-flight axios work on `close()` so `postAuth`/`getMail` cannot hang forever. */
|
|
996
1048
|
private readonly httpAbortController = new AbortController();
|
|
997
|
-
private readonly http: AxiosInstance;
|
|
998
1049
|
private readonly idKeys: KeyPair | null;
|
|
999
1050
|
private isAlive: boolean = true;
|
|
1000
1051
|
private readonly mailInterval?: NodeJS.Timeout;
|
|
1001
1052
|
|
|
1002
1053
|
private manuallyClosing: boolean = false;
|
|
1003
1054
|
/**
|
|
1004
|
-
*
|
|
1005
|
-
* `
|
|
1055
|
+
* Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
|
|
1056
|
+
* Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
|
|
1006
1057
|
*/
|
|
1007
|
-
private
|
|
1058
|
+
private nodeHttpAgents?: {
|
|
1059
|
+
http: { destroy(): void };
|
|
1060
|
+
https: { destroy(): void };
|
|
1061
|
+
};
|
|
1008
1062
|
/* Retrieves the userID with the user identifier.
|
|
1009
1063
|
user identifier is checked for userID, then signkey,
|
|
1010
1064
|
and finally falls back to username. */
|
|
@@ -1014,24 +1068,28 @@ export class Client {
|
|
|
1014
1068
|
private readonly options?: ClientOptions | undefined;
|
|
1015
1069
|
|
|
1016
1070
|
private pingInterval: null | ReturnType<typeof setTimeout> = null;
|
|
1071
|
+
/**
|
|
1072
|
+
* Bumped when the WebSocket is torn down and re-opened so the previous
|
|
1073
|
+
* `postAuth` loop exits instead of overlapping a new one.
|
|
1074
|
+
*/
|
|
1075
|
+
private postAuthVersion = 0;
|
|
1076
|
+
|
|
1017
1077
|
private readonly prefixes:
|
|
1018
1078
|
| { HTTP: "http://"; WS: "ws://" }
|
|
1019
1079
|
| { HTTP: "https://"; WS: "wss://" };
|
|
1020
|
-
|
|
1021
1080
|
private reading: boolean = false;
|
|
1022
1081
|
private readonly seenMailIDs: Set<string> = new Set();
|
|
1023
1082
|
private sessionRecords: Record<string, SessionCrypto> = {};
|
|
1083
|
+
|
|
1024
1084
|
// these are created from one set of sign keys
|
|
1025
1085
|
private readonly signKeys: KeyPair;
|
|
1026
|
-
|
|
1027
1086
|
private socket: WebSocketLike;
|
|
1028
1087
|
private token: null | string = null;
|
|
1088
|
+
|
|
1029
1089
|
private user?: User;
|
|
1030
1090
|
|
|
1031
1091
|
private userRecords: Record<string, User> = {};
|
|
1032
|
-
|
|
1033
1092
|
private xKeyRing?: XKeyRing;
|
|
1034
|
-
private readonly cryptoProfile: CryptoProfile;
|
|
1035
1093
|
|
|
1036
1094
|
private constructor(
|
|
1037
1095
|
material: {
|
|
@@ -1246,32 +1304,6 @@ export class Client {
|
|
|
1246
1304
|
return xMnemonic(xKDF(XUtils.decodeHex(session.fingerprint)));
|
|
1247
1305
|
}
|
|
1248
1306
|
|
|
1249
|
-
/**
|
|
1250
|
-
* True when running under Node (has `process.versions`).
|
|
1251
|
-
* Uses indirect lookup so the bare `process` global never appears in
|
|
1252
|
-
* source that the platform-guard plugin scans.
|
|
1253
|
-
*/
|
|
1254
|
-
private static isNodeRuntime(): boolean {
|
|
1255
|
-
try {
|
|
1256
|
-
const g = Object.getOwnPropertyDescriptor(
|
|
1257
|
-
globalThis,
|
|
1258
|
-
"\u0070rocess",
|
|
1259
|
-
);
|
|
1260
|
-
if (!g) return false;
|
|
1261
|
-
const proc: unknown =
|
|
1262
|
-
typeof g.get === "function" ? g.get() : g.value;
|
|
1263
|
-
if (typeof proc !== "object" || proc === null) {
|
|
1264
|
-
return false;
|
|
1265
|
-
}
|
|
1266
|
-
return (
|
|
1267
|
-
"versions" in proc &&
|
|
1268
|
-
typeof (proc as { versions?: unknown }).versions === "object"
|
|
1269
|
-
);
|
|
1270
|
-
} catch {
|
|
1271
|
-
return false;
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
1307
|
/**
|
|
1276
1308
|
* Browser-safe NODE_ENV accessor.
|
|
1277
1309
|
* Uses indirect lookup so the bare `process` global never appears in
|
|
@@ -1312,12 +1344,29 @@ export class Client {
|
|
|
1312
1344
|
}
|
|
1313
1345
|
|
|
1314
1346
|
/**
|
|
1315
|
-
*
|
|
1316
|
-
*
|
|
1317
|
-
*
|
|
1347
|
+
* True when running under Node (has `process.versions`).
|
|
1348
|
+
* Uses indirect lookup so the bare `process` global never appears in
|
|
1349
|
+
* source that the platform-guard plugin scans.
|
|
1318
1350
|
*/
|
|
1319
|
-
private
|
|
1320
|
-
|
|
1351
|
+
private static isNodeRuntime(): boolean {
|
|
1352
|
+
try {
|
|
1353
|
+
const g = Object.getOwnPropertyDescriptor(
|
|
1354
|
+
globalThis,
|
|
1355
|
+
"\u0070rocess",
|
|
1356
|
+
);
|
|
1357
|
+
if (!g) return false;
|
|
1358
|
+
const proc: unknown =
|
|
1359
|
+
typeof g.get === "function" ? g.get() : g.value;
|
|
1360
|
+
if (typeof proc !== "object" || proc === null) {
|
|
1361
|
+
return false;
|
|
1362
|
+
}
|
|
1363
|
+
return (
|
|
1364
|
+
"versions" in proc &&
|
|
1365
|
+
typeof (proc as { versions?: unknown }).versions === "object"
|
|
1366
|
+
);
|
|
1367
|
+
} catch {
|
|
1368
|
+
return false;
|
|
1369
|
+
}
|
|
1321
1370
|
}
|
|
1322
1371
|
|
|
1323
1372
|
/**
|
|
@@ -1392,62 +1441,6 @@ export class Client {
|
|
|
1392
1441
|
await this.negotiateOTK();
|
|
1393
1442
|
}
|
|
1394
1443
|
|
|
1395
|
-
/**
|
|
1396
|
-
* Tears down the current WebSocket and opens a new one, keeping the same
|
|
1397
|
-
* session (user + device in storage). Restarts the post-auth mail loop.
|
|
1398
|
-
* Use for long-running processes or e2e where a fresh socket matches a
|
|
1399
|
-
* newly-registered second device.
|
|
1400
|
-
*/
|
|
1401
|
-
public async reconnectWebsocket(): Promise<void> {
|
|
1402
|
-
this.postAuthVersion++;
|
|
1403
|
-
if (this.pingInterval) {
|
|
1404
|
-
clearInterval(this.pingInterval);
|
|
1405
|
-
this.pingInterval = null;
|
|
1406
|
-
}
|
|
1407
|
-
this.socket.close();
|
|
1408
|
-
try {
|
|
1409
|
-
await new Promise<void>((resolve, reject) => {
|
|
1410
|
-
const t = setTimeout(() => {
|
|
1411
|
-
this.off("connected", onC);
|
|
1412
|
-
reject(
|
|
1413
|
-
new Error(
|
|
1414
|
-
"reconnectWebsocket: timed out waiting for authorized",
|
|
1415
|
-
),
|
|
1416
|
-
);
|
|
1417
|
-
}, 15_000);
|
|
1418
|
-
const onC = () => {
|
|
1419
|
-
clearTimeout(t);
|
|
1420
|
-
this.off("connected", onC);
|
|
1421
|
-
resolve();
|
|
1422
|
-
};
|
|
1423
|
-
this.on("connected", onC);
|
|
1424
|
-
try {
|
|
1425
|
-
this.initSocket();
|
|
1426
|
-
} catch (err: unknown) {
|
|
1427
|
-
clearTimeout(t);
|
|
1428
|
-
this.off("connected", onC);
|
|
1429
|
-
const e =
|
|
1430
|
-
err instanceof Error
|
|
1431
|
-
? err
|
|
1432
|
-
: new Error(String(err), { cause: err });
|
|
1433
|
-
reject(e);
|
|
1434
|
-
}
|
|
1435
|
-
});
|
|
1436
|
-
} catch (e: unknown) {
|
|
1437
|
-
throw e instanceof Error ? e : new Error(String(e), { cause: e });
|
|
1438
|
-
}
|
|
1439
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
1440
|
-
await this.negotiateOTK();
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
/**
|
|
1444
|
-
* Triggers an immediate inbox sync by fetching `/mail` once.
|
|
1445
|
-
* Useful on mobile foreground resume where background work may pause.
|
|
1446
|
-
*/
|
|
1447
|
-
public async syncInboxNow(): Promise<void> {
|
|
1448
|
-
await this.getMail();
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
1444
|
/**
|
|
1452
1445
|
* Delete all local data — message history, encryption sessions, and prekeys.
|
|
1453
1446
|
* Closes the client afterward. Credentials (keychain) must be cleared by the consumer.
|
|
@@ -1626,6 +1619,54 @@ export class Client {
|
|
|
1626
1619
|
return this;
|
|
1627
1620
|
}
|
|
1628
1621
|
|
|
1622
|
+
/**
|
|
1623
|
+
* Tears down the current WebSocket and opens a new one, keeping the same
|
|
1624
|
+
* session (user + device in storage). Restarts the post-auth mail loop.
|
|
1625
|
+
* Use for long-running processes or e2e where a fresh socket matches a
|
|
1626
|
+
* newly-registered second device.
|
|
1627
|
+
*/
|
|
1628
|
+
public async reconnectWebsocket(): Promise<void> {
|
|
1629
|
+
this.postAuthVersion++;
|
|
1630
|
+
if (this.pingInterval) {
|
|
1631
|
+
clearInterval(this.pingInterval);
|
|
1632
|
+
this.pingInterval = null;
|
|
1633
|
+
}
|
|
1634
|
+
this.socket.close();
|
|
1635
|
+
try {
|
|
1636
|
+
await new Promise<void>((resolve, reject) => {
|
|
1637
|
+
const t = setTimeout(() => {
|
|
1638
|
+
this.off("connected", onC);
|
|
1639
|
+
reject(
|
|
1640
|
+
new Error(
|
|
1641
|
+
"reconnectWebsocket: timed out waiting for authorized",
|
|
1642
|
+
),
|
|
1643
|
+
);
|
|
1644
|
+
}, 15_000);
|
|
1645
|
+
const onC = () => {
|
|
1646
|
+
clearTimeout(t);
|
|
1647
|
+
this.off("connected", onC);
|
|
1648
|
+
resolve();
|
|
1649
|
+
};
|
|
1650
|
+
this.on("connected", onC);
|
|
1651
|
+
try {
|
|
1652
|
+
this.initSocket();
|
|
1653
|
+
} catch (err: unknown) {
|
|
1654
|
+
clearTimeout(t);
|
|
1655
|
+
this.off("connected", onC);
|
|
1656
|
+
const e =
|
|
1657
|
+
err instanceof Error
|
|
1658
|
+
? err
|
|
1659
|
+
: new Error(String(err), { cause: err });
|
|
1660
|
+
reject(e);
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
} catch (e: unknown) {
|
|
1664
|
+
throw e instanceof Error ? e : new Error(String(e), { cause: e });
|
|
1665
|
+
}
|
|
1666
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
1667
|
+
await this.negotiateOTK();
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1629
1670
|
/**
|
|
1630
1671
|
* Registers a new account on the server.
|
|
1631
1672
|
*
|
|
@@ -1699,6 +1740,14 @@ export class Client {
|
|
|
1699
1740
|
return this;
|
|
1700
1741
|
}
|
|
1701
1742
|
|
|
1743
|
+
/**
|
|
1744
|
+
* Triggers an immediate inbox sync by fetching `/mail` once.
|
|
1745
|
+
* Useful on mobile foreground resume where background work may pause.
|
|
1746
|
+
*/
|
|
1747
|
+
public async syncInboxNow(): Promise<void> {
|
|
1748
|
+
await this.getMail();
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1702
1751
|
/**
|
|
1703
1752
|
* Returns a compact `<username><deviceID>` debug label.
|
|
1704
1753
|
*/
|
|
@@ -1732,6 +1781,36 @@ export class Client {
|
|
|
1732
1781
|
return whoami;
|
|
1733
1782
|
}
|
|
1734
1783
|
|
|
1784
|
+
private async approveDeviceRequest(requestID: string): Promise<Device> {
|
|
1785
|
+
const req = await this.getDeviceRegistrationRequest(requestID);
|
|
1786
|
+
if (!req) {
|
|
1787
|
+
throw new Error("Device approval request not found.");
|
|
1788
|
+
}
|
|
1789
|
+
if (req.status !== "pending") {
|
|
1790
|
+
throw new Error(
|
|
1791
|
+
"Device approval request is not pending: " + req.status,
|
|
1792
|
+
);
|
|
1793
|
+
}
|
|
1794
|
+
const signed = XUtils.encodeHex(
|
|
1795
|
+
await xSignAsync(
|
|
1796
|
+
XUtils.decodeUTF8(requestID),
|
|
1797
|
+
this.signKeys.secretKey,
|
|
1798
|
+
),
|
|
1799
|
+
);
|
|
1800
|
+
const response = await this.http.post(
|
|
1801
|
+
this.prefixes.HTTP +
|
|
1802
|
+
this.host +
|
|
1803
|
+
"/user/" +
|
|
1804
|
+
this.getUser().userID +
|
|
1805
|
+
"/devices/requests/" +
|
|
1806
|
+
requestID +
|
|
1807
|
+
"/approve",
|
|
1808
|
+
msgpack.encode({ signed }),
|
|
1809
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
1810
|
+
);
|
|
1811
|
+
return decodeAxios(DeviceCodec, response.data);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1735
1814
|
private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
|
|
1736
1815
|
if (!preKey.index) {
|
|
1737
1816
|
throw new Error("Key index is required.");
|
|
@@ -1852,31 +1931,10 @@ export class Client {
|
|
|
1852
1931
|
}
|
|
1853
1932
|
|
|
1854
1933
|
private async createServer(name: string): Promise<Server> {
|
|
1855
|
-
const res = await this.http.post(
|
|
1856
|
-
this.getHost() + "/server/" + globalThis.btoa(name),
|
|
1857
|
-
);
|
|
1858
|
-
return decodeAxios(ServerCodec, res.data);
|
|
1859
|
-
}
|
|
1860
|
-
|
|
1861
|
-
/**
|
|
1862
|
-
* `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
|
|
1863
|
-
* active profile. When several {@link Client} instances use different
|
|
1864
|
-
* `cryptoProfile` values, scope the global to this instance for the duration
|
|
1865
|
-
* of that crypto work.
|
|
1866
|
-
*/
|
|
1867
|
-
private async runWithThisCryptoProfile<T>(
|
|
1868
|
-
fn: () => Promise<T>,
|
|
1869
|
-
): Promise<T> {
|
|
1870
|
-
const prev = getCryptoProfile();
|
|
1871
|
-
if (prev === this.cryptoProfile) {
|
|
1872
|
-
return await fn();
|
|
1873
|
-
}
|
|
1874
|
-
setCryptoProfile(this.cryptoProfile);
|
|
1875
|
-
try {
|
|
1876
|
-
return await fn();
|
|
1877
|
-
} finally {
|
|
1878
|
-
setCryptoProfile(prev);
|
|
1879
|
-
}
|
|
1934
|
+
const res = await this.http.post(
|
|
1935
|
+
this.getHost() + "/server/" + globalThis.btoa(name),
|
|
1936
|
+
);
|
|
1937
|
+
return decodeAxios(ServerCodec, res.data);
|
|
1880
1938
|
}
|
|
1881
1939
|
|
|
1882
1940
|
private async createSession(
|
|
@@ -2111,6 +2169,21 @@ export class Client {
|
|
|
2111
2169
|
private async deleteServer(serverID: string): Promise<void> {
|
|
2112
2170
|
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
2113
2171
|
}
|
|
2172
|
+
|
|
2173
|
+
private deviceListFailureDetail(err: unknown): string {
|
|
2174
|
+
if (!isAxiosError(err)) {
|
|
2175
|
+
return "";
|
|
2176
|
+
}
|
|
2177
|
+
const st = err.response?.status;
|
|
2178
|
+
if (typeof st === "number") {
|
|
2179
|
+
return ` (HTTP ${String(st)})`;
|
|
2180
|
+
}
|
|
2181
|
+
if (err.code !== undefined) {
|
|
2182
|
+
return ` (${err.code})`;
|
|
2183
|
+
}
|
|
2184
|
+
return "";
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2114
2187
|
/**
|
|
2115
2188
|
* Gets a list of permissions for a server.
|
|
2116
2189
|
*
|
|
@@ -2160,6 +2233,56 @@ export class Client {
|
|
|
2160
2233
|
}
|
|
2161
2234
|
}
|
|
2162
2235
|
|
|
2236
|
+
private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
|
|
2237
|
+
if (this.isManualCloseInFlight()) {
|
|
2238
|
+
return [];
|
|
2239
|
+
}
|
|
2240
|
+
const res = await this.http.get(
|
|
2241
|
+
this.getHost() + "/user/" + userID + "/devices",
|
|
2242
|
+
);
|
|
2243
|
+
const devices = decodeAxios(DeviceArrayCodec, res.data);
|
|
2244
|
+
for (const device of devices) {
|
|
2245
|
+
this.deviceRecords[device.deviceID] = device;
|
|
2246
|
+
}
|
|
2247
|
+
return devices;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/**
|
|
2251
|
+
* DM / forward paths need the peer’s (or self) device rows under load: bounded
|
|
2252
|
+
* retries with exponential backoff (same shape as session pubkey hydration).
|
|
2253
|
+
*/
|
|
2254
|
+
private async fetchUserDeviceListWithBackoff(
|
|
2255
|
+
userID: string,
|
|
2256
|
+
label: "own" | "peer",
|
|
2257
|
+
): Promise<Device[]> {
|
|
2258
|
+
const base =
|
|
2259
|
+
label === "own"
|
|
2260
|
+
? "Couldn't get own devices"
|
|
2261
|
+
: "Couldn't get device list";
|
|
2262
|
+
let lastErr: unknown;
|
|
2263
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
2264
|
+
if (this.isManualCloseInFlight()) {
|
|
2265
|
+
return [];
|
|
2266
|
+
}
|
|
2267
|
+
if (attempt > 0) {
|
|
2268
|
+
const delayMs = 100 * 2 ** (attempt - 1);
|
|
2269
|
+
// Chunk the delay so close() can finish before we retry HTTP.
|
|
2270
|
+
const chunkMs = 10;
|
|
2271
|
+
for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
|
|
2272
|
+
if (this.isManualCloseInFlight()) {
|
|
2273
|
+
return [];
|
|
2274
|
+
}
|
|
2275
|
+
await sleep(Math.min(chunkMs, delayMs - elapsed));
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
try {
|
|
2279
|
+
return await this.fetchUserDeviceListOnce(userID);
|
|
2280
|
+
} catch (err: unknown) {
|
|
2281
|
+
lastErr = err;
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
|
|
2285
|
+
}
|
|
2163
2286
|
private async forward(message: Message) {
|
|
2164
2287
|
if (this.isManualCloseInFlight()) {
|
|
2165
2288
|
return;
|
|
@@ -2252,6 +2375,27 @@ export class Client {
|
|
|
2252
2375
|
}
|
|
2253
2376
|
}
|
|
2254
2377
|
|
|
2378
|
+
private async getDeviceRegistrationRequest(
|
|
2379
|
+
requestID: string,
|
|
2380
|
+
): Promise<null | PendingDeviceRequest> {
|
|
2381
|
+
try {
|
|
2382
|
+
const response = await this.http.get(
|
|
2383
|
+
this.prefixes.HTTP +
|
|
2384
|
+
this.host +
|
|
2385
|
+
"/user/" +
|
|
2386
|
+
this.getUser().userID +
|
|
2387
|
+
"/devices/requests/" +
|
|
2388
|
+
requestID,
|
|
2389
|
+
);
|
|
2390
|
+
return decodeAxios(PendingDeviceRequestCodec, response.data);
|
|
2391
|
+
} catch (err: unknown) {
|
|
2392
|
+
if (isAxiosError(err) && err.response?.status === 404) {
|
|
2393
|
+
return null;
|
|
2394
|
+
}
|
|
2395
|
+
throw err;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2255
2399
|
/* Retrieves the current list of users you have sessions with. */
|
|
2256
2400
|
private async getFamiliars(): Promise<User[]> {
|
|
2257
2401
|
const sessions = await this.database.getAllSessions();
|
|
@@ -2314,8 +2458,8 @@ export class Client {
|
|
|
2314
2458
|
}
|
|
2315
2459
|
})();
|
|
2316
2460
|
debugLibvexDm("getMail: inbox", {
|
|
2317
|
-
deviceID: did,
|
|
2318
2461
|
count: String(inbox.length),
|
|
2462
|
+
deviceID: did,
|
|
2319
2463
|
});
|
|
2320
2464
|
}
|
|
2321
2465
|
|
|
@@ -2325,8 +2469,8 @@ export class Client {
|
|
|
2325
2469
|
if (libvexDebugDmEnabled()) {
|
|
2326
2470
|
debugLibvexDm("getMail: readMail one", {
|
|
2327
2471
|
mailID: mailBody.mailID,
|
|
2328
|
-
type: String(mailBody.mailType),
|
|
2329
2472
|
recipient: mailBody.recipient,
|
|
2473
|
+
type: String(mailBody.mailType),
|
|
2330
2474
|
});
|
|
2331
2475
|
}
|
|
2332
2476
|
await this.readMail(mailHeader, mailBody, timestamp);
|
|
@@ -2464,20 +2608,6 @@ export class Client {
|
|
|
2464
2608
|
return this.user;
|
|
2465
2609
|
}
|
|
2466
2610
|
|
|
2467
|
-
private deviceListFailureDetail(err: unknown): string {
|
|
2468
|
-
if (!isAxiosError(err)) {
|
|
2469
|
-
return "";
|
|
2470
|
-
}
|
|
2471
|
-
const st = err.response?.status;
|
|
2472
|
-
if (typeof st === "number") {
|
|
2473
|
-
return ` (HTTP ${String(st)})`;
|
|
2474
|
-
}
|
|
2475
|
-
if (err.code !== undefined) {
|
|
2476
|
-
return ` (${err.code})`;
|
|
2477
|
-
}
|
|
2478
|
-
return "";
|
|
2479
|
-
}
|
|
2480
|
-
|
|
2481
2611
|
/**
|
|
2482
2612
|
* Single GET for `/user/:id/devices`. On failure returns `null` (swallows errors)
|
|
2483
2613
|
* — callers that need reliability should use `fetchUserDeviceListWithBackoff`.
|
|
@@ -2492,57 +2622,6 @@ export class Client {
|
|
|
2492
2622
|
}
|
|
2493
2623
|
}
|
|
2494
2624
|
|
|
2495
|
-
private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
|
|
2496
|
-
if (this.isManualCloseInFlight()) {
|
|
2497
|
-
return [];
|
|
2498
|
-
}
|
|
2499
|
-
const res = await this.http.get(
|
|
2500
|
-
this.getHost() + "/user/" + userID + "/devices",
|
|
2501
|
-
);
|
|
2502
|
-
const devices = decodeAxios(DeviceArrayCodec, res.data);
|
|
2503
|
-
for (const device of devices) {
|
|
2504
|
-
this.deviceRecords[device.deviceID] = device;
|
|
2505
|
-
}
|
|
2506
|
-
return devices;
|
|
2507
|
-
}
|
|
2508
|
-
|
|
2509
|
-
/**
|
|
2510
|
-
* DM / forward paths need the peer’s (or self) device rows under load: bounded
|
|
2511
|
-
* retries with exponential backoff (same shape as session pubkey hydration).
|
|
2512
|
-
*/
|
|
2513
|
-
private async fetchUserDeviceListWithBackoff(
|
|
2514
|
-
userID: string,
|
|
2515
|
-
label: "peer" | "own",
|
|
2516
|
-
): Promise<Device[]> {
|
|
2517
|
-
const base =
|
|
2518
|
-
label === "own"
|
|
2519
|
-
? "Couldn't get own devices"
|
|
2520
|
-
: "Couldn't get device list";
|
|
2521
|
-
let lastErr: unknown;
|
|
2522
|
-
for (let attempt = 0; attempt < 5; attempt++) {
|
|
2523
|
-
if (this.isManualCloseInFlight()) {
|
|
2524
|
-
return [];
|
|
2525
|
-
}
|
|
2526
|
-
if (attempt > 0) {
|
|
2527
|
-
const delayMs = 100 * 2 ** (attempt - 1);
|
|
2528
|
-
// Chunk the delay so close() can finish before we retry HTTP.
|
|
2529
|
-
const chunkMs = 10;
|
|
2530
|
-
for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
|
|
2531
|
-
if (this.isManualCloseInFlight()) {
|
|
2532
|
-
return [];
|
|
2533
|
-
}
|
|
2534
|
-
await sleep(Math.min(chunkMs, delayMs - elapsed));
|
|
2535
|
-
}
|
|
2536
|
-
}
|
|
2537
|
-
try {
|
|
2538
|
-
return await this.fetchUserDeviceListOnce(userID);
|
|
2539
|
-
} catch (err: unknown) {
|
|
2540
|
-
lastErr = err;
|
|
2541
|
-
}
|
|
2542
|
-
}
|
|
2543
|
-
throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
|
|
2544
|
-
}
|
|
2545
|
-
|
|
2546
2625
|
private async getUserList(channelID: string): Promise<User[]> {
|
|
2547
2626
|
const res = await this.http.post(
|
|
2548
2627
|
this.getHost() + "/userList/" + channelID,
|
|
@@ -2552,6 +2631,13 @@ export class Client {
|
|
|
2552
2631
|
|
|
2553
2632
|
private async handleNotify(msg: NotifyMsg) {
|
|
2554
2633
|
switch (msg.event) {
|
|
2634
|
+
case "deviceRequest": {
|
|
2635
|
+
const parsed = deviceRequestNotifyData.safeParse(msg.data);
|
|
2636
|
+
if (parsed.success) {
|
|
2637
|
+
this.emitter.emit("deviceRequest", parsed.data);
|
|
2638
|
+
}
|
|
2639
|
+
break;
|
|
2640
|
+
}
|
|
2555
2641
|
case "mail":
|
|
2556
2642
|
await this.getMail();
|
|
2557
2643
|
this.fetchingMail = false;
|
|
@@ -2570,28 +2656,6 @@ export class Client {
|
|
|
2570
2656
|
}
|
|
2571
2657
|
}
|
|
2572
2658
|
|
|
2573
|
-
/**
|
|
2574
|
-
* Pipeline for decrypted messages — registered in `init`. After `close()` sets
|
|
2575
|
-
* `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
|
|
2576
|
-
* race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
|
|
2577
|
-
*/
|
|
2578
|
-
private readonly onInternalMessage = (message: Message): void => {
|
|
2579
|
-
if (this.isManualCloseInFlight()) {
|
|
2580
|
-
return;
|
|
2581
|
-
}
|
|
2582
|
-
if (message.direction === "outgoing" && !message.forward) {
|
|
2583
|
-
void this.forward(message);
|
|
2584
|
-
}
|
|
2585
|
-
|
|
2586
|
-
if (
|
|
2587
|
-
message.direction === "incoming" &&
|
|
2588
|
-
message.recipient === message.sender
|
|
2589
|
-
) {
|
|
2590
|
-
return;
|
|
2591
|
-
}
|
|
2592
|
-
void this.database.saveMessage(message);
|
|
2593
|
-
};
|
|
2594
|
-
|
|
2595
2659
|
/**
|
|
2596
2660
|
* Initializes the keyring. This must be called before anything else.
|
|
2597
2661
|
*/
|
|
@@ -2693,6 +2757,15 @@ export class Client {
|
|
|
2693
2757
|
}
|
|
2694
2758
|
}
|
|
2695
2759
|
|
|
2760
|
+
/**
|
|
2761
|
+
* Fresh read of the `manuallyClosing` flag for async loops — direct property checks
|
|
2762
|
+
* after `await` are flagged as always-false by control-flow analysis even though
|
|
2763
|
+
* `close()` can run concurrently.
|
|
2764
|
+
*/
|
|
2765
|
+
private isManualCloseInFlight(): boolean {
|
|
2766
|
+
return this.manuallyClosing;
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2696
2769
|
private async kickUser(userID: string, serverID: string): Promise<void> {
|
|
2697
2770
|
const permissionList = await this.fetchPermissionList(serverID);
|
|
2698
2771
|
for (const permission of permissionList) {
|
|
@@ -2713,6 +2786,19 @@ export class Client {
|
|
|
2713
2786
|
}
|
|
2714
2787
|
}
|
|
2715
2788
|
|
|
2789
|
+
private async listDeviceRegistrationRequests(): Promise<
|
|
2790
|
+
PendingDeviceRequest[]
|
|
2791
|
+
> {
|
|
2792
|
+
const response = await this.http.get(
|
|
2793
|
+
this.prefixes.HTTP +
|
|
2794
|
+
this.host +
|
|
2795
|
+
"/user/" +
|
|
2796
|
+
this.getUser().userID +
|
|
2797
|
+
"/devices/requests",
|
|
2798
|
+
);
|
|
2799
|
+
return decodeAxios(PendingDeviceRequestArrayCodec, response.data);
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2716
2802
|
private async markSessionVerified(sessionID: string) {
|
|
2717
2803
|
return this.database.markSessionVerified(sessionID);
|
|
2718
2804
|
}
|
|
@@ -2737,6 +2823,28 @@ export class Client {
|
|
|
2737
2823
|
this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
|
|
2738
2824
|
}
|
|
2739
2825
|
|
|
2826
|
+
/**
|
|
2827
|
+
* Pipeline for decrypted messages — registered in `init`. After `close()` sets
|
|
2828
|
+
* `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
|
|
2829
|
+
* race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
|
|
2830
|
+
*/
|
|
2831
|
+
private readonly onInternalMessage = (message: Message): void => {
|
|
2832
|
+
if (this.isManualCloseInFlight()) {
|
|
2833
|
+
return;
|
|
2834
|
+
}
|
|
2835
|
+
if (message.direction === "outgoing" && !message.forward) {
|
|
2836
|
+
void this.forward(message);
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
if (
|
|
2840
|
+
message.direction === "incoming" &&
|
|
2841
|
+
message.recipient === message.sender
|
|
2842
|
+
) {
|
|
2843
|
+
return;
|
|
2844
|
+
}
|
|
2845
|
+
void this.database.saveMessage(message);
|
|
2846
|
+
};
|
|
2847
|
+
|
|
2740
2848
|
private ping() {
|
|
2741
2849
|
if (!this.isAlive) {
|
|
2742
2850
|
}
|
|
@@ -2881,9 +2989,7 @@ export class Client {
|
|
|
2881
2989
|
void this.createSession(
|
|
2882
2990
|
deviceEntry,
|
|
2883
2991
|
user,
|
|
2884
|
-
|
|
2885
|
-
`��RETRY_REQUEST:${mail.mailID}��`,
|
|
2886
|
-
),
|
|
2992
|
+
new Uint8Array(),
|
|
2887
2993
|
mail.group,
|
|
2888
2994
|
uuid.v4(),
|
|
2889
2995
|
false,
|
|
@@ -2923,10 +3029,10 @@ export class Client {
|
|
|
2923
3029
|
"readMail initial: abort (otk index mismatch)",
|
|
2924
3030
|
{
|
|
2925
3031
|
mailID: mail.mailID,
|
|
2926
|
-
preKeyIndex: String(preKeyIndex),
|
|
2927
3032
|
otkIndex: String(
|
|
2928
3033
|
otk?.index ?? "null",
|
|
2929
3034
|
),
|
|
3035
|
+
preKeyIndex: String(preKeyIndex),
|
|
2930
3036
|
thisDevice:
|
|
2931
3037
|
this.getDevice().deviceID,
|
|
2932
3038
|
},
|
|
@@ -2963,8 +3069,8 @@ export class Client {
|
|
|
2963
3069
|
debugLibvexDm(
|
|
2964
3070
|
"readMail initial: abort (IK_A null, Ed→X25519?)",
|
|
2965
3071
|
{
|
|
2966
|
-
mailID: mail.mailID,
|
|
2967
3072
|
fips: String(fipsRead),
|
|
3073
|
+
mailID: mail.mailID,
|
|
2968
3074
|
thisDevice:
|
|
2969
3075
|
this.getDevice().deviceID,
|
|
2970
3076
|
},
|
|
@@ -3091,12 +3197,12 @@ export class Client {
|
|
|
3091
3197
|
"readMail initial: ok (emit message)",
|
|
3092
3198
|
{
|
|
3093
3199
|
mailID: mail.mailID,
|
|
3094
|
-
preKeyIndex: String(preKeyIndex),
|
|
3095
|
-
thisDevice:
|
|
3096
|
-
this.getDevice().deviceID,
|
|
3097
3200
|
plaintextLen: String(
|
|
3098
3201
|
plaintext.length,
|
|
3099
3202
|
),
|
|
3203
|
+
preKeyIndex: String(preKeyIndex),
|
|
3204
|
+
thisDevice:
|
|
3205
|
+
this.getDevice().deviceID,
|
|
3100
3206
|
},
|
|
3101
3207
|
);
|
|
3102
3208
|
} catch {
|
|
@@ -3283,7 +3389,7 @@ export class Client {
|
|
|
3283
3389
|
return decodeAxios(PermissionCodec, res.data);
|
|
3284
3390
|
}
|
|
3285
3391
|
|
|
3286
|
-
private async registerDevice(): Promise<
|
|
3392
|
+
private async registerDevice(): Promise<DeviceRegistrationResult | null> {
|
|
3287
3393
|
while (!this.xKeyRing) {
|
|
3288
3394
|
await sleep(100);
|
|
3289
3395
|
}
|
|
@@ -3336,7 +3442,19 @@ export class Client {
|
|
|
3336
3442
|
msgpack.encode(devMsg),
|
|
3337
3443
|
{ headers: { "Content-Type": "application/msgpack" } },
|
|
3338
3444
|
);
|
|
3339
|
-
return decodeAxios(
|
|
3445
|
+
return decodeAxios(DeviceRegistrationResultCodec, res.data);
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
private async rejectDeviceRequest(requestID: string): Promise<void> {
|
|
3449
|
+
await this.http.post(
|
|
3450
|
+
this.prefixes.HTTP +
|
|
3451
|
+
this.host +
|
|
3452
|
+
"/user/" +
|
|
3453
|
+
this.getUser().userID +
|
|
3454
|
+
"/devices/requests/" +
|
|
3455
|
+
requestID +
|
|
3456
|
+
"/reject",
|
|
3457
|
+
);
|
|
3340
3458
|
}
|
|
3341
3459
|
|
|
3342
3460
|
private async respond(msg: ChallMsg) {
|
|
@@ -3444,8 +3562,13 @@ export class Client {
|
|
|
3444
3562
|
await this.populateKeyRing();
|
|
3445
3563
|
|
|
3446
3564
|
const newDevice = await this.registerDevice();
|
|
3447
|
-
if (newDevice) {
|
|
3565
|
+
if (newDevice && "deviceID" in newDevice) {
|
|
3448
3566
|
device = newDevice;
|
|
3567
|
+
} else if (newDevice && "status" in newDevice) {
|
|
3568
|
+
throw new Error(
|
|
3569
|
+
"Device registration requires approval from an existing device. requestID=" +
|
|
3570
|
+
newDevice.requestID,
|
|
3571
|
+
);
|
|
3449
3572
|
} else {
|
|
3450
3573
|
throw new Error("Error registering device.");
|
|
3451
3574
|
}
|
|
@@ -3456,6 +3579,27 @@ export class Client {
|
|
|
3456
3579
|
return device;
|
|
3457
3580
|
}
|
|
3458
3581
|
|
|
3582
|
+
/**
|
|
3583
|
+
* `xDHAsync` and other helpers in `@vex-chat/crypto` use the process-wide
|
|
3584
|
+
* active profile. When several {@link Client} instances use different
|
|
3585
|
+
* `cryptoProfile` values, scope the global to this instance for the duration
|
|
3586
|
+
* of that crypto work.
|
|
3587
|
+
*/
|
|
3588
|
+
private async runWithThisCryptoProfile<T>(
|
|
3589
|
+
fn: () => Promise<T>,
|
|
3590
|
+
): Promise<T> {
|
|
3591
|
+
const prev = getCryptoProfile();
|
|
3592
|
+
if (prev === this.cryptoProfile) {
|
|
3593
|
+
return await fn();
|
|
3594
|
+
}
|
|
3595
|
+
setCryptoProfile(this.cryptoProfile);
|
|
3596
|
+
try {
|
|
3597
|
+
return await fn();
|
|
3598
|
+
} finally {
|
|
3599
|
+
setCryptoProfile(prev);
|
|
3600
|
+
}
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3459
3603
|
/* header is 32 bytes and is either empty
|
|
3460
3604
|
or contains an HMAC of the message with
|
|
3461
3605
|
a derived SK */
|
|
@@ -3533,9 +3677,9 @@ export class Client {
|
|
|
3533
3677
|
if (!session || retry) {
|
|
3534
3678
|
if (libvexDebugDmEnabled()) {
|
|
3535
3679
|
debugLibvexDm("sendMail: createSession path", {
|
|
3680
|
+
hasSession: String(!!session),
|
|
3536
3681
|
peerDevice: device.deviceID,
|
|
3537
3682
|
retry: String(retry),
|
|
3538
|
-
hasSession: String(!!session),
|
|
3539
3683
|
});
|
|
3540
3684
|
}
|
|
3541
3685
|
await this.createSession(
|
|
@@ -3684,11 +3828,11 @@ export class Client {
|
|
|
3684
3828
|
debugLibvexDm(
|
|
3685
3829
|
"sendMessage: peer device list (merged, sorted)",
|
|
3686
3830
|
{
|
|
3687
|
-
userID,
|
|
3688
3831
|
nAfterBackoff: String(afterBackoff.length),
|
|
3689
3832
|
nMerged: String(deviceListRaw.length),
|
|
3690
3833
|
nSorted: String(deviceList.length),
|
|
3691
3834
|
ourDevice: this.getDevice().deviceID,
|
|
3835
|
+
userID,
|
|
3692
3836
|
},
|
|
3693
3837
|
);
|
|
3694
3838
|
for (const [i, d] of deviceList.entries()) {
|
|
@@ -3699,13 +3843,15 @@ export class Client {
|
|
|
3699
3843
|
}
|
|
3700
3844
|
let lastErr: unknown;
|
|
3701
3845
|
let failCount = 0;
|
|
3846
|
+
// One logical DM fan-outs to multiple recipient devices. Reuse a
|
|
3847
|
+
// single mailID so local/UI dedupe treats it as one message.
|
|
3848
|
+
const messageMailID = uuid.v4();
|
|
3702
3849
|
for (const device of deviceList) {
|
|
3703
|
-
const mailID = uuid.v4();
|
|
3704
3850
|
try {
|
|
3705
3851
|
if (libvexDebugDmEnabled()) {
|
|
3706
3852
|
debugLibvexDm("sendMessage: sendMail start", {
|
|
3853
|
+
mailID: messageMailID,
|
|
3707
3854
|
recipientDevice: device.deviceID,
|
|
3708
|
-
mailID,
|
|
3709
3855
|
});
|
|
3710
3856
|
}
|
|
3711
3857
|
await this.sendMail(
|
|
@@ -3713,7 +3859,7 @@ export class Client {
|
|
|
3713
3859
|
userEntry,
|
|
3714
3860
|
XUtils.decodeUTF8(message),
|
|
3715
3861
|
null,
|
|
3716
|
-
|
|
3862
|
+
messageMailID,
|
|
3717
3863
|
false,
|
|
3718
3864
|
);
|
|
3719
3865
|
if (libvexDebugDmEnabled()) {
|