@vex-chat/libvex 6.7.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Client.d.ts +18 -7
- 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 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/messageExtra.d.ts +132 -0
- package/dist/messageExtra.d.ts.map +1 -0
- package/dist/messageExtra.js +487 -0
- package/dist/messageExtra.js.map +1 -0
- 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 +330 -168
- package/src/Storage.ts +11 -0
- package/src/__tests__/harness/memory-storage.ts +42 -1
- package/src/__tests__/messageExtra.test.ts +135 -0
- package/src/index.ts +34 -1
- package/src/messageExtra.ts +730 -0
- package/src/storage/sqlite.ts +65 -3
package/src/Client.ts
CHANGED
|
@@ -705,8 +705,6 @@ export interface NotificationSubscriptionInput {
|
|
|
705
705
|
* @public
|
|
706
706
|
*/
|
|
707
707
|
export interface Passkeys {
|
|
708
|
-
/** Approves a pending device-enrollment request using the passkey session. */
|
|
709
|
-
approveDeviceRequest: (requestID: string) => Promise<Device>;
|
|
710
708
|
/** Begin a public passkey authentication ceremony for `username`. */
|
|
711
709
|
beginAuthentication: (username: string) => Promise<{
|
|
712
710
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebAuthn options shape varies per simplewebauthn version
|
|
@@ -748,6 +746,12 @@ export interface Passkeys {
|
|
|
748
746
|
list: () => Promise<Passkey[]>;
|
|
749
747
|
/** List all of the account's devices using the passkey session. */
|
|
750
748
|
listDevices: () => Promise<Device[]>;
|
|
749
|
+
/**
|
|
750
|
+
* Recover the account onto a pending device using the passkey
|
|
751
|
+
* session. The server approves the pending device and revokes all
|
|
752
|
+
* previously-active devices for the account.
|
|
753
|
+
*/
|
|
754
|
+
recoverDeviceRequest: (requestID: string) => Promise<Device>;
|
|
751
755
|
/** Reject a pending device-enrollment request using the passkey session. */
|
|
752
756
|
rejectDeviceRequest: (requestID: string) => Promise<void>;
|
|
753
757
|
}
|
|
@@ -866,6 +870,9 @@ const messageSchema: z.ZodType<Message> = z.object({
|
|
|
866
870
|
});
|
|
867
871
|
|
|
868
872
|
const MESSAGE_BLOB_PREFIX = "vex-message:1\n";
|
|
873
|
+
const MAIL_FANOUT_CONCURRENCY = 8;
|
|
874
|
+
const MAIL_BATCH_MAX_SIZE = 32;
|
|
875
|
+
const MAIL_BATCH_FLUSH_DELAY_MS = 8;
|
|
869
876
|
|
|
870
877
|
interface DecodedMessagePlaintext {
|
|
871
878
|
extra?: null | string | undefined;
|
|
@@ -873,6 +880,27 @@ interface DecodedMessagePlaintext {
|
|
|
873
880
|
retentionHintDays?: number | undefined;
|
|
874
881
|
}
|
|
875
882
|
|
|
883
|
+
interface PendingMailBatchDelivery {
|
|
884
|
+
header: Uint8Array;
|
|
885
|
+
mail: MailWS;
|
|
886
|
+
msg: ResourceMsg;
|
|
887
|
+
reject: (err: unknown) => void;
|
|
888
|
+
resolve: () => void;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const mailBatchResponseSchema = z.object({
|
|
892
|
+
results: z.array(
|
|
893
|
+
z.object({
|
|
894
|
+
error: z.string().optional(),
|
|
895
|
+
index: z.number().int().nonnegative(),
|
|
896
|
+
mailID: z.string().optional(),
|
|
897
|
+
ok: z.boolean(),
|
|
898
|
+
recipient: z.string().optional(),
|
|
899
|
+
status: z.number().int().optional(),
|
|
900
|
+
}),
|
|
901
|
+
),
|
|
902
|
+
});
|
|
903
|
+
|
|
876
904
|
function decodeMessageBlob(body: string): DecodedMessagePlaintext {
|
|
877
905
|
if (!body.startsWith(MESSAGE_BLOB_PREFIX)) {
|
|
878
906
|
return { message: body };
|
|
@@ -1406,16 +1434,15 @@ export class Client {
|
|
|
1406
1434
|
* Passkey ("recovery credential") methods.
|
|
1407
1435
|
*
|
|
1408
1436
|
* Passkeys are an account-bound second-class credential that can
|
|
1409
|
-
* authenticate the owning user, list devices, delete devices,
|
|
1410
|
-
*
|
|
1411
|
-
*
|
|
1437
|
+
* authenticate the owning user, list devices, delete devices, recover a
|
|
1438
|
+
* pending device enrollment, and reject pending device-enrollment
|
|
1439
|
+
* requests. They cannot send/decrypt mail.
|
|
1412
1440
|
*
|
|
1413
1441
|
* The host app drives the WebAuthn ceremony (e.g. via
|
|
1414
1442
|
* `@simplewebauthn/browser`) and hands the JSON response to
|
|
1415
1443
|
* `finish*`.
|
|
1416
1444
|
*/
|
|
1417
1445
|
public passkeys: Passkeys = {
|
|
1418
|
-
approveDeviceRequest: this.passkeyApproveDeviceRequest.bind(this),
|
|
1419
1446
|
beginAuthentication: this.beginPasskeyAuthentication.bind(this),
|
|
1420
1447
|
beginRegistration: this.beginPasskeyRegistration.bind(this),
|
|
1421
1448
|
delete: this.deletePasskey.bind(this),
|
|
@@ -1424,6 +1451,7 @@ export class Client {
|
|
|
1424
1451
|
finishRegistration: this.finishPasskeyRegistration.bind(this),
|
|
1425
1452
|
list: this.listPasskeys.bind(this),
|
|
1426
1453
|
listDevices: this.passkeyListDevices.bind(this),
|
|
1454
|
+
recoverDeviceRequest: this.passkeyRecoverDeviceRequest.bind(this),
|
|
1427
1455
|
rejectDeviceRequest: this.passkeyRejectDeviceRequest.bind(this),
|
|
1428
1456
|
};
|
|
1429
1457
|
|
|
@@ -1557,6 +1585,9 @@ export class Client {
|
|
|
1557
1585
|
private localMessageRetentionDays: number;
|
|
1558
1586
|
private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
|
|
1559
1587
|
null;
|
|
1588
|
+
private mailBatchFlushTimer: null | ReturnType<typeof setTimeout> = null;
|
|
1589
|
+
private readonly mailBatchQueue: PendingMailBatchDelivery[] = [];
|
|
1590
|
+
private mailBatchUnsupported = false;
|
|
1560
1591
|
private readonly mailInterval?: NodeJS.Timeout;
|
|
1561
1592
|
|
|
1562
1593
|
private manuallyClosing: boolean = false;
|
|
@@ -1879,6 +1910,14 @@ export class Client {
|
|
|
1879
1910
|
if (this.mailInterval) {
|
|
1880
1911
|
clearInterval(this.mailInterval);
|
|
1881
1912
|
}
|
|
1913
|
+
if (this.mailBatchFlushTimer) {
|
|
1914
|
+
clearTimeout(this.mailBatchFlushTimer);
|
|
1915
|
+
this.mailBatchFlushTimer = null;
|
|
1916
|
+
}
|
|
1917
|
+
const pendingMailBatch = this.mailBatchQueue.splice(0);
|
|
1918
|
+
for (const pending of pendingMailBatch) {
|
|
1919
|
+
pending.reject(new Error("Client closed before mail batch sent."));
|
|
1920
|
+
}
|
|
1882
1921
|
if (this.localRetentionPurgeTimer) {
|
|
1883
1922
|
clearInterval(this.localRetentionPurgeTimer);
|
|
1884
1923
|
this.localRetentionPurgeTimer = null;
|
|
@@ -2674,7 +2713,8 @@ export class Client {
|
|
|
2674
2713
|
// my keys
|
|
2675
2714
|
const IK_A = this.xKeyRing.identityKeys.secretKey;
|
|
2676
2715
|
const IK_AP = this.xKeyRing.identityKeys.publicKey;
|
|
2677
|
-
const
|
|
2716
|
+
const ephemeralKeys = await xBoxKeyPairAsync();
|
|
2717
|
+
const EK_A = ephemeralKeys.secretKey;
|
|
2678
2718
|
|
|
2679
2719
|
const fips = this.cryptoProfile === "fips";
|
|
2680
2720
|
// their keys — FIPS: `signKey` in bundle is the peer P-256 ECDH identity (raw, typically 65B).
|
|
@@ -2732,13 +2772,13 @@ export class Client {
|
|
|
2732
2772
|
const cipher = await xSecretboxAsync(message, nonce, SK);
|
|
2733
2773
|
|
|
2734
2774
|
const signKeyWire = fips ? IK_AP : this.signKeys.publicKey;
|
|
2735
|
-
const ephKeyWire =
|
|
2775
|
+
const ephKeyWire = ephemeralKeys.publicKey;
|
|
2736
2776
|
|
|
2737
2777
|
const extra = fips
|
|
2738
2778
|
? encodeFipsInitialExtraV1(signKeyWire, ephKeyWire, PK, AD, IDX)
|
|
2739
2779
|
: xConcat(
|
|
2740
2780
|
this.signKeys.publicKey,
|
|
2741
|
-
|
|
2781
|
+
ephemeralKeys.publicKey,
|
|
2742
2782
|
PK,
|
|
2743
2783
|
AD,
|
|
2744
2784
|
IDX,
|
|
@@ -2768,9 +2808,6 @@ export class Client {
|
|
|
2768
2808
|
type: "resource",
|
|
2769
2809
|
};
|
|
2770
2810
|
|
|
2771
|
-
// discard the ephemeral keys
|
|
2772
|
-
await this.newEphemeralKeys();
|
|
2773
|
-
|
|
2774
2811
|
const ratchet = await initRatchetSession(SK, "initiator");
|
|
2775
2812
|
const sessionEntry: SessionSQL = {
|
|
2776
2813
|
...ratchet,
|
|
@@ -2816,37 +2853,7 @@ export class Client {
|
|
|
2816
2853
|
this.emitter.emit("message", emitMsg);
|
|
2817
2854
|
}
|
|
2818
2855
|
|
|
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
|
-
});
|
|
2856
|
+
await this.deliverMailResource(msg, hmac, mail);
|
|
2850
2857
|
});
|
|
2851
2858
|
}
|
|
2852
2859
|
|
|
@@ -2886,6 +2893,59 @@ export class Client {
|
|
|
2886
2893
|
private async deleteServer(serverID: string): Promise<void> {
|
|
2887
2894
|
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
2888
2895
|
}
|
|
2896
|
+
private deliverMailResource(
|
|
2897
|
+
msg: ResourceMsg,
|
|
2898
|
+
header: Uint8Array,
|
|
2899
|
+
mail: MailWS,
|
|
2900
|
+
): Promise<void> {
|
|
2901
|
+
if (this.mailBatchUnsupported) {
|
|
2902
|
+
return this.deliverMailResourceOverSocket(msg, header);
|
|
2903
|
+
}
|
|
2904
|
+
return new Promise<void>((resolve, reject) => {
|
|
2905
|
+
this.mailBatchQueue.push({
|
|
2906
|
+
header,
|
|
2907
|
+
mail,
|
|
2908
|
+
msg,
|
|
2909
|
+
reject,
|
|
2910
|
+
resolve,
|
|
2911
|
+
});
|
|
2912
|
+
if (this.mailBatchQueue.length >= MAIL_BATCH_MAX_SIZE) {
|
|
2913
|
+
void this.flushMailBatchQueue();
|
|
2914
|
+
} else {
|
|
2915
|
+
this.scheduleMailBatchFlush();
|
|
2916
|
+
}
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
private async deliverMailResourceOverSocket(
|
|
2920
|
+
msg: ResourceMsg,
|
|
2921
|
+
header: Uint8Array,
|
|
2922
|
+
): Promise<void> {
|
|
2923
|
+
await new Promise<void>((res, rej) => {
|
|
2924
|
+
const callback = (packedMsg: Uint8Array) => {
|
|
2925
|
+
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
2926
|
+
if (receivedMsg.transmissionID === msg.transmissionID) {
|
|
2927
|
+
this.socket.off("message", callback);
|
|
2928
|
+
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
2929
|
+
if (parsed.success && parsed.data.type === "success") {
|
|
2930
|
+
res();
|
|
2931
|
+
} else {
|
|
2932
|
+
rej(
|
|
2933
|
+
new Error(
|
|
2934
|
+
"Mail delivery failed: " +
|
|
2935
|
+
JSON.stringify(receivedMsg),
|
|
2936
|
+
),
|
|
2937
|
+
);
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
};
|
|
2941
|
+
this.socket.on("message", callback);
|
|
2942
|
+
this.send(msg, header).catch((err: unknown) => {
|
|
2943
|
+
this.socket.off("message", callback);
|
|
2944
|
+
rej(err instanceof Error ? err : new Error(String(err)));
|
|
2945
|
+
});
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2889
2949
|
private deviceListFailureDetail(err: unknown): string {
|
|
2890
2950
|
if (!isHttpError(err)) {
|
|
2891
2951
|
return "";
|
|
@@ -2899,6 +2959,7 @@ export class Client {
|
|
|
2899
2959
|
}
|
|
2900
2960
|
return "";
|
|
2901
2961
|
}
|
|
2962
|
+
|
|
2902
2963
|
/**
|
|
2903
2964
|
* Gets a list of permissions for a server.
|
|
2904
2965
|
*
|
|
@@ -3053,6 +3114,84 @@ export class Client {
|
|
|
3053
3114
|
return decodeHttpResponse(PasskeyCodec, response.data);
|
|
3054
3115
|
}
|
|
3055
3116
|
|
|
3117
|
+
private async flushMailBatchOverSocket(
|
|
3118
|
+
batch: PendingMailBatchDelivery[],
|
|
3119
|
+
): Promise<void> {
|
|
3120
|
+
await Promise.all(
|
|
3121
|
+
batch.map(async (item) => {
|
|
3122
|
+
try {
|
|
3123
|
+
await this.deliverMailResourceOverSocket(
|
|
3124
|
+
item.msg,
|
|
3125
|
+
item.header,
|
|
3126
|
+
);
|
|
3127
|
+
item.resolve();
|
|
3128
|
+
} catch (err: unknown) {
|
|
3129
|
+
item.reject(err);
|
|
3130
|
+
}
|
|
3131
|
+
}),
|
|
3132
|
+
);
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
private async flushMailBatchQueue(): Promise<void> {
|
|
3136
|
+
if (this.mailBatchFlushTimer) {
|
|
3137
|
+
clearTimeout(this.mailBatchFlushTimer);
|
|
3138
|
+
this.mailBatchFlushTimer = null;
|
|
3139
|
+
}
|
|
3140
|
+
const batch = this.mailBatchQueue.splice(0, MAIL_BATCH_MAX_SIZE);
|
|
3141
|
+
if (this.mailBatchQueue.length > 0) {
|
|
3142
|
+
this.scheduleMailBatchFlush();
|
|
3143
|
+
}
|
|
3144
|
+
if (batch.length === 0) {
|
|
3145
|
+
return;
|
|
3146
|
+
}
|
|
3147
|
+
if (this.mailBatchUnsupported) {
|
|
3148
|
+
await this.flushMailBatchOverSocket(batch);
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
try {
|
|
3153
|
+
const response = await this.http.post(
|
|
3154
|
+
this.getHost() + "/mail/batch",
|
|
3155
|
+
msgpack.encode({
|
|
3156
|
+
mails: batch.map((item) => ({
|
|
3157
|
+
header: item.header,
|
|
3158
|
+
mail: item.mail,
|
|
3159
|
+
})),
|
|
3160
|
+
}),
|
|
3161
|
+
{ headers: { "Content-Type": "application/msgpack" } },
|
|
3162
|
+
);
|
|
3163
|
+
const decoded = mailBatchResponseSchema.parse(
|
|
3164
|
+
msgpack.decode(new Uint8Array(response.data)),
|
|
3165
|
+
);
|
|
3166
|
+
const resultsByIndex = new Map(
|
|
3167
|
+
decoded.results.map((result) => [result.index, result]),
|
|
3168
|
+
);
|
|
3169
|
+
for (const [index, item] of batch.entries()) {
|
|
3170
|
+
const result = resultsByIndex.get(index);
|
|
3171
|
+
if (result?.ok === true) {
|
|
3172
|
+
item.resolve();
|
|
3173
|
+
continue;
|
|
3174
|
+
}
|
|
3175
|
+
item.reject(
|
|
3176
|
+
new Error(
|
|
3177
|
+
"Mail delivery failed: " +
|
|
3178
|
+
(result?.error ??
|
|
3179
|
+
`missing batch result for index ${String(index)}`),
|
|
3180
|
+
),
|
|
3181
|
+
);
|
|
3182
|
+
}
|
|
3183
|
+
} catch (err: unknown) {
|
|
3184
|
+
if (isHttpError(err) && err.response?.status === 404) {
|
|
3185
|
+
this.mailBatchUnsupported = true;
|
|
3186
|
+
await this.flushMailBatchOverSocket(batch);
|
|
3187
|
+
return;
|
|
3188
|
+
}
|
|
3189
|
+
for (const item of batch) {
|
|
3190
|
+
item.reject(err);
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3056
3195
|
private async forward(message: Message) {
|
|
3057
3196
|
if (this.isManualCloseInFlight()) {
|
|
3058
3197
|
return;
|
|
@@ -3076,22 +3215,34 @@ export class Client {
|
|
|
3076
3215
|
this.getUser().userID,
|
|
3077
3216
|
"own",
|
|
3078
3217
|
);
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3218
|
+
const targetDevices = devices.filter(
|
|
3219
|
+
(device) => device.deviceID !== this.getDevice().deviceID,
|
|
3220
|
+
);
|
|
3221
|
+
for (
|
|
3222
|
+
let index = 0;
|
|
3223
|
+
index < targetDevices.length;
|
|
3224
|
+
index += MAIL_FANOUT_CONCURRENCY
|
|
3225
|
+
) {
|
|
3226
|
+
const batch = targetDevices.slice(
|
|
3227
|
+
index,
|
|
3228
|
+
index + MAIL_FANOUT_CONCURRENCY,
|
|
3229
|
+
);
|
|
3230
|
+
await Promise.all(
|
|
3231
|
+
batch.map(async (device) => {
|
|
3232
|
+
try {
|
|
3233
|
+
await this.sendMailWithRecovery(
|
|
3234
|
+
device,
|
|
3235
|
+
this.getUser(),
|
|
3236
|
+
msgBytes,
|
|
3237
|
+
null,
|
|
3238
|
+
copy.mailID,
|
|
3239
|
+
true,
|
|
3240
|
+
);
|
|
3241
|
+
} catch {
|
|
3242
|
+
/* best-effort per device */
|
|
3243
|
+
}
|
|
3244
|
+
}),
|
|
3245
|
+
);
|
|
3095
3246
|
}
|
|
3096
3247
|
}
|
|
3097
3248
|
|
|
@@ -3467,8 +3618,6 @@ export class Client {
|
|
|
3467
3618
|
return true;
|
|
3468
3619
|
}
|
|
3469
3620
|
|
|
3470
|
-
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3471
|
-
|
|
3472
3621
|
/**
|
|
3473
3622
|
* Initializes the keyring. This must be called before anything else.
|
|
3474
3623
|
*/
|
|
@@ -3488,8 +3637,6 @@ export class Client {
|
|
|
3488
3637
|
this.emitter.emit("ready");
|
|
3489
3638
|
}
|
|
3490
3639
|
|
|
3491
|
-
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3492
|
-
|
|
3493
3640
|
private initSocket() {
|
|
3494
3641
|
try {
|
|
3495
3642
|
if (!this.token) {
|
|
@@ -3673,16 +3820,6 @@ export class Client {
|
|
|
3673
3820
|
await this.submitOTK(needs);
|
|
3674
3821
|
}
|
|
3675
3822
|
|
|
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
3823
|
/**
|
|
3687
3824
|
* Pipeline for decrypted messages — registered in `init`. After `close()` sets
|
|
3688
3825
|
* `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
|
|
@@ -3710,21 +3847,6 @@ export class Client {
|
|
|
3710
3847
|
this.scheduleRetentionPurge();
|
|
3711
3848
|
};
|
|
3712
3849
|
|
|
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
3850
|
private async passkeyDeleteDevice(deviceID: string): Promise<void> {
|
|
3729
3851
|
const userID = this.getUser().userID;
|
|
3730
3852
|
await this.http.delete(
|
|
@@ -3740,6 +3862,20 @@ export class Client {
|
|
|
3740
3862
|
return decodeHttpResponse(DeviceArrayCodec, response.data);
|
|
3741
3863
|
}
|
|
3742
3864
|
|
|
3865
|
+
private async passkeyRecoverDeviceRequest(
|
|
3866
|
+
requestID: string,
|
|
3867
|
+
): Promise<Device> {
|
|
3868
|
+
const userID = this.getUser().userID;
|
|
3869
|
+
const response = await this.http.post(
|
|
3870
|
+
this.getHost() +
|
|
3871
|
+
"/user/" +
|
|
3872
|
+
userID +
|
|
3873
|
+
"/passkey/recover/devices/requests/" +
|
|
3874
|
+
requestID,
|
|
3875
|
+
);
|
|
3876
|
+
return decodeHttpResponse(DeviceCodec, response.data);
|
|
3877
|
+
}
|
|
3878
|
+
|
|
3743
3879
|
private async passkeyRejectDeviceRequest(requestID: string): Promise<void> {
|
|
3744
3880
|
const userID = this.getUser().userID;
|
|
3745
3881
|
await this.http.post(
|
|
@@ -4818,6 +4954,16 @@ export class Client {
|
|
|
4818
4954
|
}
|
|
4819
4955
|
}
|
|
4820
4956
|
|
|
4957
|
+
private scheduleMailBatchFlush(): void {
|
|
4958
|
+
if (this.mailBatchFlushTimer) {
|
|
4959
|
+
return;
|
|
4960
|
+
}
|
|
4961
|
+
this.mailBatchFlushTimer = setTimeout(() => {
|
|
4962
|
+
this.mailBatchFlushTimer = null;
|
|
4963
|
+
void this.flushMailBatchQueue();
|
|
4964
|
+
}, MAIL_BATCH_FLUSH_DELAY_MS);
|
|
4965
|
+
}
|
|
4966
|
+
|
|
4821
4967
|
private scheduleReconnect(): void {
|
|
4822
4968
|
if (
|
|
4823
4969
|
!this.autoReconnectEnabled ||
|
|
@@ -4963,27 +5109,49 @@ export class Client {
|
|
|
4963
5109
|
|
|
4964
5110
|
let failCount = 0;
|
|
4965
5111
|
let lastErr: unknown;
|
|
4966
|
-
for (
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
5112
|
+
for (
|
|
5113
|
+
let index = 0;
|
|
5114
|
+
index < stableDevices.length;
|
|
5115
|
+
index += MAIL_FANOUT_CONCURRENCY
|
|
5116
|
+
) {
|
|
5117
|
+
const batch = stableDevices.slice(
|
|
5118
|
+
index,
|
|
5119
|
+
index + MAIL_FANOUT_CONCURRENCY,
|
|
5120
|
+
);
|
|
5121
|
+
const results = await Promise.all(
|
|
5122
|
+
batch.map(async (device): Promise<undefined | unknown> => {
|
|
5123
|
+
const ownerRecord =
|
|
5124
|
+
device.owner === myUserID
|
|
5125
|
+
? this.getUser()
|
|
5126
|
+
: this.userRecords[device.owner];
|
|
5127
|
+
if (!ownerRecord) {
|
|
5128
|
+
return new Error(
|
|
5129
|
+
`Missing owner record for device ${device.deviceID}.`,
|
|
5130
|
+
);
|
|
5131
|
+
}
|
|
5132
|
+
try {
|
|
5133
|
+
await this.sendMailWithRecovery(
|
|
5134
|
+
device,
|
|
5135
|
+
ownerRecord,
|
|
5136
|
+
msgBytes,
|
|
5137
|
+
uuidToUint8(channelID),
|
|
5138
|
+
mailID,
|
|
5139
|
+
false,
|
|
5140
|
+
);
|
|
5141
|
+
return undefined;
|
|
5142
|
+
} catch (e) {
|
|
5143
|
+
return e;
|
|
5144
|
+
}
|
|
5145
|
+
}),
|
|
5146
|
+
);
|
|
5147
|
+
for (const result of results) {
|
|
5148
|
+
if (result !== undefined) {
|
|
5149
|
+
lastErr = result;
|
|
5150
|
+
failCount += 1;
|
|
5151
|
+
}
|
|
4974
5152
|
}
|
|
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;
|
|
5153
|
+
if (failCount === stableDevices.length) {
|
|
5154
|
+
break;
|
|
4987
5155
|
}
|
|
4988
5156
|
}
|
|
4989
5157
|
|
|
@@ -5138,35 +5306,7 @@ export class Client {
|
|
|
5138
5306
|
await this.database.saveSession(persisted);
|
|
5139
5307
|
this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
|
|
5140
5308
|
|
|
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
|
-
});
|
|
5309
|
+
await this.deliverMailResource(msgb, hmac, mail);
|
|
5170
5310
|
} finally {
|
|
5171
5311
|
this.sending.delete(device.deviceID);
|
|
5172
5312
|
}
|
|
@@ -5265,38 +5405,60 @@ export class Client {
|
|
|
5265
5405
|
// One logical DM fan-outs to multiple recipient devices. Reuse a
|
|
5266
5406
|
// single mailID so local/UI dedupe treats it as one message.
|
|
5267
5407
|
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
|
-
|
|
5408
|
+
const msgBytes = XUtils.decodeUTF8(payload);
|
|
5409
|
+
for (
|
|
5410
|
+
let index = 0;
|
|
5411
|
+
index < deviceList.length;
|
|
5412
|
+
index += MAIL_FANOUT_CONCURRENCY
|
|
5413
|
+
) {
|
|
5414
|
+
const batch = deviceList.slice(
|
|
5415
|
+
index,
|
|
5416
|
+
index + MAIL_FANOUT_CONCURRENCY,
|
|
5417
|
+
);
|
|
5418
|
+
const results = await Promise.all(
|
|
5419
|
+
batch.map(async (device): Promise<undefined | unknown> => {
|
|
5420
|
+
try {
|
|
5421
|
+
if (libvexDebugDmEnabled()) {
|
|
5422
|
+
debugLibvexDm("sendMessage: sendMail start", {
|
|
5423
|
+
mailID: messageMailID,
|
|
5424
|
+
recipientDevice: device.deviceID,
|
|
5425
|
+
});
|
|
5426
|
+
}
|
|
5427
|
+
await this.sendMailWithRecovery(
|
|
5428
|
+
device,
|
|
5429
|
+
userEntry,
|
|
5430
|
+
msgBytes,
|
|
5431
|
+
null,
|
|
5432
|
+
messageMailID,
|
|
5433
|
+
false,
|
|
5434
|
+
);
|
|
5435
|
+
if (libvexDebugDmEnabled()) {
|
|
5436
|
+
debugLibvexDm("sendMessage: sendMail ok", {
|
|
5437
|
+
recipientDevice: device.deviceID,
|
|
5438
|
+
});
|
|
5439
|
+
}
|
|
5440
|
+
return undefined;
|
|
5441
|
+
} catch (e) {
|
|
5442
|
+
if (libvexDebugDmEnabled()) {
|
|
5443
|
+
// eslint-disable-next-line no-console -- LIBVEX_DEBUG_DM only
|
|
5444
|
+
console.error(
|
|
5445
|
+
"[libvex:debug-dm] sendMessage: sendMail failed for device",
|
|
5446
|
+
device.deviceID,
|
|
5447
|
+
e,
|
|
5448
|
+
);
|
|
5449
|
+
}
|
|
5450
|
+
return e;
|
|
5451
|
+
}
|
|
5452
|
+
}),
|
|
5453
|
+
);
|
|
5454
|
+
for (const result of results) {
|
|
5455
|
+
if (result !== undefined) {
|
|
5456
|
+
lastErr = result;
|
|
5457
|
+
failCount += 1;
|
|
5297
5458
|
}
|
|
5298
|
-
|
|
5299
|
-
|
|
5459
|
+
}
|
|
5460
|
+
if (failCount === deviceList.length) {
|
|
5461
|
+
break;
|
|
5300
5462
|
}
|
|
5301
5463
|
}
|
|
5302
5464
|
if (failCount > 0) {
|