@vex-chat/libvex 6.8.0 → 7.0.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/dist/Client.d.ts +24 -8
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +220 -124
- package/dist/Client.js.map +1 -1
- package/dist/Storage.d.ts +6 -0
- package/dist/Storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.d.ts +2 -1
- package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.js +27 -0
- package/dist/__tests__/harness/memory-storage.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/messageExtra.d.ts +18 -0
- package/dist/messageExtra.d.ts.map +1 -1
- package/dist/messageExtra.js +104 -0
- package/dist/messageExtra.js.map +1 -1
- package/dist/storage/sqlite.d.ts +2 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +47 -1
- package/dist/storage/sqlite.js.map +1 -1
- package/package.json +4 -4
- package/src/Client.ts +336 -169
- package/src/Storage.ts +11 -0
- package/src/__tests__/harness/memory-storage.ts +42 -1
- package/src/__tests__/messageExtra.test.ts +38 -0
- package/src/index.ts +6 -1
- package/src/messageExtra.ts +145 -0
- package/src/storage/sqlite.ts +65 -3
package/src/Client.ts
CHANGED
|
@@ -422,7 +422,12 @@ export interface Devices {
|
|
|
422
422
|
challenge: string;
|
|
423
423
|
requestID: string;
|
|
424
424
|
}) => Promise<void>;
|
|
425
|
-
/**
|
|
425
|
+
/**
|
|
426
|
+
* Approves a pending device registration request as the current device.
|
|
427
|
+
* Servers with required passkeys expect the current bearer token to be a
|
|
428
|
+
* fresh passkey session while the current device token identifies the
|
|
429
|
+
* approving device.
|
|
430
|
+
*/
|
|
426
431
|
approveRequest: (requestID: string) => Promise<Device>;
|
|
427
432
|
/** Deletes one of the account's devices (except the currently active one). */
|
|
428
433
|
delete: (deviceID: string) => Promise<void>;
|
|
@@ -705,8 +710,6 @@ export interface NotificationSubscriptionInput {
|
|
|
705
710
|
* @public
|
|
706
711
|
*/
|
|
707
712
|
export interface Passkeys {
|
|
708
|
-
/** Approves a pending device-enrollment request using the passkey session. */
|
|
709
|
-
approveDeviceRequest: (requestID: string) => Promise<Device>;
|
|
710
713
|
/** Begin a public passkey authentication ceremony for `username`. */
|
|
711
714
|
beginAuthentication: (username: string) => Promise<{
|
|
712
715
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
|
|
@@ -748,6 +751,12 @@ export interface Passkeys {
|
|
|
748
751
|
list: () => Promise<Passkey[]>;
|
|
749
752
|
/** List all of the account's devices using the passkey session. */
|
|
750
753
|
listDevices: () => Promise<Device[]>;
|
|
754
|
+
/**
|
|
755
|
+
* Recover the account onto a pending device using the passkey
|
|
756
|
+
* session. The server approves the pending device and revokes all
|
|
757
|
+
* previously-active devices for the account.
|
|
758
|
+
*/
|
|
759
|
+
recoverDeviceRequest: (requestID: string) => Promise<Device>;
|
|
751
760
|
/** Reject a pending device-enrollment request using the passkey session. */
|
|
752
761
|
rejectDeviceRequest: (requestID: string) => Promise<void>;
|
|
753
762
|
}
|
|
@@ -866,6 +875,9 @@ const messageSchema: z.ZodType<Message> = z.object({
|
|
|
866
875
|
});
|
|
867
876
|
|
|
868
877
|
const MESSAGE_BLOB_PREFIX = "vex-message:1\n";
|
|
878
|
+
const MAIL_FANOUT_CONCURRENCY = 8;
|
|
879
|
+
const MAIL_BATCH_MAX_SIZE = 32;
|
|
880
|
+
const MAIL_BATCH_FLUSH_DELAY_MS = 8;
|
|
869
881
|
|
|
870
882
|
interface DecodedMessagePlaintext {
|
|
871
883
|
extra?: null | string | undefined;
|
|
@@ -873,6 +885,27 @@ interface DecodedMessagePlaintext {
|
|
|
873
885
|
retentionHintDays?: number | undefined;
|
|
874
886
|
}
|
|
875
887
|
|
|
888
|
+
interface PendingMailBatchDelivery {
|
|
889
|
+
header: Uint8Array;
|
|
890
|
+
mail: MailWS;
|
|
891
|
+
msg: ResourceMsg;
|
|
892
|
+
reject: (err: unknown) => void;
|
|
893
|
+
resolve: () => void;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const mailBatchResponseSchema = z.object({
|
|
897
|
+
results: z.array(
|
|
898
|
+
z.object({
|
|
899
|
+
error: z.string().optional(),
|
|
900
|
+
index: z.number().int().nonnegative(),
|
|
901
|
+
mailID: z.string().optional(),
|
|
902
|
+
ok: z.boolean(),
|
|
903
|
+
recipient: z.string().optional(),
|
|
904
|
+
status: z.number().int().optional(),
|
|
905
|
+
}),
|
|
906
|
+
),
|
|
907
|
+
});
|
|
908
|
+
|
|
876
909
|
function decodeMessageBlob(body: string): DecodedMessagePlaintext {
|
|
877
910
|
if (!body.startsWith(MESSAGE_BLOB_PREFIX)) {
|
|
878
911
|
return { message: body };
|
|
@@ -1406,16 +1439,15 @@ export class Client {
|
|
|
1406
1439
|
* Passkey ("recovery credential") methods.
|
|
1407
1440
|
*
|
|
1408
1441
|
* Passkeys are an account-bound second-class credential that can
|
|
1409
|
-
* authenticate the owning user, list devices, delete devices,
|
|
1410
|
-
*
|
|
1411
|
-
*
|
|
1442
|
+
* authenticate the owning user, list devices, delete devices, recover a
|
|
1443
|
+
* pending device enrollment, and reject pending device-enrollment
|
|
1444
|
+
* requests. They cannot send/decrypt mail.
|
|
1412
1445
|
*
|
|
1413
1446
|
* The host app drives the WebAuthn ceremony (e.g. via
|
|
1414
1447
|
* `@simplewebauthn/browser`) and hands the JSON response to
|
|
1415
1448
|
* `finish*`.
|
|
1416
1449
|
*/
|
|
1417
1450
|
public passkeys: Passkeys = {
|
|
1418
|
-
approveDeviceRequest: this.passkeyApproveDeviceRequest.bind(this),
|
|
1419
1451
|
beginAuthentication: this.beginPasskeyAuthentication.bind(this),
|
|
1420
1452
|
beginRegistration: this.beginPasskeyRegistration.bind(this),
|
|
1421
1453
|
delete: this.deletePasskey.bind(this),
|
|
@@ -1424,6 +1456,7 @@ export class Client {
|
|
|
1424
1456
|
finishRegistration: this.finishPasskeyRegistration.bind(this),
|
|
1425
1457
|
list: this.listPasskeys.bind(this),
|
|
1426
1458
|
listDevices: this.passkeyListDevices.bind(this),
|
|
1459
|
+
recoverDeviceRequest: this.passkeyRecoverDeviceRequest.bind(this),
|
|
1427
1460
|
rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
|
|
1428
1461
|
};
|
|
1429
1462
|
|
|
@@ -1557,6 +1590,9 @@ export class Client {
|
|
|
1557
1590
|
private localMessageRetentionDays: number;
|
|
1558
1591
|
private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
|
|
1559
1592
|
null;
|
|
1593
|
+
private mailBatchFlushTimer: null | ReturnType<typeof setTimeout> = null;
|
|
1594
|
+
private readonly mailBatchQueue: PendingMailBatchDelivery[] = [];
|
|
1595
|
+
private mailBatchUnsupported = false;
|
|
1560
1596
|
private readonly mailInterval?: NodeJS.Timeout;
|
|
1561
1597
|
|
|
1562
1598
|
private manuallyClosing: boolean = false;
|
|
@@ -1879,6 +1915,14 @@ export class Client {
|
|
|
1879
1915
|
if (this.mailInterval) {
|
|
1880
1916
|
clearInterval(this.mailInterval);
|
|
1881
1917
|
}
|
|
1918
|
+
if (this.mailBatchFlushTimer) {
|
|
1919
|
+
clearTimeout(this.mailBatchFlushTimer);
|
|
1920
|
+
this.mailBatchFlushTimer = null;
|
|
1921
|
+
}
|
|
1922
|
+
const pendingMailBatch = this.mailBatchQueue.splice(0);
|
|
1923
|
+
for (const pending of pendingMailBatch) {
|
|
1924
|
+
pending.reject(new Error("Client closed before mail batch sent."));
|
|
1925
|
+
}
|
|
1882
1926
|
if (this.localRetentionPurgeTimer) {
|
|
1883
1927
|
clearInterval(this.localRetentionPurgeTimer);
|
|
1884
1928
|
this.localRetentionPurgeTimer = null;
|
|
@@ -2674,7 +2718,8 @@ export class Client {
|
|
|
2674
2718
|
// my keys
|
|
2675
2719
|
const IK_A = this.xKeyRing.identityKeys.secretKey;
|
|
2676
2720
|
const IK_AP = this.xKeyRing.identityKeys.publicKey;
|
|
2677
|
-
const
|
|
2721
|
+
const ephemeralKeys = await xBoxKeyPairAsync();
|
|
2722
|
+
const EK_A = ephemeralKeys.secretKey;
|
|
2678
2723
|
|
|
2679
2724
|
const fips = this.cryptoProfile === "fips";
|
|
2680
2725
|
// their keys — FIPS: `signKey` in bundle is the peer P-256 ECDH identity (raw, typically 65B).
|
|
@@ -2732,13 +2777,13 @@ export class Client {
|
|
|
2732
2777
|
const cipher = await xSecretboxAsync(message, nonce, SK);
|
|
2733
2778
|
|
|
2734
2779
|
const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
|
|
2735
|
-
const ephKeyWire =
|
|
2780
|
+
const ephKeyWire = ephemeralKeys.publicKey;
|
|
2736
2781
|
|
|
2737
2782
|
const extra = fips
|
|
2738
2783
|
? encodeFipsInitialExtraV1(signKeyWire, ephKeyWire, PK, AD, IDX)
|
|
2739
2784
|
: xConcat(
|
|
2740
2785
|
this.signKeys.publicKey,
|
|
2741
|
-
|
|
2786
|
+
ephemeralKeys.publicKey,
|
|
2742
2787
|
PK,
|
|
2743
2788
|
AD,
|
|
2744
2789
|
IDX,
|
|
@@ -2768,9 +2813,6 @@ export class Client {
|
|
|
2768
2813
|
type: "resource",
|
|
2769
2814
|
};
|
|
2770
2815
|
|
|
2771
|
-
// discard the ephemeral keys
|
|
2772
|
-
await this.newEphemeralKeys();
|
|
2773
|
-
|
|
2774
2816
|
const ratchet = await initRatchetSession(SK, "initiator");
|
|
2775
2817
|
const sessionEntry: SessionSQL = {
|
|
2776
2818
|
...ratchet,
|
|
@@ -2816,37 +2858,7 @@ export class Client {
|
|
|
2816
2858
|
this.emitter.emit("message", emitMsg);
|
|
2817
2859
|
}
|
|
2818
2860
|
|
|
2819
|
-
|
|
2820
|
-
await new Promise((res, rej) => {
|
|
2821
|
-
const callback = (packedMsg: Uint8Array) => {
|
|
2822
|
-
const [_header, receivedMsg] =
|
|
2823
|
-
XUtils.unpackMessage(packedMsg);
|
|
2824
|
-
if (receivedMsg.transmissionID === msg.transmissionID) {
|
|
2825
|
-
this.socket.off("message", callback);
|
|
2826
|
-
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
2827
|
-
if (parsed.success && parsed.data.type === "success") {
|
|
2828
|
-
res(parsed.data.data);
|
|
2829
|
-
} else {
|
|
2830
|
-
rej(
|
|
2831
|
-
new Error(
|
|
2832
|
-
"Mail delivery failed: " +
|
|
2833
|
-
JSON.stringify(receivedMsg),
|
|
2834
|
-
),
|
|
2835
|
-
);
|
|
2836
|
-
}
|
|
2837
|
-
}
|
|
2838
|
-
};
|
|
2839
|
-
this.socket.on("message", callback);
|
|
2840
|
-
// Forward send failures to the outer promise instead
|
|
2841
|
-
// of leaking them as an unhandled rejection: the
|
|
2842
|
-
// listener above can never resolve if the send didn't
|
|
2843
|
-
// make it onto the wire, so without this the caller
|
|
2844
|
-
// would hang for the full 30s send-loop timeout.
|
|
2845
|
-
this.send(msg, hmac).catch((err: unknown) => {
|
|
2846
|
-
this.socket.off("message", callback);
|
|
2847
|
-
rej(err instanceof Error ? err : new Error(String(err)));
|
|
2848
|
-
});
|
|
2849
|
-
});
|
|
2861
|
+
await this.deliverMailResource(msg, hmac, mail);
|
|
2850
2862
|
});
|
|
2851
2863
|
}
|
|
2852
2864
|
|
|
@@ -2886,6 +2898,59 @@ export class Client {
|
|
|
2886
2898
|
private async deleteServer(serverID: string): Promise<void> {
|
|
2887
2899
|
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
2888
2900
|
}
|
|
2901
|
+
private deliverMailResource(
|
|
2902
|
+
msg: ResourceMsg,
|
|
2903
|
+
header: Uint8Array,
|
|
2904
|
+
mail: MailWS,
|
|
2905
|
+
): Promise<void> {
|
|
2906
|
+
if (this.mailBatchUnsupported) {
|
|
2907
|
+
return this.deliverMailResourceOverSocket(msg, header);
|
|
2908
|
+
}
|
|
2909
|
+
return new Promise<void>((resolve, reject) => {
|
|
2910
|
+
this.mailBatchQueue.push({
|
|
2911
|
+
header,
|
|
2912
|
+
mail,
|
|
2913
|
+
msg,
|
|
2914
|
+
reject,
|
|
2915
|
+
resolve,
|
|
2916
|
+
});
|
|
2917
|
+
if (this.mailBatchQueue.length >= MAIL_BATCH_MAX_SIZE) {
|
|
2918
|
+
void this.flushMailBatchQueue();
|
|
2919
|
+
} else {
|
|
2920
|
+
this.scheduleMailBatchFlush();
|
|
2921
|
+
}
|
|
2922
|
+
});
|
|
2923
|
+
}
|
|
2924
|
+
private async deliverMailResourceOverSocket(
|
|
2925
|
+
msg: ResourceMsg,
|
|
2926
|
+
header: Uint8Array,
|
|
2927
|
+
): Promise<void> {
|
|
2928
|
+
await new Promise<void>((res, rej) => {
|
|
2929
|
+
const callback = (packedMsg: Uint8Array) => {
|
|
2930
|
+
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
2931
|
+
if (receivedMsg.transmissionID === msg.transmissionID) {
|
|
2932
|
+
this.socket.off("message", callback);
|
|
2933
|
+
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
2934
|
+
if (parsed.success && parsed.data.type === "success") {
|
|
2935
|
+
res();
|
|
2936
|
+
} else {
|
|
2937
|
+
rej(
|
|
2938
|
+
new Error(
|
|
2939
|
+
"Mail delivery failed: " +
|
|
2940
|
+
JSON.stringify(receivedMsg),
|
|
2941
|
+
),
|
|
2942
|
+
);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
};
|
|
2946
|
+
this.socket.on("message", callback);
|
|
2947
|
+
this.send(msg, header).catch((err: unknown) => {
|
|
2948
|
+
this.socket.off("message", callback);
|
|
2949
|
+
rej(err instanceof Error ? err : new Error(String(err)));
|
|
2950
|
+
});
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2889
2954
|
private deviceListFailureDetail(err: unknown): string {
|
|
2890
2955
|
if (!isHttpError(err)) {
|
|
2891
2956
|
return "";
|
|
@@ -2899,6 +2964,7 @@ export class Client {
|
|
|
2899
2964
|
}
|
|
2900
2965
|
return "";
|
|
2901
2966
|
}
|
|
2967
|
+
|
|
2902
2968
|
/**
|
|
2903
2969
|
* Gets a list of permissions for a server.
|
|
2904
2970
|
*
|
|
@@ -3053,6 +3119,84 @@ export class Client {
|
|
|
3053
3119
|
return decodeHttpResponse(PasskeyCodec, response.data);
|
|
3054
3120
|
}
|
|
3055
3121
|
|
|
3122
|
+
private async flushMailBatchOverSocket(
|
|
3123
|
+
batch: PendingMailBatchDelivery[],
|
|
3124
|
+
): Promise<void> {
|
|
3125
|
+
await Promise.all(
|
|
3126
|
+
batch.map(async (item) => {
|
|
3127
|
+
try {
|
|
3128
|
+
await this.deliverMailResourceOverSocket(
|
|
3129
|
+
item.msg,
|
|
3130
|
+
item.header,
|
|
3131
|
+
);
|
|
3132
|
+
item.resolve();
|
|
3133
|
+
} catch (err: unknown) {
|
|
3134
|
+
item.reject(err);
|
|
3135
|
+
}
|
|
3136
|
+
}),
|
|
3137
|
+
);
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
private async flushMailBatchQueue(): Promise<void> {
|
|
3141
|
+
if (this.mailBatchFlushTimer) {
|
|
3142
|
+
clearTimeout(this.mailBatchFlushTimer);
|
|
3143
|
+
this.mailBatchFlushTimer = null;
|
|
3144
|
+
}
|
|
3145
|
+
const batch = this.mailBatchQueue.splice(0, MAIL_BATCH_MAX_SIZE);
|
|
3146
|
+
if (this.mailBatchQueue.length > 0) {
|
|
3147
|
+
this.scheduleMailBatchFlush();
|
|
3148
|
+
}
|
|
3149
|
+
if (batch.length === 0) {
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
if (this.mailBatchUnsupported) {
|
|
3153
|
+
await this.flushMailBatchOverSocket(batch);
|
|
3154
|
+
return;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
try {
|
|
3158
|
+
const response = await this.http.post(
|
|
3159
|
+
this.getHost() + "/mail/batch",
|
|
3160
|
+
msgpack.encode({
|
|
3161
|
+
mails: batch.map((item) => ({
|
|
3162
|
+
header: item.header,
|
|
3163
|
+
mail: item.mail,
|
|
3164
|
+
})),
|
|
3165
|
+
}),
|
|
3166
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
3167
|
+
);
|
|
3168
|
+
const decoded = mailBatchResponseSchema.parse(
|
|
3169
|
+
msgpack.decode(new Uint8Array(response.data)),
|
|
3170
|
+
);
|
|
3171
|
+
const resultsByIndex = new Map(
|
|
3172
|
+
decoded.results.map((result) => [result.index, result]),
|
|
3173
|
+
);
|
|
3174
|
+
for (const [index, item] of batch.entries()) {
|
|
3175
|
+
const result = resultsByIndex.get(index);
|
|
3176
|
+
if (result?.ok === true) {
|
|
3177
|
+
item.resolve();
|
|
3178
|
+
continue;
|
|
3179
|
+
}
|
|
3180
|
+
item.reject(
|
|
3181
|
+
new Error(
|
|
3182
|
+
"Mail delivery failed: " +
|
|
3183
|
+
(result?.error ??
|
|
3184
|
+
`missing batch result for index ${String(index)}`),
|
|
3185
|
+
),
|
|
3186
|
+
);
|
|
3187
|
+
}
|
|
3188
|
+
} catch (err: unknown) {
|
|
3189
|
+
if (isHttpError(err) && err.response?.status === 404) {
|
|
3190
|
+
this.mailBatchUnsupported = true;
|
|
3191
|
+
await this.flushMailBatchOverSocket(batch);
|
|
3192
|
+
return;
|
|
3193
|
+
}
|
|
3194
|
+
for (const item of batch) {
|
|
3195
|
+
item.reject(err);
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3056
3200
|
private async forward(message: Message) {
|
|
3057
3201
|
if (this.isManualCloseInFlight()) {
|
|
3058
3202
|
return;
|
|
@@ -3076,22 +3220,34 @@ export class Client {
|
|
|
3076
3220
|
this.getUser().userID,
|
|
3077
3221
|
"own",
|
|
3078
3222
|
);
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3223
|
+
const targetDevices = devices.filter(
|
|
3224
|
+
(device) => device.deviceID !== this.getDevice().deviceID,
|
|
3225
|
+
);
|
|
3226
|
+
for (
|
|
3227
|
+
let index = 0;
|
|
3228
|
+
index < targetDevices.length;
|
|
3229
|
+
index += MAIL_FANOUT_CONCURRENCY
|
|
3230
|
+
) {
|
|
3231
|
+
const batch = targetDevices.slice(
|
|
3232
|
+
index,
|
|
3233
|
+
index + MAIL_FANOUT_CONCURRENCY,
|
|
3234
|
+
);
|
|
3235
|
+
await Promise.all(
|
|
3236
|
+
batch.map(async (device) => {
|
|
3237
|
+
try {
|
|
3238
|
+
await this.sendMailWithRecovery(
|
|
3239
|
+
device,
|
|
3240
|
+
this.getUser(),
|
|
3241
|
+
msgBytes,
|
|
3242
|
+
null,
|
|
3243
|
+
copy.mailID,
|
|
3244
|
+
true,
|
|
3245
|
+
);
|
|
3246
|
+
} catch {
|
|
3247
|
+
/* best-effort per device */
|
|
3248
|
+
}
|
|
3249
|
+
}),
|
|
3250
|
+
);
|
|
3095
3251
|
}
|
|
3096
3252
|
}
|
|
3097
3253
|
|
|
@@ -3467,8 +3623,6 @@ export class Client {
|
|
|
3467
3623
|
return true;
|
|
3468
3624
|
}
|
|
3469
3625
|
|
|
3470
|
-
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3471
|
-
|
|
3472
3626
|
/**
|
|
3473
3627
|
* Initializes the keyring. This must be called before anything else.
|
|
3474
3628
|
*/
|
|
@@ -3488,8 +3642,6 @@ export class Client {
|
|
|
3488
3642
|
this.emitter.emit("ready");
|
|
3489
3643
|
}
|
|
3490
3644
|
|
|
3491
|
-
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3492
|
-
|
|
3493
3645
|
private initSocket() {
|
|
3494
3646
|
try {
|
|
3495
3647
|
if (!this.token) {
|
|
@@ -3673,16 +3825,6 @@ export class Client {
|
|
|
3673
3825
|
await this.submitOTK(needs);
|
|
3674
3826
|
}
|
|
3675
3827
|
|
|
3676
|
-
private async newEphemeralKeys() {
|
|
3677
|
-
if (!this.xKeyRing) {
|
|
3678
|
-
if (this.manuallyClosing) {
|
|
3679
|
-
return;
|
|
3680
|
-
}
|
|
3681
|
-
throw new Error("Key ring not initialized.");
|
|
3682
|
-
}
|
|
3683
|
-
this.xKeyRing.ephemeralKeys = await xBoxKeyPairAsync();
|
|
3684
|
-
}
|
|
3685
|
-
|
|
3686
3828
|
/**
|
|
3687
3829
|
* Pipeline for decrypted messages — registered in `init`. After `close()` sets
|
|
3688
3830
|
* `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
|
|
@@ -3710,21 +3852,6 @@ export class Client {
|
|
|
3710
3852
|
this.scheduleRetentionPurge();
|
|
3711
3853
|
};
|
|
3712
3854
|
|
|
3713
|
-
private async passkeyApproveDeviceRequest(
|
|
3714
|
-
requestID: string,
|
|
3715
|
-
): Promise<Device> {
|
|
3716
|
-
const userID = this.getUser().userID;
|
|
3717
|
-
const response = await this.http.post(
|
|
3718
|
-
this.getHost() +
|
|
3719
|
-
"/user/" +
|
|
3720
|
-
userID +
|
|
3721
|
-
"/passkey/devices/requests/" +
|
|
3722
|
-
requestID +
|
|
3723
|
-
"/approve",
|
|
3724
|
-
);
|
|
3725
|
-
return decodeHttpResponse(DeviceCodec, response.data);
|
|
3726
|
-
}
|
|
3727
|
-
|
|
3728
3855
|
private async passkeyDeleteDevice(deviceID: string): Promise<void> {
|
|
3729
3856
|
const userID = this.getUser().userID;
|
|
3730
3857
|
await this.http.delete(
|
|
@@ -3740,6 +3867,20 @@ export class Client {
|
|
|
3740
3867
|
return decodeHttpResponse(DeviceArrayCodec, response.data);
|
|
3741
3868
|
}
|
|
3742
3869
|
|
|
3870
|
+
private async passkeyRecoverDeviceRequest(
|
|
3871
|
+
requestID: string,
|
|
3872
|
+
): Promise<Device> {
|
|
3873
|
+
const userID = this.getUser().userID;
|
|
3874
|
+
const response = await this.http.post(
|
|
3875
|
+
this.getHost() +
|
|
3876
|
+
"/user/" +
|
|
3877
|
+
userID +
|
|
3878
|
+
"/passkey/recover/devices/requests/" +
|
|
3879
|
+
requestID,
|
|
3880
|
+
);
|
|
3881
|
+
return decodeHttpResponse(DeviceCodec, response.data);
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3743
3884
|
private async passkeyRejectDeviceRequest(requestID: string): Promise<void> {
|
|
3744
3885
|
const userID = this.getUser().userID;
|
|
3745
3886
|
await this.http.post(
|
|
@@ -4818,6 +4959,16 @@ export class Client {
|
|
|
4818
4959
|
}
|
|
4819
4960
|
}
|
|
4820
4961
|
|
|
4962
|
+
private scheduleMailBatchFlush(): void {
|
|
4963
|
+
if (this.mailBatchFlushTimer) {
|
|
4964
|
+
return;
|
|
4965
|
+
}
|
|
4966
|
+
this.mailBatchFlushTimer = setTimeout(() => {
|
|
4967
|
+
this.mailBatchFlushTimer = null;
|
|
4968
|
+
void this.flushMailBatchQueue();
|
|
4969
|
+
}, MAIL_BATCH_FLUSH_DELAY_MS);
|
|
4970
|
+
}
|
|
4971
|
+
|
|
4821
4972
|
private scheduleReconnect(): void {
|
|
4822
4973
|
if (
|
|
4823
4974
|
!this.autoReconnectEnabled ||
|
|
@@ -4963,27 +5114,49 @@ export class Client {
|
|
|
4963
5114
|
|
|
4964
5115
|
let failCount = 0;
|
|
4965
5116
|
let lastErr: unknown;
|
|
4966
|
-
for (
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
5117
|
+
for (
|
|
5118
|
+
let index = 0;
|
|
5119
|
+
index < stableDevices.length;
|
|
5120
|
+
index += MAIL_FANOUT_CONCURRENCY
|
|
5121
|
+
) {
|
|
5122
|
+
const batch = stableDevices.slice(
|
|
5123
|
+
index,
|
|
5124
|
+
index + MAIL_FANOUT_CONCURRENCY,
|
|
5125
|
+
);
|
|
5126
|
+
const results = await Promise.all(
|
|
5127
|
+
batch.map(async (device): Promise<undefined | unknown> => {
|
|
5128
|
+
const ownerRecord =
|
|
5129
|
+
device.owner === myUserID
|
|
5130
|
+
? this.getUser()
|
|
5131
|
+
: this.userRecords[device.owner];
|
|
5132
|
+
if (!ownerRecord) {
|
|
5133
|
+
return new Error(
|
|
5134
|
+
`Missing owner record for device ${device.deviceID}.`,
|
|
5135
|
+
);
|
|
5136
|
+
}
|
|
5137
|
+
try {
|
|
5138
|
+
await this.sendMailWithRecovery(
|
|
5139
|
+
device,
|
|
5140
|
+
ownerRecord,
|
|
5141
|
+
msgBytes,
|
|
5142
|
+
uuidToUint8(channelID),
|
|
5143
|
+
mailID,
|
|
5144
|
+
false,
|
|
5145
|
+
);
|
|
5146
|
+
return undefined;
|
|
5147
|
+
} catch (e) {
|
|
5148
|
+
return e;
|
|
5149
|
+
}
|
|
5150
|
+
}),
|
|
5151
|
+
);
|
|
5152
|
+
for (const result of results) {
|
|
5153
|
+
if (result !== undefined) {
|
|
5154
|
+
lastErr = result;
|
|
5155
|
+
failCount += 1;
|
|
5156
|
+
}
|
|
4974
5157
|
}
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
device,
|
|
4978
|
-
ownerRecord,
|
|
4979
|
-
msgBytes,
|
|
4980
|
-
uuidToUint8(channelID),
|
|
4981
|
-
mailID,
|
|
4982
|
-
false,
|
|
4983
|
-
);
|
|
4984
|
-
} catch (e) {
|
|
4985
|
-
lastErr = e;
|
|
4986
|
-
failCount += 1;
|
|
5158
|
+
if (failCount === stableDevices.length) {
|
|
5159
|
+
break;
|
|
4987
5160
|
}
|
|
4988
5161
|
}
|
|
4989
5162
|
|
|
@@ -5138,35 +5311,7 @@ export class Client {
|
|
|
5138
5311
|
await this.database.saveSession(persisted);
|
|
5139
5312
|
this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
|
|
5140
5313
|
|
|
5141
|
-
await
|
|
5142
|
-
const callback = (packedMsg: Uint8Array) => {
|
|
5143
|
-
const [_header, receivedMsg] =
|
|
5144
|
-
XUtils.unpackMessage(packedMsg);
|
|
5145
|
-
if (receivedMsg.transmissionID === msgb.transmissionID) {
|
|
5146
|
-
this.socket.off("message", callback);
|
|
5147
|
-
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
5148
|
-
if (parsed.success && parsed.data.type === "success") {
|
|
5149
|
-
res(parsed.data.data);
|
|
5150
|
-
} else {
|
|
5151
|
-
rej(
|
|
5152
|
-
new Error(
|
|
5153
|
-
"Mail delivery failed: " +
|
|
5154
|
-
JSON.stringify(receivedMsg),
|
|
5155
|
-
),
|
|
5156
|
-
);
|
|
5157
|
-
}
|
|
5158
|
-
}
|
|
5159
|
-
};
|
|
5160
|
-
this.socket.on("message", callback);
|
|
5161
|
-
// See the matching block above (sendMail handshake):
|
|
5162
|
-
// forward send failures to the outer promise so the
|
|
5163
|
-
// caller doesn't hang waiting for a response we never
|
|
5164
|
-
// sent.
|
|
5165
|
-
this.send(msgb, hmac).catch((err: unknown) => {
|
|
5166
|
-
this.socket.off("message", callback);
|
|
5167
|
-
rej(err instanceof Error ? err : new Error(String(err)));
|
|
5168
|
-
});
|
|
5169
|
-
});
|
|
5314
|
+
await this.deliverMailResource(msgb, hmac, mail);
|
|
5170
5315
|
} finally {
|
|
5171
5316
|
this.sending.delete(device.deviceID);
|
|
5172
5317
|
}
|
|
@@ -5265,38 +5410,60 @@ export class Client {
|
|
|
5265
5410
|
// One logical DM fan-outs to multiple recipient devices. Reuse a
|
|
5266
5411
|
// single mailID so local/UI dedupe treats it as one message.
|
|
5267
5412
|
const messageMailID = uuid.v4();
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5413
|
+
const msgBytes = XUtils.decodeUTF8(payload);
|
|
5414
|
+
for (
|
|
5415
|
+
let index = 0;
|
|
5416
|
+
index < deviceList.length;
|
|
5417
|
+
index += MAIL_FANOUT_CONCURRENCY
|
|
5418
|
+
) {
|
|
5419
|
+
const batch = deviceList.slice(
|
|
5420
|
+
index,
|
|
5421
|
+
index + MAIL_FANOUT_CONCURRENCY,
|
|
5422
|
+
);
|
|
5423
|
+
const results = await Promise.all(
|
|
5424
|
+
batch.map(async (device): Promise<undefined | unknown> => {
|
|
5425
|
+
try {
|
|
5426
|
+
if (libvexDebugDmEnabled()) {
|
|
5427
|
+
debugLibvexDm("sendMessage: sendMail start", {
|
|
5428
|
+
mailID: messageMailID,
|
|
5429
|
+
recipientDevice: device.deviceID,
|
|
5430
|
+
});
|
|
5431
|
+
}
|
|
5432
|
+
await this.sendMailWithRecovery(
|
|
5433
|
+
device,
|
|
5434
|
+
userEntry,
|
|
5435
|
+
msgBytes,
|
|
5436
|
+
null,
|
|
5437
|
+
messageMailID,
|
|
5438
|
+
false,
|
|
5439
|
+
);
|
|
5440
|
+
if (libvexDebugDmEnabled()) {
|
|
5441
|
+
debugLibvexDm("sendMessage: sendMail ok", {
|
|
5442
|
+
recipientDevice: device.deviceID,
|
|
5443
|
+
});
|
|
5444
|
+
}
|
|
5445
|
+
return undefined;
|
|
5446
|
+
} catch (e) {
|
|
5447
|
+
if (libvexDebugDmEnabled()) {
|
|
5448
|
+
// eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
|
|
5449
|
+
console.error(
|
|
5450
|
+
"[libvex:debug-dm] sendMessage: sendMail failed for device",
|
|
5451
|
+
device.deviceID,
|
|
5452
|
+
e,
|
|
5453
|
+
);
|
|
5454
|
+
}
|
|
5455
|
+
return e;
|
|
5456
|
+
}
|
|
5457
|
+
}),
|
|
5458
|
+
);
|
|
5459
|
+
for (const result of results) {
|
|
5460
|
+
if (result !== undefined) {
|
|
5461
|
+
lastErr = result;
|
|
5462
|
+
failCount += 1;
|
|
5297
5463
|
}
|
|
5298
|
-
|
|
5299
|
-
|
|
5464
|
+
}
|
|
5465
|
+
if (failCount === deviceList.length) {
|
|
5466
|
+
break;
|
|
5300
5467
|
}
|
|
5301
5468
|
}
|
|
5302
5469
|
if (failCount > 0) {
|